Repository navigation
feat: per-request PSX proxy passthrough (X-PSX-Proxy) with SSRF hardening (0.4.0) - #6
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-Proxyrequest header routes that request's PSX traffic, including theX-Req-Idtoken fetch, through the caller's proxy. psxdata'sPSXClient(proxy=...)does the routing.curl -H "X-PSX-Proxy: http://user:pass@proxy.example.com:8080" \ https://psxdata-api.fastapicloud.dev/stocks/ENGRO/quoteCache 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 with400.SSRF hardening
The server opens a connection to an address the caller chooses, so
api/proxy.pychecks every proxy before use:https://proxieshttp,socks5andsocks5h, with an explicit port of 80, 443 or 1024–65535. No path, query or fragment. At most 2048 characters.http/socksproxy only tunnels TLS and cannot alter data. Proxied requests therefore share the caches (both the/historicalRedis/memory cache and the SDK disk cache): they read fresh cached data without touching PSX, and what they fetch is cached for everyone.429). A 5-second TCP check rejects dead proxies (502 proxy_unreachable) before the 30 s × 3 SDK retries can start.TokenProviderregistry is pruned to the live set, because the SDK never evicts from it.Changes
api/proxy.py(new) contains:pin_proxy;ProxyPassthrough(rate limit, concurrency slots, client pool);PsxSource;psx_sourcedependency, which declaresX-PSX-Proxyon all 10 PSX-backed endpoints in OpenAPI.psx.fetch(...)instead ofpsxdata.*(...). 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 inHistoricalServicelike any other request.main.py: buildsProxyPassthroughfrom the environment inlifespan, and adds a502 proxy_unreachablehandler.pyproject.toml: requirespsxdata[socks]==1.2.0and bumps the version to0.4.0.Testing
tests/api/test_proxy.pyhas 55 tests covering:/historicalMISS fills the shared cache (a later unproxied call HITs), and a proxied call HITs existing cache without contacting PSX;ruff check api/andmypy api/are clean.localhost,169.254.169.254and127.0.0.1.nip.ioare rejected;metadata.google.internalis unresolvable here and also rejected;example.comis allowed and pinned to its IP.After merge
To enable it on FastAPI Cloud: