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
Published August 26, 2026Reviewed 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.


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 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.


References

Frequently Asked Questions

What is a DoS amplification vulnerability in a proxy endpoint?

It occurs when an unauthenticated HTTP endpoint forwards requests to external services without limiting how many requests a single client can send, letting an attacker use the proxy as a force-multiplier to exhaust connection pools or overwhelm upstream services.

How do you prevent DoS amplification in FastAPI proxy routes?

Inject a rate-limiting dependency (using `Depends()`) on every proxy route that tracks per-IP request counts in a sliding time window and returns HTTP 429 when the threshold is exceeded.

What CWE is DoS amplification via unbounded proxy requests?

CWE-770 — "Allocation of Resources Without Limits or Throttling," which covers cases where software does not restrict the amount of resources it consumes on behalf of an actor.

Is authentication alone enough to prevent this type of DoS?

No. Even authenticated endpoints need rate limiting; without it, a single compromised or abusive account can still exhaust the connection pool. Rate limiting must be applied independently.

Can static analysis detect missing rate limiting on FastAPI routes?

Yes. Tools like Semgrep can flag `@router.get` / `@router.post` decorators that lack a `Depends(rate_limit)` argument, and AI-assisted scanners like Orbis AppSec can reason about the absence of throttling controls across entire route files.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #13

Related Articles

high

How Denial of Service via infinite loop happens in Node.js dependencies and how to fix it

A high-severity Denial of Service vulnerability in the nanoid package (CVE-2026-67213) was discovered in the project's dependency tree, where crafted input could trigger an infinite loop during random ID generation. The fix upgrades nanoid from 3.3.17 to 3.3.18 and adds an npm override to ensure all transitive dependencies use the patched version.

high

How Dependabot Missing Cooldown happens in GitHub Actions and how to fix it

A Dependabot configuration in `.github/dependabot.yml` was missing cooldown periods for both its npm and GitHub Actions package ecosystems, meaning newly published — potentially malicious or unstable — package versions could be proposed for adoption immediately after release. Adding a `cooldown` block with `default-days: 7` to each ecosystem entry creates a 7-day buffer, allowing the security community time to identify and flag compromised packages before they reach your codebase.

high

How pnpm Missing Minimum Release Age happens in Node.js workspaces and how to fix it

A missing `minimumReleaseAge` setting in `pnpm-workspace.yaml` left this Node.js workspace vulnerable to immediately installing newly published — potentially malicious — package versions. The fix adds `minimumReleaseAge: 10080` (7 days in minutes) to enforce a quarantine window before any freshly published package can be installed. This single configuration change significantly reduces the risk of supply chain attacks targeting the package publishing pipeline.

high

How Dependabot Missing Cooldown happens in GitHub Actions and how to fix it

A high-severity misconfiguration in `.github/dependabot.yml` left three `package-ecosystem` entries without a cooldown period, meaning Dependabot could immediately propose updates from newly published—potentially malicious—packages. The fix adds a `cooldown` block with `default-days: 7` to each entry, introducing a mandatory waiting period before any newly released package version is surfaced as an update candidate. For a Node.js library whose vulnerabilities ripple downstream to all consumers,

high

How Dependabot Missing Cooldown happens in GitHub Actions and how to fix it

A missing `cooldown` block in `.github/dependabot.yml` meant that Dependabot could immediately propose updates to newly published npm packages — including those that may be malicious, compromised, or unstable. By adding a `cooldown` with `default-days: 7`, the project now waits one week before surfacing new package versions, giving the security community time to detect and flag bad releases before they reach production.

critical

How Archive Path Traversal Happens in Node.js and How to Fix It

CVE-2026-53486 is a critical path traversal vulnerability in the Decompress library, where crafted archive entries can write files and symbolic links outside the intended extraction directory. This vulnerability was transitively introduced through `@vitest/browser` and related packages pinned at version 4.1.5, and was resolved by upgrading to 4.1.6 and 5.0.0-beta.3. Left unpatched, an attacker who controls an archive file processed by any downstream consumer of this dependency chain could overwr