Back to Blog
critical SEVERITY8 min read

How Unauthenticated Proxy Endpoints Enable DoS Amplification in FastAPI and how to fix it

Public proxy endpoints in `backend/api/proxy.py` had no rate limiting, allowing any attacker to flood the httpx connection pool with unauthenticated requests and amplify denial-of-service attacks against downstream tile and coordinate-conversion services. The fix introduces a per-IP sliding-window rate limiter using environment-configurable thresholds, closing the amplification vector without breaking legitimate usage.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published August 26, 2026•Reviewed August 26, 2026

Answer Summary

This is a Denial-of-Service (DoS) amplification vulnerability (CWE-770: Allocation of Resources Without Limits or Throttling) in a FastAPI application's `backend/api/proxy.py`. The unauthenticated endpoints `/proxy/{target_url:path}`, `/proxy/gcj2wgs/{target_url:path}`, `/proxy/wgs2gcj/{target_url:path}`, and `/tiles/ships66/{z}/{x}/{y}.png` forwarded requests to external services without any rate limiting, letting attackers exhaust the 100-connection httpx pool. The fix adds a per-IP sliding-window rate limiter controlled by the `PROXY_RATE_LIMIT` environment variable and a `_rate_limit_check()` dependency injected into every proxy route.

Vulnerability at a Glance

cweCWE-770
fixPer-IP sliding-window rate limiter injected as a FastAPI dependency on all proxy endpoints
riskAttacker exhausts httpx connection pool, denying service to all users and amplifying load on upstream tile providers
languagePython
root causeNo rate limiting on public-facing proxy routes in proxy.py
vulnerabilityUnauthenticated Proxy Endpoint DoS Amplification

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:

  1. Tile requests are cacheable but not always cached — a cache miss forces a full upstream fetch.
  2. Coordinate-conversion endpoints (gcj2wgs, wgs2gcj) perform CPU-bound math and an outbound HTTP call, making each request more expensive than a simple passthrough.
  3. The httpx connection 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.


Key Takeaways

  • Proxy endpoints that make outbound HTTP calls are amplification vectors by definition — the /proxy/{target_url:path} route in proxy.py forwarded every unauthenticated request directly to external tile providers, multiplying attacker effort into upstream load.
  • A shared httpx connection 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 handle X-Forwarded-For correctly — taking split(",")[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=0 as 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 httpx async client call inside each proxy handler in backend/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 a Depends() argument on all four proxy route families, returning HTTP 429 when a single IP exceeds PROXY_RATE_LIMIT requests 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.


Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #13

Related Articles

critical

CVE-2026-59873: node-tar 7.5.11 DoS via Crafted Gzip Bomb

node-tar versions 7.5.11 through 7.5.18 are vulnerable to a denial-of-service attack through maliciously crafted gzip archives that decompress to disproportionately large sizes. An attacker can exploit this to exhaust memory and CPU resources by submitting a small, highly compressed archive that expands beyond configured limits during extraction.

high

MapManager.get() Race Condition Duplicates API Requests

The MapManager's `get(mapUid, cache)` method used a check-then-act pattern that permitted multiple concurrent requests to pass the cache miss check simultaneously, triggering redundant API calls and risking cache corruption. The fix introduces a `_pending` promise map to deduplicate in-flight fetches for identical map UIDs.

critical

Lampa Desktop Auto-Update Heuristic Bypass: Execution of Unverified

Lampa Desktop's auto-update mechanism downloaded JavaScript and CSS from `raw.githubusercontent.com` using only heuristic validation—file size thresholds and string pattern matching—that attackers could trivially satisfy. The fix introduces cryptographic integrity verification by cross-referencing Git blob hashes from the GitHub Contents API, ensuring downloaded code matches the repository's authoritative state before execution.

high

adm-zip 0.6.0 Preserves SUID Bits From ZIPs: CVE-2026-102282

The `adm-zip` dependency resolved to 0.6.0 in this project's dependency tree, a version affected by CVE-2026-102282: during extraction it applies the Unix permission bits stored in each ZIP entry's external file attributes verbatim, including the setuid (`04000`), setgid (`02000`), and sticky bits. An attacker who controls an archive passed to `extractAllTo()` or `extractEntryTo()` can therefore have the extractor create a setuid binary owned by whatever user the extraction process runs as. The

high

requestInput() Type Confusion: NaN and Object Bypass in JavaScript

The `requestInput()` utility function lacked validation on its `type` parameter and failed to handle `NaN` results from float conversions, creating a type confusion weakness. An attacker could supply malformed inputs that propagate unhandled `NaN` values or unexpected object types through the type system. The fix adds explicit guards against `NaN` type parameters and rejects non-primitive type values.

critical

No Rate Limit on /api/uploads/presign Enables DoS

The `/api/uploads/presign` endpoint accepted unlimited concurrent requests to generate storage presigned URLs, giving an attacker a free lever to exhaust storage-provider quotas and server resources. The fix adds an `express-rate-limit` middleware capping each client to 30 requests per minute on that route.