Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm

---

## [0.4.0] — 2026-09-30

### Added

- Per-request proxy passthrough. An optional `X-PSX-Proxy` header (`http://`, `socks5://` or `socks5h://`, with an explicit port and optional `user:pass@`) makes the API fetch that request's PSX data, including the request token, through the caller's proxy, using psxdata 1.2.0's proxy support. Off by default. Enable it with `PSX_PROXY_PASSTHROUGH=true`.
- Hardening against server-side request forgery (SSRF) for the proxy:
- the host must resolve only to public addresses, and the connection is pinned to the checked IP (defeats DNS rebinding);
- `https://` proxies and ports below 1024 (other than 80 and 443) are refused;
- a 5-second TCP check runs first (`502 proxy_unreachable` on failure);
- limits of 10 proxied PSX fetches per minute per IP and 4 concurrent server-wide;
- a bounded pool of proxied clients.
- The proxy is only used on a cache miss: cached data is served without resolving or contacting the caller's proxy, and cache hits don't count toward the proxy rate limit. A malformed header, or one sent while the feature is off, is still rejected on every request.
- Proxied requests use the same caches as all other requests. The proxy only changes the route to PSX: cached data is served without contacting PSX, and data fetched through a proxy is cached for everyone. PSX is HTTPS-only with certificate verification, so a proxy cannot alter what it relays.
- Proxy credentials are never logged or echoed in error messages.

### Changed

- Requires `psxdata[socks]==1.2.0` (was `psxdata==1.1.1`).

---

## [0.3.0] — 2026-09-30

### Added
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ Every response wraps its payload in a consistent envelope.
| 404 | `not_found` | Symbol or index does not exist |
| 429 | `rate_limited` | Exceeded 60 requests/minute per IP |
| 502 | `upstream_data_error` | Upstream PSX data failed validation |
| 502 | `proxy_unreachable` | The `X-PSX-Proxy` you sent did not accept a connection |
| 503 | `psx_unavailable` | PSX website unreachable, or PSX is rate-limiting the API (sent with a `Retry-After` header) |
| 500 | `internal_error` | Unexpected server error |

Expand Down Expand Up @@ -146,6 +147,26 @@ Every `/historical` response says where it came from:

---

## Proxy Passthrough

Send an `X-PSX-Proxy` header to have the API fetch that request's PSX data through your own proxy, using psxdata's [proxy support](https://psxdata.mintlify.app/sdk/guides/proxy). The request token is fetched through the same proxy.

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

The proxy is only used when PSX has to be contacted: if the data is already in the API's cache, it's served straight from there and your proxy is never touched. When PSX must be fetched, the server connects to an address you choose, so the proxy is checked first:

- **Only when enabled.** The server operator has to turn it on (`PSX_PROXY_PASSTHROUGH`). Otherwise the header is rejected with `400`.
- **Schemes:** `http://`, `socks5://` or `socks5h://`, with an explicit port (80, 443, or 1024–65535) and optional `user:pass@`. `https://` proxies are not accepted.
- **Public addresses only.** The proxy host must resolve only to public internet addresses. Loopback, private, link-local (including cloud metadata), CGNAT and multicast addresses are rejected. The connection is pinned to the checked IP, so the name cannot be re-pointed afterwards.
- **Quick reachability check.** On a cache miss, a proxy that doesn't accept a TCP connection within 5 seconds returns `502 proxy_unreachable`.
- **Same cache as everyone else.** The proxy only changes the route to PSX, not the data. Proxied requests are served from the cache when it's fresh, and what they fetch is cached for all callers. PSX is HTTPS-only and certificates are verified, so a proxy tunnels encrypted traffic and cannot alter it.
- **Stricter limits on PSX fetches.** Requests that go through your proxy to PSX are limited to 10 per minute per IP, with at most 4 in progress server-wide (`429` beyond that). Requests answered from the cache don't count toward this, only toward the normal limit.
- **Credentials are never logged or echoed back.** Send the proxy only in the header, never in the URL.

---

## Configuration

All settings are optional environment variables.
Expand All @@ -154,6 +175,7 @@ All settings are optional environment variables.
| -------- | ------- | ----------- |
| `REDIS_URL` | unset | Redis-compatible server for the `/historical` cache, e.g. an [Aiven for Valkey](https://aiven.io/valkey) service URI (`rediss://default:<password>@<host>:<port>`). Keeps the cache across restarts and instances. When unset, or when the server is unreachable, the API falls back to an in-memory cache and keeps working. |
| `HISTORICAL_CACHE_MARKET_TTL` | `1800` | How long, in seconds, `/historical` data stays fresh during PSX trading hours. |
| `PSX_PROXY_PASSTHROUGH` | off | Set to `true` to honour the per-request `X-PSX-Proxy` header (see [Proxy Passthrough](#proxy-passthrough)). |

```bash
docker run -p 8000:8000 -e REDIS_URL="rediss://default:<password>@<host>:<port>" mtauha/psxdata-api
Expand Down
10 changes: 10 additions & 0 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from api.cache.factory import build_historical_service
from api.cache.historical import COOLDOWN_MESSAGE, PSX_COOLDOWN_SECONDS
from api.dependencies import limiter
from api.proxy import ProxyPassthrough, ProxyUnreachableError
from api.routers import router_registry

logger = logging.getLogger("api")
Expand All @@ -30,6 +31,7 @@ async def lifespan(app: FastAPI):
"""Build the /historical cache on startup and close its backend on shutdown."""
service = build_historical_service(os.environ)
app.state.historical_service = service
app.state.proxy_passthrough = ProxyPassthrough.from_env(os.environ)
try:
yield
finally:
Expand Down Expand Up @@ -92,6 +94,14 @@ async def psx_rate_limit_handler(request: Request, exc: PSXRateLimitError) -> JS
)


@app.exception_handler(ProxyUnreachableError)
async def proxy_unreachable_handler(request: Request, exc: ProxyUnreachableError) -> JSONResponse:
return JSONResponse(
status_code=502,
content={"error": {"status": 502, "code": "proxy_unreachable", "message": str(exc)}},
)


@app.exception_handler(InvalidSymbolError)
async def invalid_symbol_handler(request: Request, exc: InvalidSymbolError) -> JSONResponse:
return JSONResponse(
Expand Down
Loading
Loading