diff --git a/open-sse/config/constants.ts b/open-sse/config/constants.ts index 82aeb0f3ba..d3d49d8eaa 100644 --- a/open-sse/config/constants.ts +++ b/open-sse/config/constants.ts @@ -187,6 +187,7 @@ export const HTTP_STATUS = { UNPROCESSABLE_ENTITY: 422, REQUEST_TIMEOUT: 408, GONE: 410, + PAYLOAD_TOO_LARGE: 413, RATE_LIMITED: 429, PLAN_LIMIT_EXCEEDED: 432, SERVER_ERROR: 500, diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index bedd556a52..db348e84bb 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -344,6 +344,8 @@ export const CONTEXT_OVERFLOW_PATTERNS = [ /\bmax.*token/i, /\btoken limit/i, /\brequest too large\b/i, + /\btokens per minute\b/i, + /\btpm\b/i, ]; // Structured error codes that reliably indicate model access denied @@ -1741,6 +1743,7 @@ export function checkFallbackError( const retryableStatuses = new Set([ HTTP_STATUS.REQUEST_TIMEOUT, HTTP_STATUS.RATE_LIMITED, + HTTP_STATUS.PAYLOAD_TOO_LARGE, HTTP_STATUS.SERVER_ERROR, HTTP_STATUS.BAD_GATEWAY, HTTP_STATUS.SERVICE_UNAVAILABLE, @@ -2208,6 +2211,10 @@ export function checkFallbackError( } if (status === HTTP_STATUS.NOT_ACCEPTABLE || retryableStatuses.has(status)) { + // 413 PAYLOAD_TOO_LARGE (TPM rate limits) should trigger fallback + if (status === HTTP_STATUS.PAYLOAD_TOO_LARGE) { + return buildRetryableFallback(RateLimitReason.MODEL_CAPACITY); + } return buildRetryableFallback(RateLimitReason.SERVER_ERROR); } diff --git a/src/shared/constants/upstreamHeaders.ts b/src/shared/constants/upstreamHeaders.ts index 5d9d7f7f08..fcc3f03d18 100644 --- a/src/shared/constants/upstreamHeaders.ts +++ b/src/shared/constants/upstreamHeaders.ts @@ -2,6 +2,12 @@ * User-supplied upstream extra headers: names we never forward (Host / hop-by-hop / framing). * Changing this list requires syncing: `sanitizeUpstreamHeadersMap` (models.ts), Zod * `upstreamHeaderNameSchema` / record refine (schemas.ts), and `upstream-headers-sanitize` tests. + * + * The forwarding/IP set (x-forwarded-for, x-real-ip, cf-connecting-ip, forwarded, via, …) + * is forbidden so the client-origin IP can never be disclosed (or spoofed) to the upstream + * provider through an operator-set custom upstream header. This mirrors the established + * scrubbers/denylists already used by the Antigravity (`antigravityHeaderScrub.ts`) and + * Cursor CLI (`cursorCliProxy.ts`) paths, extended here to cover every provider. */ const FORBIDDEN = new Set( [ @@ -24,6 +30,18 @@ const FORBIDDEN = new Set( "te", "trailer", "upgrade", + // Origin-IP disclosure: never send the client's forwarding headers upstream. + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-forwarded-port", + "x-forwarded-server", + "x-real-ip", + "cf-connecting-ip", + "true-client-ip", + "client-ip", + "forwarded", + "via", ].map((s) => s.toLowerCase()) ); diff --git a/tests/unit/upstream-headers-sanitize.test.ts b/tests/unit/upstream-headers-sanitize.test.ts index b9814e2d7d..935f4d5f7d 100644 --- a/tests/unit/upstream-headers-sanitize.test.ts +++ b/tests/unit/upstream-headers-sanitize.test.ts @@ -1,6 +1,10 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { sanitizeUpstreamHeadersMap } from "../../src/lib/db/models.ts"; +import { + isForbiddenUpstreamHeaderName, + isForbiddenCustomHeaderName, +} from "../../src/shared/constants/upstreamHeaders.ts"; test("sanitizeUpstreamHeadersMap: drops hop-by-hop / Host names", () => { const out = sanitizeUpstreamHeadersMap({ @@ -12,6 +16,48 @@ test("sanitizeUpstreamHeadersMap: drops hop-by-hop / Host names", () => { assert.deepEqual(out, { "X-Custom": "ok" }); }); +test("sanitizeUpstreamHeadersMap: drops origin-IP forwarding headers (no origin IP leak upstream)", () => { + const out = sanitizeUpstreamHeadersMap({ + "X-Custom": "kept", + "X-Forwarded-For": "203.0.113.9", + "X-Real-IP": "203.0.113.9", + "CF-Connecting-IP": "203.0.113.9", + Forwarded: "for=203.0.113.9", + Via: "1.1 proxy", + "True-Client-IP": "203.0.113.9", + "X-Forwarded-Host": "origin.example.com", + "X-Forwarded-Proto": "https", + }); + assert.deepEqual(out, { "X-Custom": "kept" }); +}); + +test("isForbiddenUpstreamHeaderName: blocks origin-IP forwarding headers", () => { + for (const name of [ + "x-forwarded-for", + "x-real-ip", + "cf-connecting-ip", + "forwarded", + "via", + "true-client-ip", + "client-ip", + "X-Forwarded-For", + "X-Real-IP", + "CF-Connecting-IP", + ]) { + assert.equal(isForbiddenUpstreamHeaderName(name), true, `${name} must be forbidden upstream`); + } + assert.equal(isForbiddenUpstreamHeaderName("x-custom-hdr"), false); +}); + +test("isForbiddenCustomHeaderName: blocks origin-IP forwarding headers for operator custom headers", () => { + assert.equal(isForbiddenCustomHeaderName("x-forwarded-for"), true); + assert.equal(isForbiddenCustomHeaderName("x-real-ip"), true); + assert.equal(isForbiddenCustomHeaderName("cf-connecting-ip"), true); + assert.equal(isForbiddenCustomHeaderName("forwarded"), true); + assert.equal(isForbiddenCustomHeaderName("via"), true); + assert.equal(isForbiddenCustomHeaderName("x-custom-hdr"), false); +}); + test("sanitizeUpstreamHeadersMap: drops values with CR/LF", () => { const out = sanitizeUpstreamHeadersMap({ Good: "a",