fix(security): close 3 advisories — search baseUrl exfil, sk- in the error sanitizer, bifrost relay header leak (#12620)

Validado em worktree combinada sobre o tip de release/v3.8.51: os dois boardaram sem conflito, typecheck:core limpo, check-file-size sem violação nova (as duas restantes — codex.ts e stream.ts — são drift anterior) e 51/51 nos 5 arquivos de teste que os PRs trazem.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-09-03 20:48:58 -03:00
committed by GitHub
parent 8a95a2bced
commit 4a37c7f46e
13 changed files with 570 additions and 30 deletions

View File

@@ -0,0 +1,103 @@
/**
* GHSA-9m72-44hg-w32g — the standalone bifrost relay route copied ALL upstream
* response headers (`new Headers(upstream.headers)`) and returned non-2xx bodies
* verbatim, while its TypeScript sibling routed non-2xx through
* parseUpstreamError + buildErrorBody + stripStaleEncodingHeaders.
*
* The relay sends `Authorization: Bearer ${BIFROST_API_KEY}` to the sidecar, so
* anything the sidecar (or a further upstream) echoes back — that header, its own
* `set-cookie`, an `x-api-key` — reached the relay-token holder untouched.
*
* Both relay routes copy upstream headers, so the strip is a shared helper used
* by both rather than a fix in one and a second copy waiting to drift (the
* failure mode of GHSA-v7g9 and GHSA-qv45).
*
* Run with:
* node --import tsx/esm --test tests/unit/bifrost-relay-response-leak-9m72.test.ts
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { stripSensitiveResponseHeaders } from "../../open-sse/utils/upstreamResponseHeaders.ts";
const read = (rel: string) =>
readFileSync(fileURLToPath(new URL(`../../${rel}`, import.meta.url)), "utf8");
const BIFROST_ROUTE = "src/app/api/v1/relay/chat/completions/bifrost/route.ts";
const TS_ROUTE = "src/app/api/v1/relay/chat/completions/route.ts";
describe("stripSensitiveResponseHeaders", () => {
it("drops credentials and cookies the upstream echoed back", () => {
const upstream = new Headers([
["authorization", "Bearer sidecar-secret"],
["x-api-key", "op-key"],
["x-goog-api-key", "goog-key"],
["api-key", "azure-key"],
["cookie", "session=abc"],
["set-cookie", "session=abc; HttpOnly"],
["proxy-authorization", "Basic zzz"],
["content-type", "application/json"],
["x-request-id", "keep-me"],
]);
const out = stripSensitiveResponseHeaders(upstream);
for (const gone of [
"authorization",
"x-api-key",
"x-goog-api-key",
"api-key",
"cookie",
"set-cookie",
"proxy-authorization",
]) {
assert.equal(out.get(gone), null, `${gone} survived`);
}
assert.equal(out.get("content-type"), "application/json");
assert.equal(out.get("x-request-id"), "keep-me");
});
it("also drops the stale framing headers", () => {
const out = stripSensitiveResponseHeaders(
new Headers([
["content-encoding", "gzip"],
["content-length", "123"],
["transfer-encoding", "chunked"],
["x-keep", "yes"],
])
);
assert.equal(out.get("content-encoding"), null);
assert.equal(out.get("content-length"), null);
assert.equal(out.get("transfer-encoding"), null);
assert.equal(out.get("x-keep"), "yes");
});
it("does not mutate the input Headers", () => {
const input = new Headers([["authorization", "Bearer x"]]);
stripSensitiveResponseHeaders(input);
assert.equal(input.get("authorization"), "Bearer x");
});
});
describe("both relay routes use the shared strip (GHSA-9m72-44hg-w32g)", () => {
for (const route of [BIFROST_ROUTE, TS_ROUTE]) {
it(`${route} strips sensitive upstream response headers`, () => {
const src = read(route);
assert.ok(
src.includes("stripSensitiveResponseHeaders"),
`${route} relays upstream headers verbatim — a sidecar-echoed credential reaches the caller`
);
assert.ok(
!/new Headers\(upstream\.headers\)/.test(src),
`${route} still copies upstream headers wholesale`
);
});
}
it("the bifrost route normalizes non-2xx through the error sanitizer", () => {
const src = read(BIFROST_ROUTE);
assert.ok(src.includes("buildErrorBody"), "bifrost non-2xx must not be relayed verbatim");
assert.ok(src.includes("sanitizeErrorMessage"), "bifrost non-2xx must be sanitized (HR#12)");
});
});

View File

@@ -0,0 +1,93 @@
/**
* GHSA-qv45-56jc-4wmj — two copies of one redaction rule, and only one got the
* `sk-` pattern.
*
* `upstreamErrorPassthrough.ts`'s CREDENTIAL_LEAK_RE matches `\bsk-[…]{8,}` and
* REFUSES verbatim passthrough when an upstream 4xx echoes a key — correctly
* treating it as a leak. The body then falls through to `buildErrorBody` →
* `sanitizeErrorMessage` → `redactSensitiveErrorText`, which had no `sk-`
* pattern at all. So the layer that recognized the credential handed it to a
* layer that did not, and OpenAI-style `Incorrect API key provided: sk-proj-…`
* bodies were returned to the caller verbatim.
*
* The passthrough file's own comment says it "mirrors the vocabulary of
* redactSensitiveErrorText" — the mirror had diverged. This suite pins both
* directions so it cannot diverge again.
*
* Run with:
* node --import tsx/esm --test tests/unit/error-sanitizer-sk-key-qv45.test.ts
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { redactSensitiveErrorText, sanitizeErrorMessage } from "../../open-sse/utils/error.ts";
const LEAKY_BODIES = [
"Incorrect API key provided: sk-proj-AbCdEfGhIjKlMnOpQrStUv. You can find your API key at …",
"401 Unauthorized: sk-ant-api03-abcdefghijklmnopqrstuvwxyz-1234567890",
"invalid key sk_live_51H8xKzAbCdEfGhIjKlMn",
"Bad credentials for AIzaSyA1B2C3D4E5F6G7H8I9J0KaLbMcNdOeP",
"token rejected: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U",
];
describe("redactSensitiveErrorText — raw credential patterns (GHSA-qv45-56jc-4wmj)", () => {
for (const body of LEAKY_BODIES) {
it(`redacts the raw credential in: ${body.slice(0, 42)}`, () => {
const out = redactSensitiveErrorText(body);
assert.ok(!/\bsk[-_][A-Za-z0-9._-]{8,}/.test(out), `sk- survived: ${out}`);
assert.ok(!/\bAIza[A-Za-z0-9_-]{20,}/.test(out), `Google key survived: ${out}`);
assert.ok(!/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\./.test(out), `JWT survived: ${out}`);
assert.ok(out.includes("[REDACTED"), `nothing was redacted: ${out}`);
});
}
it("redacts through sanitizeErrorMessage, the path buildErrorBody actually uses", () => {
const out = sanitizeErrorMessage("Incorrect API key provided: sk-proj-AbCdEfGhIjKlMnOpQr.");
assert.ok(!out.includes("sk-proj-AbCdEfGhIjKlMnOpQr"), out);
});
it("keeps the pre-existing redactions working", () => {
assert.match(redactSensitiveErrorText("401: Bearer abc123def456"), /Bearer \[REDACTED\]/);
assert.match(
redactSensitiveErrorText('{"api_key":"secret-value","detail":"bad"}'),
/\[REDACTED\]/
);
assert.match(
redactSensitiveErrorText("data:image/png;base64,AAAABBBBCCCC"),
/\[REDACTED_DATA_URL\]/
);
});
it("does not maul ordinary error prose that merely contains 'sk'", () => {
for (const benign of [
"Model gpt-5 is not available on this plan",
"risk score too high",
"task sk failed", // short, no credential shape
"Rate limit reached for requests",
]) {
assert.equal(redactSensitiveErrorText(benign), benign);
}
});
});
describe("the two redaction layers stay in step", () => {
it("every credential shape the passthrough layer refuses is also redacted here", async () => {
// If passthrough REFUSES a body as leaky, the fallback sanitizer is the only
// thing standing between that body and the caller. Anything the first layer
// calls a credential, the second must scrub.
const { shouldPassthroughUpstreamError } =
await import("../../open-sse/utils/upstreamErrorPassthrough.ts");
for (const body of LEAKY_BODIES) {
const payload = { error: { message: body } };
const relayedVerbatim = shouldPassthroughUpstreamError(401, payload);
if (relayedVerbatim) continue; // not classified as a leak — nothing to assert
const scrubbed = redactSensitiveErrorText(body);
assert.notEqual(
scrubbed,
body,
`passthrough refused this body as leaky but the sanitizer left it untouched: ${body}`
);
}
});
});

View File

@@ -0,0 +1,159 @@
/**
* GHSA-3f8g-pfh9-j687 — a tenant-supplied `provider_options.baseUrl` redirected
* the SaaS search builders, so the OPERATOR's search-provider API key was sent
* to a tenant-chosen host (query string for google-pse/searchapi, header for
* you.com/linkup/nimble/ollama).
*
* This is the same override the GHSA-j7j4-g9qc-q69c fix hardened — and that fix
* only added a block-metadata check, which does nothing here: the attacker
* points at their own PUBLIC host and collects the key. The j7j4 regression test
* missed it because its fixture was `searxng-search`, the one keyless provider
* where redirecting the base URL leaks no credential.
*
* The two override sources have different trust:
* - `providerSpecificData` comes from the stored provider connection
* (`credentials?.providerSpecificData`, search.ts:1510) — OPERATOR config.
* This is how an operator points at their self-hosted searxng, so it keeps
* working on loopback/LAN under the block-metadata policy.
* - `providerOptions` comes straight off the request body
* (`body.provider_options`, v1/search/route.ts:366) — TENANT input. It is
* refused outright: no provider needs a per-request caller-chosen fetch
* target, and honoring one is credential exfiltration for keyed providers
* and SSRF-with-readback for every provider.
*
* Run with:
* node --import tsx/esm --test tests/unit/search-baseurl-client-override-3f8g.test.ts
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { resolveSearchBaseUrl } from "../../open-sse/handlers/search.ts";
import type { SearchProviderConfig } from "../../open-sse/config/searchRegistry.ts";
import { SEARCH_PROVIDERS } from "../../open-sse/config/searchRegistry.ts";
const base = { query: "test", searchType: "web", maxResults: 5 };
/** Every provider whose builder attaches an operator-held key. */
const KEYED_PROVIDER_IDS = Object.values(SEARCH_PROVIDERS)
.filter((p) => p.authType === "apikey")
.map((p) => p.id);
describe("resolveSearchBaseUrl — tenant-supplied baseUrl (GHSA-3f8g-pfh9-j687)", () => {
it("covers every keyed provider in the registry, not one hand-picked fixture", () => {
// The j7j4 test only exercised searxng. Assert the registry actually has
// keyed providers so this suite cannot silently degrade to zero coverage.
assert.ok(
KEYED_PROVIDER_IDS.length >= 5,
`expected keyed providers, got ${KEYED_PROVIDER_IDS.length}`
);
});
for (const id of KEYED_PROVIDER_IDS) {
it(`refuses a tenant baseUrl for the keyed provider ${id}`, () => {
const config = SEARCH_PROVIDERS[id];
assert.throws(
() =>
resolveSearchBaseUrl(config, {
...base,
providerOptions: { baseUrl: "https://attacker.example" },
}),
`${id} honored a tenant baseUrl — the operator key would be sent there`
);
});
}
it("the opt-in flag is NEVER set on a provider that carries an operator key", () => {
// The whole invariant in one assertion: if this ever pairs with authType
// "apikey", a caller can redirect the operator's key again.
for (const p of Object.values(SEARCH_PROVIDERS)) {
if (p.allowClientBaseUrlOverride) {
assert.equal(p.authType, "none", `${p.id} opts into a caller baseUrl while holding a key`);
}
}
});
it("refuses a caller baseUrl for keyless providers that did NOT opt in", () => {
for (const id of ["context7", "duckduckgo-free"]) {
const config = SEARCH_PROVIDERS[id];
if (!config || config.allowClientBaseUrlOverride) continue;
assert.throws(
() =>
resolveSearchBaseUrl(config, {
...base,
providerOptions: { baseUrl: "https://attacker.example" },
}),
`${id} honored a caller baseUrl without opting in`
);
}
});
it("SearXNG keeps its documented self-hosted caller override, IMDS still blocked", () => {
// Opted in: keyless, so no operator credential travels with the request.
// Loopback/LAN is the point of a self-hosted instance (search-route.test.ts
// covers the route-level flow); cloud metadata stays rejected.
const config = SEARCH_PROVIDERS["searxng-search"];
assert.equal(config.allowClientBaseUrlOverride, true);
assert.equal(
resolveSearchBaseUrl(config, {
...base,
providerOptions: { baseUrl: "http://127.0.0.1:8888/search" },
}),
"http://127.0.0.1:8888/search"
);
assert.throws(() =>
resolveSearchBaseUrl(config, {
...base,
providerOptions: { baseUrl: "http://169.254.169.254/latest/meta-data/" },
})
);
});
it("keeps the operator-configured override working, including loopback/LAN", () => {
const config = SEARCH_PROVIDERS["searxng-search"];
assert.equal(
resolveSearchBaseUrl(config, {
...base,
providerSpecificData: { baseUrl: "http://127.0.0.1:8888/search" },
}),
"http://127.0.0.1:8888/search"
);
assert.equal(
resolveSearchBaseUrl(config, {
...base,
providerSpecificData: { baseUrl: "http://10.0.0.5:8888" },
}),
"http://10.0.0.5:8888"
);
});
it("still blocks cloud metadata from the operator source (j7j4 must not regress)", () => {
const config = SEARCH_PROVIDERS["searxng-search"];
assert.throws(() =>
resolveSearchBaseUrl(config, {
...base,
providerSpecificData: { baseUrl: "http://169.254.169.254/latest/meta-data/" },
})
);
});
it("the operator source wins over a tenant one instead of the tenant shadowing it", () => {
const config = SEARCH_PROVIDERS["searxng-search"];
assert.equal(
resolveSearchBaseUrl(config, {
...base,
providerOptions: { baseUrl: "https://attacker.example" },
providerSpecificData: { baseUrl: "http://127.0.0.1:8888" },
}),
"http://127.0.0.1:8888"
);
});
it("falls back to the catalog baseUrl when neither source supplies one", () => {
const config: SearchProviderConfig = {
...SEARCH_PROVIDERS["searxng-search"],
baseUrl: "http://localhost:8888/search",
};
assert.equal(resolveSearchBaseUrl(config, base), "http://localhost:8888/search");
});
});

View File

@@ -58,18 +58,26 @@ describe("resolveSearchBaseUrl — SSRF guard on client-controlled baseUrl (GHSA
});
}
it("still allows a self-hosted loopback/LAN override (block-metadata, not public-only)", () => {
// CORRECTED for GHSA-3f8g-pfh9-j687. This case originally asserted the
// loopback/LAN override via `providerOptions` — i.e. via TENANT input — which
// is the SSRF-with-readback half of that advisory. The self-hosted use case
// this test was written to protect is operator configuration, and it arrives
// on `providerSpecificData` (search.ts reads it from the stored connection's
// `credentials?.providerSpecificData`), so that is where it is asserted now.
// Tenant-supplied overrides are refused outright — see
// search-baseurl-client-override-3f8g.test.ts.
it("still allows a self-hosted loopback/LAN override from OPERATOR config", () => {
assert.equal(
resolveSearchBaseUrl(config, {
...base,
providerOptions: { baseUrl: "http://127.0.0.1:9999" },
providerSpecificData: { baseUrl: "http://127.0.0.1:9999" },
}),
"http://127.0.0.1:9999"
);
assert.equal(
resolveSearchBaseUrl(config, {
...base,
providerOptions: { baseUrl: "http://10.0.0.5:8080" },
providerSpecificData: { baseUrl: "http://10.0.0.5:8080" },
}),
"http://10.0.0.5:8080"
);