Files
OmniRoute/tests/unit/middleware-header-strip-5849.test.ts
Ravi Tharuma 722748f7c1 fix(sse): keep Codex quota headers under the forwarding budget (#10306)
* fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)

Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.

Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.

npm audit → 0 vulnerabilities.

* fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)

_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.

* Hide health-check excluded models from /v1/models catalog (#10026)

Mirror the request-time exclusion rule (provider_specific_data.excludedModels)
in the unified catalog builder: a model is hidden when its provider has
connections but none of them is eligible for it. Applied across the
PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops
so ghost models no longer appear as available.

Co-authored-by: ritheshcn25 <ritheshcn25@users.noreply.github.com>

* fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055)

* fix(models): memoize getModelsDevPricing for /v1/models catalog

resolveCatalogPricing called getModelsDevPricing once per model while
building GET /v1/models. Each call re-scanned models_dev_pricing and
JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging
the event loop so even /healthz timed out (#9685, #10052).

Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing
and add a unit test for invalidation.

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>

* fix(db): invalidate modelsDevPricing cache on DB reset (#10055)

Copilot review fixes:
1. Register invalidateModelsDevPricingCache() with DB state reset system
   so resetDbInstance() clears the process-local memo, preventing stale
   pricing data from surviving across DB reset/restore operations.
2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055).

The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing()
results until saveModelsDevPricing()/clearModelsDevPricing() to avoid
re-scanning all pricing rows on every /v1/models request. Without this hook,
backup restore and test DB resets would serve stale cached data from the
previous connection.

Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts

---------

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* fix(sse): keep Codex quota headers under the forwarding budget

The 768-byte cap plus priority-3 for any name that does not contain
"ratelimit" dropped x-codex-*-used-percent / reset / credits on every
stream. x-codex-turn-state (314 bytes) ate the budget. Raise the cap,
treat Codex quota headers as rate-limit priority, and do not forward
turn-state.

---------

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@outlook.com>
Co-authored-by: diegosouzapw <diegosouza.pw@gmail.com>
Co-authored-by: ritheshcn25 <rithesh.chandran@snb.ca>
Co-authored-by: ritheshcn25 <ritheshcn25@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-17 08:22:42 -03:00

171 lines
7.2 KiB
TypeScript

import assert from "node:assert/strict";
import { test } from "node:test";
import {
buildStreamingResponseHeaders,
isNextMiddlewareControlHeader,
MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES,
stripNextMiddlewareControlHeaders,
} from "@omniroute/open-sse/handlers/chatCore/responseHeaders.ts";
// Regression guard for issue #5849:
// Providers hosted behind a Next.js middleware (e.g. synthetic.new) leak Next's
// internal `x-middleware-*` control headers on a successful 200 response.
// Forwarding `x-middleware-rewrite` verbatim from an App Router route handler
// makes Next 16 throw `NextResponse.rewrite() was used in a app route handler`
// and return 500. Both proxy paths (streaming + JSON) must strip the family.
const MIDDLEWARE_HEADERS: [string, string][] = [
["x-middleware-rewrite", "/internal/rewrite"],
["x-middleware-next", "1"],
["x-middleware-override-headers", "x-foo"],
["x-middleware-set-cookie", "a=b"],
["x-middleware-request-foo", "bar"],
];
function getHeaderValue(headers: Record<string, string>, name: string): string | undefined {
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase());
return entry?.[1];
}
test("isNextMiddlewareControlHeader matches the whole x-middleware-* family (case-insensitive)", () => {
for (const [name] of MIDDLEWARE_HEADERS) {
assert.equal(isNextMiddlewareControlHeader(name), true, name);
assert.equal(isNextMiddlewareControlHeader(name.toUpperCase()), true, name);
}
assert.equal(isNextMiddlewareControlHeader("x-request-id"), false);
assert.equal(isNextMiddlewareControlHeader("content-type"), false);
});
test("streaming path: buildStreamingResponseHeaders strips x-middleware-* and preserves normal headers", () => {
const upstream = new Headers();
for (const [k, v] of MIDDLEWARE_HEADERS) upstream.append(k, v);
upstream.append("x-request-id", "req-123");
const out = buildStreamingResponseHeaders(upstream, {});
const lowerKeys = Object.keys(out).map((k) => k.toLowerCase());
for (const [name] of MIDDLEWARE_HEADERS) {
assert.ok(
!lowerKeys.includes(name.toLowerCase()),
`expected ${name} to be stripped, got: ${lowerKeys.join(", ")}`
);
}
// Normal upstream header preserved.
const requestIdKey = Object.keys(out).find((k) => k.toLowerCase() === "x-request-id");
assert.ok(requestIdKey, "x-request-id must be preserved");
assert.equal(out[requestIdKey as string], "req-123");
});
test("streaming path strips oversized and credential-bearing upstream response headers", () => {
const upstream = new Headers({
"x-request-id": "req-oversized-guard",
"x-upstream-diagnostic": "x".repeat(MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES),
"set-cookie": "session=upstream-secret; HttpOnly",
});
const warnings: unknown[][] = [];
const out = buildStreamingResponseHeaders(
upstream,
{},
{
warn: (...args: unknown[]) => warnings.push(args),
}
);
const lowerKeys = Object.keys(out).map((key) => key.toLowerCase());
assert.ok(lowerKeys.includes("x-request-id"));
assert.ok(!lowerKeys.includes("x-upstream-diagnostic"));
assert.ok(!lowerKeys.includes("set-cookie"));
assert.equal(warnings.length, 1);
assert.ok(!JSON.stringify(warnings).includes("session=upstream-secret"));
});
test("streaming path bounds the aggregate size of many small upstream response headers", () => {
const upstream = new Headers({ "x-request-id": "req-many-small-headers" });
for (let index = 0; index < 40; index += 1) {
upstream.set(`x-upstream-diagnostic-${index.toString().padStart(2, "0")}`, "x".repeat(32));
}
const out = buildStreamingResponseHeaders(upstream, {}, null);
const upstreamEntries = Object.entries(out).filter(
([name]) =>
name.toLowerCase().startsWith("x-upstream-") || name.toLowerCase() === "x-request-id"
);
const forwardedBytes = upstreamEntries.reduce(
(total, [name, value]) => total + Buffer.byteLength(`${name}: ${value}\r\n`),
0
);
assert.ok(forwardedBytes <= MAX_FORWARDED_UPSTREAM_RESPONSE_HEADER_BYTES);
assert.ok(upstreamEntries.length < 41, "at least one small header must be dropped");
assert.equal(getHeaderValue(out, "x-request-id"), "req-many-small-headers");
});
test("streaming path keeps Codex quota headers and drops x-codex-turn-state", () => {
const upstream = new Headers();
upstream.set("x-codex-turn-state", "s".repeat(300));
upstream.set("content-security-policy", "default-src 'none'");
upstream.set("cf-ray", "abcdefghijklmnopqrstuvwxyz");
upstream.set("date", "Thu, 13 Aug 2026 21:00:00 GMT");
upstream.set("x-codex-primary-used-percent", "41");
upstream.set("x-codex-primary-reset-after-seconds", "120");
upstream.set("x-codex-credits-has-credits", "true");
upstream.set("x-request-id", "req-codex-quota");
const out = buildStreamingResponseHeaders(upstream, {}, null);
assert.equal(getHeaderValue(out, "x-request-id"), "req-codex-quota");
assert.equal(getHeaderValue(out, "x-codex-primary-used-percent"), "41");
assert.equal(getHeaderValue(out, "x-codex-primary-reset-after-seconds"), "120");
assert.equal(getHeaderValue(out, "x-codex-credits-has-credits"), "true");
assert.equal(getHeaderValue(out, "x-codex-turn-state"), undefined);
});
test("streaming path prioritizes request and rate-limit headers over diagnostics", () => {
const upstream = new Headers();
for (let index = 0; index < 20; index += 1) {
upstream.set(`a-diagnostic-${index.toString().padStart(2, "0")}`, "x".repeat(48));
}
upstream.set("retry-after", "30");
upstream.set("x-ratelimit-remaining-requests", "12");
upstream.set("x-request-id", "req-priority");
const out = buildStreamingResponseHeaders(upstream, {}, null);
assert.equal(getHeaderValue(out, "x-request-id"), "req-priority");
assert.equal(getHeaderValue(out, "retry-after"), "30");
assert.equal(getHeaderValue(out, "x-ratelimit-remaining-requests"), "12");
});
test("streaming path strips hop-by-hop and spoofed OmniRoute headers", () => {
const upstream = new Headers({
connection: "keep-alive, x-remove-me",
"keep-alive": "timeout=5",
"proxy-authenticate": "Basic realm=upstream",
"x-remove-me": "connection-scoped",
"x-omniroute-provider": "spoofed-provider",
"x-request-id": "req-safe",
});
const out = buildStreamingResponseHeaders(upstream, {}, null);
assert.equal(getHeaderValue(out, "x-request-id"), "req-safe");
assert.ok(!Object.keys(out).some((name) => name.toLowerCase() === "keep-alive"));
assert.ok(!Object.keys(out).some((name) => name.toLowerCase() === "proxy-authenticate"));
assert.ok(!Object.keys(out).some((name) => name.toLowerCase() === "x-remove-me"));
assert.ok(!Object.values(out).includes("spoofed-provider"));
});
test("non-streaming JSON path: stripNextMiddlewareControlHeaders removes the family, keeps the rest", () => {
const headers = new Headers();
for (const [k, v] of MIDDLEWARE_HEADERS) headers.append(k, v);
headers.append("x-request-id", "req-456");
headers.append("content-type", "application/json");
stripNextMiddlewareControlHeaders(headers);
for (const [name] of MIDDLEWARE_HEADERS) {
assert.equal(headers.get(name), null, `${name} must be stripped`);
}
assert.equal(headers.get("x-request-id"), "req-456");
assert.equal(headers.get("content-type"), "application/json");
});