How Unauthenticated Proxy Endpoints Enable DoS Amplification in FastAPI and how to fix it
Introduction
The backend/api/proxy.py file is the heart of a tile-serving and coordinate-conversion service. It proxies OpenStreetMap tiles, converts between GCJ-02 and WGS-84 coordinate systems, and serves AIS ship-tracking tiles — all through a shared httpx async HTTP client capped at 100 concurrent connections. Every one of those routes was publicly reachable with zero rate limiting, and that made the entire connection pool a single attacker request-flood away from total exhaustion.
This post walks through exactly how that amplification path worked, what the fix does at the code level, and how to apply the same pattern in your own FastAPI services.
The Vulnerability Explained
What the exposed surface looked like
Before the fix, four route families in proxy.py were reachable without authentication and without any throttling:
GET /proxy/{target_url:path}
GET /proxy/gcj2wgs/{target_url:path}
GET /proxy/wgs2gcj/{target_url:path}
GET /tiles/ships66/{z}/{x}/{y}.png
Each handler resolved the caller-supplied URL, validated it wasn't a private-network address (the PROXY_ALLOW_PRIVATE_HOSTS guard), then fired off an httpx request using the shared client. The shared client was configured with a connection pool ceiling — 100 max connections — which is entirely reasonable for normal traffic but becomes a hard denial-of-service threshold the moment an attacker can generate load faster than the pool can recycle connections.
The amplification math
Consider the tile endpoint:
GET /proxy/tile.openstreetmap.org/{z}/{x}/{y}.png
A single tile request at zoom level 18 can take 200–800 ms to complete (network round-trip to OSM plus tile decoding). If an attacker opens 100 concurrent connections and keeps them saturated, the pool is full. Every legitimate user request after that receives a connection-pool timeout or a 503. The attacker's cost: a modest HTTP flood script. The victim's cost: complete service unavailability for all users.
The /tiles/ships66/{z}/{x}/{y}.png endpoint is even more attractive because it fetches from a specialized AIS data provider that may have its own rate limits — meaning the amplification can simultaneously exhaust the local pool and trigger upstream bans.
The specific lines that were missing
Looking at the original route definitions (around line 165 in proxy.py), there was no Depends(...) argument carrying any throttling logic:
# BEFORE — no rate limiting dependency
@router.get("/proxy/{target_url:path}")
async def proxy_request(target_url: str, request: Request):
...
@router.get("/tiles/ships66/{z}/{x}/{y}.png")
async def ships66_tile(z: int, x: int, y: int, request: Request):
...
No Depends, no counter, no sliding window — just an open door.
Real-world impact for this application
This isn't a theoretical risk. Map-tile proxies are a well-known amplification target because:
- Tile requests are cacheable but not always cached — a cache miss forces a full upstream fetch.
- Coordinate-conversion endpoints (
gcj2wgs,wgs2gcj) perform CPU-bound math and an outbound HTTP call, making each request more expensive than a simple passthrough. - The
httpxconnection pool is shared across all four route families, so flooding one endpoint degrades all of them simultaneously.
The Fix
New helper: _get_client_ip()
The fix starts by reliably identifying the caller, even behind a reverse proxy:
def _get_client_ip(request: Request) -> str:
"""获取真实客户端 IP,兼容 Nginx/反向代理"""
x_forwarded_for = request.headers.get("X-Forwarded-For")
if x_forwarded_for:
return x_forwarded_for.split(",")[0].strip()
x_real_ip = request.headers.get("X-Real-IP")
if x_real_ip:
return x_real_ip.strip()
return request.client.host if request.client else "unknown"
This correctly handles X-Forwarded-For chains (taking the leftmost — i.e., the original client — IP), X-Real-IP headers set by Nginx, and direct connections. Without this, a single attacker behind a load balancer could appear as many IPs, or — worse — all traffic could appear to come from the proxy's IP and the rate limiter would block everyone.
Sliding-window rate limiter
PROXY_RATE_LIMIT = int(os.getenv("PROXY_RATE_LIMIT", "0"))
_rate_limit_store: Dict[str, List[float]] = defaultdict(list)
_last_clean_time = time.time()
def _rate_limit_check(request: Request) -> None:
global _last_clean_time
if PROXY_RATE_LIMIT <= 0:
return
ip = _get_client_ip(request)
now = time.time()
window_start = now - 60.0
# ... prune old timestamps, count recent ones, raise 429 if over limit
Key design decisions worth noting:
| Decision | Rationale |
|---|---|
defaultdict(list) of timestamps |
O(1) insertion; pruning old entries keeps memory bounded |
| 60-second sliding window | Matches the "per minute" mental model operators expect |
PROXY_RATE_LIMIT=0 disables throttling |
Safe default for local dev; ops sets it in production |
global _last_clean_time + periodic sweep |
Prevents unbounded growth of the IP store over time |
Before vs. After
Before — route with no throttling:
from fastapi import APIRouter, HTTPException, Request, BackgroundTasks
# ...
@router.get("/proxy/{target_url:path}")
async def proxy_request(target_url: str, request: Request):
# directly proxies — no rate check
After — rate-limit dependency injected:
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request
# ...
@router.get("/proxy/{target_url:path}")
async def proxy_request(
target_url: str,
request: Request,
_: None = Depends(_rate_limit_check), # <-- throttle gate
):
# only reaches here if under the rate limit
The Depends(_rate_limit_check) pattern is idiomatic FastAPI: the dependency runs before the handler body, and if it raises HTTPException(429), FastAPI short-circuits the request cleanly without touching the httpx pool at all.
Prevention & Best Practices
1. Always attach rate limiting to unauthenticated proxy routes
Any endpoint that makes outbound network calls on behalf of a caller is a potential amplification vector. Apply rate limiting as a Depends() on every such route — not as middleware, which can be bypassed by route-level overrides.
2. Use environment-variable-driven thresholds
Hard-coding 60 req/min bakes an operational decision into source code. The PROXY_RATE_LIMIT env-var approach lets SREs tune the threshold per environment without a code deploy.
3. Separate the connection pool from the rate limiter
Rate limiting at the FastAPI layer protects the httpx pool indirectly. For defense in depth, also configure httpx limits explicitly:
limits = httpx.Limits(max_connections=100, max_keepalive_connections=20)
client = httpx.AsyncClient(limits=limits, timeout=10.0)
An explicit timeout prevents slow-loris-style attacks from holding connections open indefinitely.
4. Consider Redis-backed rate limiting for multi-process deployments
The in-process defaultdict store works for a single-worker deployment. If you run multiple Uvicorn workers or deploy across several pods, each worker has its own counter and the effective limit multiplies by the worker count. Libraries like slowapi (a FastAPI-compatible port of Flask-Limiter) support Redis backends for shared state.
5. Return Retry-After headers with 429 responses
raise HTTPException(
status_code=429,
detail="Rate limit exceeded",
headers={"Retry-After": "60"},
)
This is required by RFC 6585 and helps legitimate clients back off gracefully rather than hammering the endpoint.
OWASP & CWE alignment
- CWE-770: Allocation of Resources Without Limits or Throttling
- OWASP API Security Top 10 — API4:2023: Unrestricted Resource Consumption
- OWASP Cheat Sheet: Denial of Service Cheat Sheet
Key Takeaways
- Proxy endpoints that make outbound HTTP calls are amplification vectors by definition — the
/proxy/{target_url:path}route inproxy.pyforwarded every unauthenticated request directly to external tile providers, multiplying attacker effort into upstream load. - A shared
httpxconnection pool is a single point of exhaustion — all four proxy route families shared the same 100-connection pool, meaning flooding any one of them degraded all of them. Depends(_rate_limit_check)is the correct FastAPI pattern — attaching throttling as a dependency ensures it runs before any handler logic and cannot be accidentally omitted on new routes that share the same router._get_client_ip()must handleX-Forwarded-Forcorrectly — takingsplit(",")[0](the original client) rather than the last entry (the proxy) prevents both IP spoofing and accidental rate-limiting of the reverse proxy's IP.PROXY_RATE_LIMIT=0as the default is a conscious trade-off — it keeps local development frictionless while requiring operators to explicitly opt in to enforcement in production, which should be documented in the deployment runbook.
How Orbis AppSec Detected This
- Source: Unauthenticated HTTP requests arriving at
/proxy/{target_url:path}and/tiles/ships66/{z}/{x}/{y}.png— no session, token, or API key required. - Sink: The
httpxasync client call inside each proxy handler inbackend/api/proxy.py:165, which unconditionally forwarded the request to the caller-supplied external URL. - Missing control: No rate-limiting dependency, no per-IP counter, and no connection-pool guard at the route layer. The only existing validation was the private-IP block (
PROXY_ALLOW_PRIVATE_HOSTS), which protects against SSRF but does nothing to limit request volume. - CWE: CWE-770 — Allocation of Resources Without Limits or Throttling.
- Fix: A sliding-window
_rate_limit_check()function was introduced and injected as aDepends()argument on all four proxy route families, returning HTTP 429 when a single IP exceedsPROXY_RATE_LIMITrequests per 60-second window.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
Unauthenticated proxy endpoints are deceptively dangerous. The private-IP SSRF guard in proxy.py gave a false sense of security — it prevented the proxy from being weaponized against internal infrastructure, but left the connection pool completely exposed to volumetric abuse. The fix is elegantly minimal: a 40-line sliding-window rate limiter, a reliable IP-extraction helper, and a single Depends() call per route. That's the FastAPI way — composable, testable, and hard to accidentally omit. If your service proxies anything on behalf of unauthenticated callers, audit your routes today.
References
- CWE-770: Allocation of Resources Without Limits or Throttling
- OWASP API Security Top 10 — API4:2023 Unrestricted Resource Consumption
- OWASP Denial of Service Cheat Sheet
- FastAPI Dependencies documentation
- httpx Connection Pool configuration
- Semgrep rules: missing rate limiting
- fix: public proxy endpoints (/proxy/{target_url:path... in proxy.py