diff --git a/.env.example b/.env.example index 731f76ae61..338bcf5cdf 100644 --- a/.env.example +++ b/.env.example @@ -353,6 +353,7 @@ ALLOW_API_KEY_REVEAL=false # instead of growing an unbounded string until the V8 heap is exhausted. # Used by: open-sse/handlers/chatCore/nonStreamingResponseBody.ts # Default: 67108864 (64 MB) +# OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES=768 # OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES=67108864 # CORS configuration — controls which cross-origin browser clients can call the API. diff --git a/changelog.d/features/9243-forwarded-header-budget-env.md b/changelog.d/features/9243-forwarded-header-budget-env.md new file mode 100644 index 0000000000..830ce41612 --- /dev/null +++ b/changelog.d/features/9243-forwarded-header-budget-env.md @@ -0,0 +1 @@ +- feat: make forwarded upstream response-header budget configurable via env var (#9243) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index 3b154ad038..a36f60b7be 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -196,6 +196,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | `OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS` | `32000` | `src/shared/middleware/chatBodyAdmission.ts` | Conservative string-size token estimate that classifies a request as heavyweight; this is an admission-cost proxy, not provider billing tokenization. | | `OMNIROUTE_CHAT_HARD_MAX_MESSAGES` | `800` | `src/shared/middleware/chatBodyAdmission.ts` | Hard chat history cap. Requests above it receive structured compact-required `413` before compression, translation, or provider dispatch. | | `OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES` | `67108864` (64 MB) | `open-sse/handlers/chatCore/nonStreamingResponseBody.ts` | Hard cap for a non-streaming upstream response buffered fully into memory. Past this the upstream reader is cancelled and the request fails fast instead of growing an unbounded string until the heap is exhausted. | +| `OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES` | `768` | `open-sse/handlers/chatCore/responseHeaders.ts` | Max wire bytes forwarded from upstream response headers. When the budget is exceeded, lower-priority headers (e.g., custom `x-codex-*`, `x-oai-request-id`) are dropped to stay within common reverse-proxy header limits. Set higher to forward more upstream metadata at the cost of larger response header size. | | `CORS_ORIGIN` | _(unset)_ | `src/server/cors/origins.ts` | Legacy single-origin CORS allowlist. Prefer `CORS_ALLOWED_ORIGINS` for new deployments. CORS is only for cross-origin browser API clients; authenticated dashboard writes use same-origin requests plus session-bound CSRF protection instead. | | `CORS_ALLOWED_ORIGINS` | _(unset)_ | `src/server/cors/origins.ts` | Comma-separated CORS allowlist. No wildcard is sent unless `CORS_ALLOW_ALL=true` is explicitly configured. | | `CORS_ALLOW_ALL` | `false` | `src/server/cors/origins.ts` | Development-only escape hatch to echo any browser `Origin`. Do not enable on shared or production deployments. | diff --git a/open-sse/handlers/chatCore/responseHeaders.ts b/open-sse/handlers/chatCore/responseHeaders.ts index 60701d9495..43fdc5a88e 100644 --- a/open-sse/handlers/chatCore/responseHeaders.ts +++ b/open-sse/handlers/chatCore/responseHeaders.ts @@ -30,12 +30,27 @@ const STREAMING_RESPONSE_HEADER_DENYLIST = new Set([ "x-accel-buffering", ]); +const DEFAULT_FORWARDED_HEADER_BUDGET_BYTES = 768; + +/** + * Resolve the forwarded upstream response-header budget from an optional string value + * (typically `process.env.OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES`). Returns the + * default of 768 when the input is unset, empty, or non-positive. + * Extracted as a pure function so unit tests can pass values directly without + * module-cache manipulation. + */ +export function resolveForwardedHeaderBudget(env?: string): number { + const parsed = Number.parseInt(String(env ?? process.env.OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES), 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_FORWARDED_HEADER_BUDGET_BYTES; +} + /** * Keep upstream-derived headers comfortably below common reverse-proxy response-header limits. * This budget includes each header name, separator, value, and trailing CRLF. OmniRoute's own * response metadata and framework/security headers are added separately. + * Override with `OMNIROUTE_FORWARDING_HEADER_BUDGET_BYTES`. */ -export const MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES = 768; +export const MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES = resolveForwardedHeaderBudget(); const MAX_LOGGED_DROPPED_RESPONSE_HEADERS = 20; const responseHeaderEncoder = new TextEncoder(); diff --git a/stryker.conf.json b/stryker.conf.json index e729c958c7..90cbc4bec3 100644 --- a/stryker.conf.json +++ b/stryker.conf.json @@ -208,6 +208,7 @@ "tests/unit/executor-antigravity.test.ts", "tests/unit/executor-web-cookie-sweep.test.ts", "tests/unit/format-provider-error-cause.test.ts", + "tests/unit/forwarded-header-budget.test.ts", "tests/unit/gemini-web-missing-browser-3516.test.ts", "tests/unit/grok-cli-oauth.test.ts", "tests/unit/guardrails-api-3496.test.ts", diff --git a/tests/unit/forwarded-header-budget.test.ts b/tests/unit/forwarded-header-budget.test.ts new file mode 100644 index 0000000000..614e56ac20 --- /dev/null +++ b/tests/unit/forwarded-header-budget.test.ts @@ -0,0 +1,31 @@ +import { describe, it } from "node:test"; +import { equal } from "node:assert/strict"; + +describe("Forwarded upstream response-header budget (#9243)", () => { + it("resolveForwardedHeaderBudget returns default 768 when env is unset", async () => { + const { resolveForwardedHeaderBudget } = await import( + "@/../open-sse/handlers/chatCore/responseHeaders" + ); + equal(resolveForwardedHeaderBudget(undefined), 768); + equal(resolveForwardedHeaderBudget(), 768); + }); + + it("resolveForwardedHeaderBudget overrides with a valid value", async () => { + const { resolveForwardedHeaderBudget } = await import( + "@/../open-sse/handlers/chatCore/responseHeaders" + ); + equal(resolveForwardedHeaderBudget("2048"), 2048); + equal(resolveForwardedHeaderBudget("1"), 1); + equal(resolveForwardedHeaderBudget("4096"), 4096); + }); + + it("resolveForwardedHeaderBudget falls back to default on invalid input", async () => { + const { resolveForwardedHeaderBudget } = await import( + "@/../open-sse/handlers/chatCore/responseHeaders" + ); + equal(resolveForwardedHeaderBudget(""), 768, "empty string"); + equal(resolveForwardedHeaderBudget("abc"), 768, "non-numeric"); + equal(resolveForwardedHeaderBudget("0"), 768, "zero"); + equal(resolveForwardedHeaderBudget("-1"), 768, "negative"); + }); +});