Skip to content

feat: per-request PSX proxy passthrough (X-PSX-Proxy) with SSRF hardening (0.4.0) - #6

Merged
mtauha merged 3 commits into
mainfrom
feat/proxy-passthrough
Sep 30, 2026
Merged

mtauha merged 3 commits into
mainfrom
feat/proxy-passthrough

Conversation

@mtauha

@mtauha mtauha commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Brings psxdata 1.2.0's proxy support (psxdata/psxdata#162) to the API as a per-request option.

What it does

An optional X-PSX-Proxy request header routes that request's PSX traffic, including the X-Req-Id token fetch, through the caller's proxy. psxdata's PSXClient(proxy=...) does the routing.

curl -H "X-PSX-Proxy: http://user:pass@proxy.example.com:8080" \
  https://psxdata-api.fastapicloud.dev/stocks/ENGRO/quote

Cache first. If the data is already cached, it's served straight from the cache and the proxy is never resolved or contacted. Only a cache miss uses the proxy, after the checks below. Checks that need no network (feature flag, URL syntax) run on every request.

Off by default. It has to be turned on with PSX_PROXY_PASSTHROUGH=true. Until then, the header is rejected with 400.

SSRF hardening

The server opens a connection to an address the caller chooses, so api/proxy.py checks every proxy before use:

Risk Mitigation
Reaching internal services (metadata IP, platform network, the allowlisted Valkey) The host must resolve only to globally routable addresses. Loopback, RFC1918, link-local/169.254, CGNAT, multicast, unique-local, and IPv4-mapped/6to4/Teredo addresses wrapping any of those are all rejected.
DNS rebinding (check a public IP, connect to a private one) The proxy URL is rewritten to the checked IP, so the connection goes exactly where it was validated.
https:// proxies Refused, because TLS to the proxy verifies its hostname, which is incompatible with IP pinning.
Probing arbitrary services Only http, socks5 and socks5h, with an explicit port of 80, 443 or 1024–65535. No path, query or fragment. At most 2048 characters.
Proxy tampering with cached data PSX is HTTPS-only and certificates are verified, so an http/socks proxy only tunnels TLS and cannot alter data. Proxied requests therefore share the caches (both the /historical Redis/memory cache and the SDK disk cache): they read fresh cached data without touching PSX, and what they fetch is cached for everyone.
Resource exhaustion Applies to cache misses only: 10 proxied PSX fetches per minute per IP (checked before DNS resolution) and 4 concurrent server-wide (429). A 5-second TCP check rejects dead proxies (502 proxy_unreachable) before the 30 s × 3 SDK retries can start.
Memory growth At most 32 pooled clients (LRU). On eviction, psxdata's per-proxy TokenProvider registry is pruned to the live set, because the SDK never evicts from it.
Credential leakage The proxy is only accepted in a header, never a query parameter, so it stays out of request-line logs and the MotherDuck log export. 400 messages never include the URL, and SDK errors are already redacted.

Changes

  • api/proxy.py (new) contains:
    • pin_proxy;
    • ProxyPassthrough (rate limit, concurrency slots, client pool);
    • PsxSource;
    • the psx_source dependency, which declares X-PSX-Proxy on all 10 PSX-backed endpoints in OpenAPI.
  • Routers: they call psx.fetch(...) instead of psxdata.*(...). Without the header, behaviour is unchanged: the same module-level calls and the same caching.
  • /historical: misses are fetched through the caller's proxy (if given) and cached in HistoricalService like any other request.
  • main.py: builds ProxyPassthrough from the environment in lifespan, and adds a 502 proxy_unreachable handler.
  • pyproject.toml: requires psxdata[socks]==1.2.0 and bumps the version to 0.4.0.
  • The README (a new Proxy Passthrough section, error code, config variable) and the CHANGELOG are updated.

Testing

  • tests/api/test_proxy.py has 55 tests covering:
    • address classes and IPv6 wrappers;
    • malformed and disallowed URLs;
    • no credential echo;
    • disabled → 400;
    • a miss fetches through the pinned proxy, while a hit (including data cached by another caller) never resolves or contacts it;
    • cache hits don't count toward the proxy rate limit, and a malformed header is rejected even when the data is cached;
    • a proxied /historical MISS fills the shared cache (a later unproxied call HITs), and a proxied call HITs existing cache without contacting PSX;
    • proxied clients use the same SDK disk-cache directory as the default client;
    • the connect check targets the pinned IP;
    • unreachable → 502;
    • per-IP limit, concurrency cap, and slot release after an upstream error;
    • LRU eviction prunes token providers;
    • the env flag.
  • Full suite: 194 passed. ruff check api/ and mypy api/ are clean.
  • Real-DNS spot check:
    • localhost, 169.254.169.254 and 127.0.0.1.nip.io are rejected;
    • metadata.google.internal is unresolvable here and also rejected;
    • example.com is allowed and pinned to its IP.

After merge

To enable it on FastAPI Cloud:

fastapi cloud env set PSX_PROXY_PASSTHROUGH true

…dening

An optional X-PSX-Proxy header routes a request's PSX traffic, including
the request token, through the caller's proxy using psxdata 1.2.0's proxy
support. Off unless PSX_PROXY_PASSTHROUGH is enabled.

Because the server connects to a caller-chosen address, the proxy must
be http/socks5/socks5h with an explicit port (80, 443 or >= 1024), must
resolve only to public addresses, and is pinned to the checked IP to
defeat DNS rebinding. A 5s TCP check rejects dead proxies (502
proxy_unreachable). Proxied requests bypass the shared cache (X-Cache:
BYPASS on /historical), are limited to 10/min per IP and 4 concurrent,
and use a bounded client pool that also prunes psxdata's per-proxy token
providers. Credentials are never logged or echoed.

Bumps psxdata to psxdata[socks]==1.2.0 and the version to 0.4.0.
The proxy only changes the egress to PSX, not the data: PSX is HTTPS-only
with verified certificates, so a proxy cannot alter what it relays.
Proxied requests now read from and write to the same /historical cache and
SDK disk cache as unproxied ones, so caching keeps working when most
traffic is proxied. Removes X-Cache: BYPASS.
Proxied requests are answered from the shared caches first. A cache-only
client (its scrapers raise CacheMiss instead of calling PSX) reads the SDK
disk cache; /historical already calls its fetcher only on a miss. Only
then are the rate limit, DNS pinning, concurrency slot and TCP check run
and PSX fetched through the proxy. Checks that need no network (feature
flag, URL syntax) still run on every request. Cache hits no longer count
toward the proxied rate limit.
@mtauha
mtauha merged commit 2fc6038 into main Sep 30, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant