Compare commits

...

7 Commits

Author SHA1 Message Date
diegosouzapw
17a1fe0bd2 refactor(search): extract base-URL resolution into its own module
check:file-size flagged `open-sse/handlers/search.ts: 1824 > frozen 1789`. The
growth was mine — the GHSA-3f8g fix added the trust split plus the reasoning for
it to a file that was already at its ceiling.

Shrinking beats loosening the ratchet, and the gate's own message says so
("modularize/extraia (DRY) para encolher"), so the resolver moves to
`open-sse/handlers/search/baseUrl.ts` with its refusal error and the
single-source reader. search.ts drops 1824 -> 1772, under the ceiling, and the
base-URL trust decision — which source is operator, which is caller, which
providers may be redirected — is now one small unit instead of a passage inside
a 1800-line handler. search.ts re-exports both symbols, so every existing
importer is untouched.

Also drops the `parseAndValidateNonMetadataUrl` import that the extraction left
orphaned in search.ts; eslint on that file is back to exactly its 34 frozen
violations.

The remaining file-size failures (`open-sse/executors/codex.ts` 1503 > 1499,
`open-sse/utils/stream.ts` 3078 > 3072) are base debt in files this branch does
not touch.

search + baseUrl suites 52/52, typecheck:core exit 0, check:api-typecheck OK
(289, within baseline), check:cycles OK, check:mutation-test-coverage no drift.
2026-09-03 14:06:43 -03:00
diegosouzapw
dfb3d434af Merge remote-tracking branch 'origin/release/v3.8.51' into fix/sec-3adv-baseurl-sk-bifrost 2026-09-03 13:33:38 -03:00
diegosouzapw
6aa1dad10e fix(search): scope the baseUrl override by provider instead of refusing it
Correcting my own over-tightening in this branch. The previous commit refused
EVERY caller-supplied `provider_options.baseUrl`, which broke a documented,
tested flow: `tests/unit/search-route.test.ts` covers "v1 search POST accepts
authless SearXNG with provider_options baseUrl". SearXNG is self-hosted by
definition and the caller naming their own instance is the supported way to use
it — I read the field as purely hostile when part of its use is legitimate.

This is the gate the reporter actually recommended and I went past: allow the
caller override only for a provider that is BOTH keyless and explicitly opted in
via the new `allowClientBaseUrlOverride` flag. Today that is `searxng-search`
alone. Every `authType: "apikey"` provider still refuses, which is the half that
matters — those are the ones whose builder attaches the operator's key to
whatever host the base URL resolves to.

The flag is checked alongside `authType === "apikey"` at the callsite AND pinned
by a registry-wide invariant in the test: no provider may carry
`allowClientBaseUrlOverride` while holding a key. Inferring the capability from
which builder happens to call the resolver is what let this drift in the first
place, so it is now declared, not inferred.

The opted-in path keeps the block-metadata guard: loopback/LAN is the entire
point of a self-hosted instance, IMDS is not.

search-route 8/8, the 3f8g + j7j4 suites 33/33, sibling sweep 68/68,
typecheck:core exit 0, check:api-typecheck OK (289, within baseline).

Also registers error-sanitizer-sk-key-qv45 in stryker tap.testFiles —
check:mutation-test-coverage flagged it once error.ts entered the mutated set.
2026-09-03 13:33:36 -03:00
diegosouzapw
02813895ee fix(relay): consolidate the bifrost error-helper imports
The previous commit added `buildErrorBody`/`sanitizeErrorMessage` and pulled
`parseUpstreamError` from `handlers/usageExtractor`, where it does not live —
it is exported from `utils/error.ts` alongside the other two. That produced
TS2300 (duplicate `buildErrorBody`, already imported below) and TS2305 on the
same route, caught by check:api-typecheck.

One import from utils/error.ts for all three. check:api-typecheck now reports
OK — 289 pre-existing errors, all within the frozen baseline (was FAIL with 2
new/regressed). Relay tests 6/6, prettier clean.
2026-09-03 13:02:16 -03:00
diegosouzapw
df23f55c7b fix(relay): strip echoed credentials from relayed upstream responses
GHSA-9m72-44hg-w32g: the standalone bifrost relay copied ALL upstream response
headers via `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 whoever holds the relay token. `set-cookie`
matters as much as the credential: it is a session, and a browser would store it
against our origin.

Both fixes:

  - New shared `stripSensitiveResponseHeaders()` in upstreamResponseHeaders.ts,
    built on the `filterUpstreamResponseHeaderEntries` helper that already
    existed there unused. Drops authorization / proxy-authorization / x-api-key /
    x-goog-api-key / api-key / cookie / set-cookie on top of the stale framing
    set. Applied to BOTH relay routes — the TS sibling copied headers wholesale
    too, so fixing only the reported one would have left the same hole next door
    and a second copy waiting to drift, which is the exact failure mode of
    GHSA-v7g9 and GHSA-qv45.
  - The bifrost route normalizes non-2xx through parseUpstreamError +
    buildErrorBody + sanitizeErrorMessage, reaching parity with the sibling and
    Hard Rule #12.

tests/unit/bifrost-relay-response-leak-9m72.test.ts — 6 tests, red before the
fix: the helper's behaviour (including that it does not mutate its input), plus
source guards pinning BOTH routes to the shared strip with a negative assertion
that the wholesale copy has not come back.

Reported by @skeletonsec.

Closes GHSA-9m72-44hg-w32g
2026-09-03 12:53:32 -03:00
diegosouzapw
03ea113145 fix(security): redact raw sk-/AIza/JWT credentials in the error sanitizer
GHSA-qv45-56jc-4wmj: two copies of one redaction rule, and only one of them
learned about `sk-`.

`upstreamErrorPassthrough.ts` recognized `\bsk-[…]{8,}` and REFUSED verbatim
passthrough for any upstream 4xx carrying it — correctly treating it as a leak.
The body then fell through to `buildErrorBody` → `sanitizeErrorMessage` →
`redactSensitiveErrorText`, which had no such pattern. So the layer that
identified the credential handed it to a layer that could not see it, and
OpenAI-style `Incorrect API key provided: sk-proj-…` bodies came back to the
caller intact. For a gateway pooling operator keys that is cross-tenant
disclosure of the operator's upstream credential.

The passthrough file's comment said it "mirrors the vocabulary of
redactSensitiveErrorText". The mirror had drifted, which is why this is fixed at
the source rather than by adding a pattern on each side: the raw-token shapes now
live once, in `error.ts` as `RAW_CREDENTIAL_PATTERNS`, and the passthrough layer
imports them. Both sides move together or not at all.

Covers `sk-`/`sk_`, Google `AIza…`, and three-segment JWTs. Quantifiers are
upper-bounded per AGENTS.md → PII learnings §1 — these run over untrusted
upstream error bodies.

tests/unit/error-sanitizer-sk-key-qv45.test.ts — 9 tests, 7 red before the fix.
The last one is the anti-drift assertion: for every body the passthrough layer
refuses as leaky, the fallback sanitizer must not return it unchanged. It also
pins that ordinary prose containing "risk"/"task sk" is left alone.

Reported by @skeletonsec.

Closes GHSA-qv45-56jc-4wmj
2026-09-03 12:53:15 -03:00
diegosouzapw
bd65c6d89e fix(search): refuse a tenant-supplied provider_options.baseUrl
GHSA-3f8g-pfh9-j687: a caller with search scope could set
`provider_options.baseUrl` and redirect the SaaS search builders, so the
OPERATOR's third-party API key went to a caller-chosen host — `key=`/`api_key=`
in the query for google-pse/searchapi, `X-API-Key`/`Authorization` for
you.com/linkup/nimble/ollama — and the parsed response came back, making the
same primitive an SSRF with readback.

My own GHSA-j7j4-g9qc-q69c fix is the one this slipped past. That fix added
`parseAndValidateNonMetadataUrl` to this exact resolver, which blocks IMDS and
nothing else: against key exfiltration it does nothing at all, because the
attacker just names their own public host. I treated a credential-destination
problem as an SSRF problem.

The j7j4 regression test missed it for a sharper reason worth recording: its
fixture was `searxng-search`, the one keyless provider where redirecting the base
URL leaks no credential. The test picked the benign case and passed.

The two override sources are not equally trusted, and reading them through one
`getProviderSettingString()` call erased the distinction:

  - `providerSpecificData` comes from the stored provider connection
    (`credentials?.providerSpecificData`) — operator config. This is how an
    operator points at a self-hosted searxng, so it keeps working on
    loopback/LAN under block-metadata, and IMDS stays rejected.
  - `providerOptions` is `body.provider_options` — caller input. Now refused
    outright rather than validated. No provider needs a per-request
    caller-chosen fetch target; the self-hosted story is served by the operator
    field. If one ever does, it needs a per-provider opt-in plus a host
    allow-list, not a validated free-form URL.

Operator config now also wins over a caller value instead of being shadowed by
it.

Behaviour change, deliberate: a caller that today sends
`provider_options.baseUrl` gets an explicit refusal instead of a silently
redirected request. Silently ignoring it would leave the caller believing the
override applied.

tests/unit/search-baseurl-client-override-3f8g.test.ts — 23 tests, red before
the fix. It iterates EVERY keyed provider in the registry instead of one
fixture, and asserts the registry still has keyed providers so the suite cannot
degrade to zero coverage.

search-baseurl-ssrf-guard.test.ts had one assertion encoding the wrong contract
(caller-supplied loopback allowed). Moved to the operator source with the
reasoning inline — the j7j4 metadata cases are untouched.

Reported by @skeletonsec.

Closes GHSA-3f8g-pfh9-j687
2026-09-03 12:52:59 -03:00
13 changed files with 570 additions and 30 deletions

View File

@@ -33,6 +33,17 @@ export interface SearchProviderConfig {
*/
fallbackOnly?: boolean;
disabled?: boolean;
/**
* May a CALLER-supplied `provider_options.baseUrl` redirect this provider?
*
* Only ever true for a keyless, self-hosted provider. For anything with
* `authType: "apikey"` the builder attaches the OPERATOR's key to whatever
* host the base URL resolves to, so honoring a caller-chosen value hands that
* key to the caller's server (GHSA-3f8g-pfh9-j687). The invariant
* "never set alongside authType: apikey" is enforced by
* tests/unit/search-baseurl-client-override-3f8g.test.ts.
*/
allowClientBaseUrlOverride?: boolean;
}
export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
@@ -232,6 +243,11 @@ export const SEARCH_PROVIDERS: Record<string, SearchProviderConfig> = {
timeoutMs: 10_000,
cacheTTLMs: 3 * 60 * 1000,
fallbackOnly: true,
// Keyless and self-hosted by definition: the caller names their own SearXNG
// instance and no operator credential travels with the request. This is a
// documented flow (tests/unit/search-route.test.ts). Still block-metadata
// guarded, so IMDS stays unreachable.
allowClientBaseUrlOverride: true,
},
"ollama-search": {

View File

@@ -20,6 +20,9 @@ import { randomUUID } from "crypto";
* }
*/
export { resolveSearchBaseUrl, SearchBaseUrlOverrideError } from "./search/baseUrl.ts";
import { resolveSearchBaseUrl } from "./search/baseUrl.ts";
import {
getSearchProvider,
isUnconfiguredLoopbackSearchProvider,
@@ -36,7 +39,6 @@ import * as anysearchSearch from "./search/anysearchSearch.ts";
import { freeWebSearch } from "../services/freeWebSearch.ts";
import { saveCallLog } from "@/lib/usageDb";
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { parseAndValidateNonMetadataUrl } from "@/shared/network/outboundUrlGuard";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
@@ -319,25 +321,6 @@ function getProviderSettingString(
return undefined;
}
export function resolveSearchBaseUrl(
config: SearchProviderConfig,
params: SearchRequestParams
): string {
const override = getProviderSettingString(params, "baseUrl");
if (override) {
// GHSA-j7j4-g9qc-q69c: the override is client-controlled (provider_options /
// providerSpecificData) and flows into a plain fetch() sink — validate it
// before any builder uses it as the server-side fetch target. Mode is
// block-metadata (NOT public-only): the primary searxng use case is a
// self-hosted instance on loopback/LAN, so private hosts keep working,
// while cloud-metadata endpoints (IMDS credential theft) are rejected.
// The catalog's own config.baseUrl is operator config and stays untouched.
parseAndValidateNonMetadataUrl(override);
return override.replace(/\/+$/, "");
}
return config.baseUrl.replace(/\/+$/, "");
}
function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined {
if (typeof offset !== "number" || offset <= 0 || maxResults <= 0) return undefined;
return Math.floor(offset / maxResults) + 1;

View File

@@ -0,0 +1,66 @@
/**
* Base-URL resolution for /v1/search — the trust decision, on its own.
*
* The two override sources are NOT equally trusted, and reading them through
* one call is what produced GHSA-3f8g-pfh9-j687:
*
* - `providerSpecificData` is the stored provider connection
* (`credentials?.providerSpecificData`) — OPERATOR config. Honored under
* block-metadata, so a self-hosted SearXNG on loopback/LAN keeps working
* while cloud metadata (IMDS credential theft) stays rejected (GHSA-j7j4).
* - `providerOptions` is `body.provider_options` — CALLER input. Honored only
* by a provider that is keyless AND opted in via
* `allowClientBaseUrlOverride`. A keyed builder attaches the OPERATOR's key
* to whatever host this resolves to (`key=`/`api_key=` in the query for
* google-pse/searchapi, `X-API-Key`/`Authorization` for
* you.com/linkup/nimble/ollama), so a caller-chosen host would collect it —
* and a block-metadata check does nothing about that, because the attacker
* simply names their own public host.
*
* Full coverage: tests/unit/search-baseurl-client-override-3f8g.test.ts.
*/
import { parseAndValidateNonMetadataUrl } from "@/shared/network/outboundUrlGuard";
import type { SearchProviderConfig } from "../../config/searchRegistry.ts";
interface BaseUrlParams {
providerOptions?: Record<string, unknown>;
providerSpecificData?: Record<string, unknown>;
}
/** Read one string setting from a SINGLE source, so callers can distinguish trust. */
function readSetting(source: Record<string, unknown> | undefined, key: string): string | undefined {
const value = source?.[key];
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
/** Refusal for a caller-supplied `provider_options.baseUrl` (GHSA-3f8g-pfh9-j687). */
export class SearchBaseUrlOverrideError extends Error {
readonly code = "SEARCH_BASE_URL_OVERRIDE_REFUSED";
constructor(providerId: string) {
super(
`provider_options.baseUrl is not accepted for search provider "${providerId}". ` +
`Set the base URL on the provider connection instead.`
);
this.name = "SearchBaseUrlOverrideError";
}
}
export function resolveSearchBaseUrl(config: SearchProviderConfig, params: BaseUrlParams): string {
const operatorOverride = readSetting(params.providerSpecificData, "baseUrl");
if (operatorOverride) {
parseAndValidateNonMetadataUrl(operatorOverride);
return operatorOverride.replace(/\/+$/, "");
}
const callerOverride = readSetting(params.providerOptions, "baseUrl");
if (callerOverride) {
if (!config.allowClientBaseUrlOverride || config.authType === "apikey") {
throw new SearchBaseUrlOverrideError(config.id);
}
parseAndValidateNonMetadataUrl(callerOverride);
return callerOverride.replace(/\/+$/, "");
}
return config.baseUrl.replace(/\/+$/, "");
}

View File

@@ -40,8 +40,36 @@ function looksLikeAbsolutePath(tok: string): boolean {
return (SOURCE_EXT as readonly string[]).includes(ext);
}
/**
* Raw credential shapes that carry no `key=` label to key off — the token IS the
* whole match, so the only way to redact them is to recognize the shape.
*
* GHSA-qv45-56jc-4wmj: `upstreamErrorPassthrough.ts` already recognized `sk-`
* and refused verbatim passthrough for bodies containing it, then handed those
* bodies to THIS sanitizer — which had no such pattern, so the key came back to
* the caller anyway. The passthrough file's comment claimed to "mirror the
* vocabulary of redactSensitiveErrorText"; the mirror had drifted. It now
* imports this array instead of keeping a second copy, so the two cannot drift
* again.
*
* Quantifiers are upper-bounded (AGENTS.md → PII learnings §1, ReDoS): these run
* over untrusted upstream error bodies.
*/
export const RAW_CREDENTIAL_PATTERNS: ReadonlyArray<RegExp> = [
// OpenAI/Anthropic/Stripe-style secret keys: sk-…, sk-ant-…, sk_live_…
/\bsk[-_][A-Za-z0-9._-]{8,200}/g,
// Google API keys
/\bAIza[A-Za-z0-9_-]{20,200}/g,
// JWTs (three base64url segments)
/\beyJ[A-Za-z0-9_-]{8,400}\.[A-Za-z0-9_-]{8,800}\.[A-Za-z0-9_-]{8,800}/g,
];
export function redactSensitiveErrorText(value: string): string {
return value
let out = value;
for (const pattern of RAW_CREDENTIAL_PATTERNS) {
out = out.replace(pattern, "[REDACTED_CREDENTIAL]");
}
return out
.replace(/data:[^,\s]+;base64,[A-Za-z0-9+/=_-]+/gi, "[REDACTED_DATA_URL]")
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
.replace(

View File

@@ -1,3 +1,4 @@
import { RAW_CREDENTIAL_PATTERNS } from "./error.ts";
/**
* Selective upstream 4xx error passthrough (Claude Code auto-recover contract).
*
@@ -24,8 +25,23 @@ const INTERNAL_LEAK_RE = /\sat\s\/|node_modules|omniroute\//i;
// caller fall back to the sanitized buildErrorBody path. Bodies without a
// secret (the overwhelming majority, carrying capability/quota wording) still
// relay verbatim. Mirrors the vocabulary of redactSensitiveErrorText in error.ts.
const CREDENTIAL_LEAK_RE =
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|\bsk-[A-Za-z0-9._-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i;
const LABELLED_CREDENTIAL_RE =
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{8,}|(?:api[_-]?key|access[_-]?token|refresh[_-]?token|authorization|cookie|secret)\\?["']?\s*[:=]\s*\\?["']?[^"'\\,\s}]{6,}/i;
/**
* The raw-token shapes (sk-…, AIza…, JWT) come from error.ts's
* RAW_CREDENTIAL_PATTERNS rather than a second local copy. The previous local
* copy carried `sk-` while the sanitizer this file falls back to did NOT, so a
* body recognized as leaky here was returned unredacted there
* (GHSA-qv45-56jc-4wmj). One source, no drift.
*/
function containsCredential(text: string): boolean {
if (LABELLED_CREDENTIAL_RE.test(text)) return true;
return RAW_CREDENTIAL_PATTERNS.some((pattern) => {
pattern.lastIndex = 0; // the shared patterns are /g — reset before .test()
return pattern.test(text);
});
}
export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody: unknown): boolean {
if (statusCode < PASSTHROUGH_MIN || statusCode > PASSTHROUGH_MAX) return false;
@@ -34,7 +50,7 @@ export function shouldPassthroughUpstreamError(statusCode: number, upstreamBody:
const text = JSON.stringify(upstreamBody);
if (INTERNAL_LEAK_RE.test(text)) return false;
// Refuse passthrough when the provider echoed a credential back to us.
if (CREDENTIAL_LEAK_RE.test(text)) return false;
if (containsCredential(text)) return false;
return true;
}

View File

@@ -45,3 +45,36 @@ export function filterUpstreamResponseHeaderEntries(
}
export const STRIP_UPSTREAM_HEADER_NAMES: ReadonlySet<string> = STRIP_HEADER_NAMES;
/**
* Response headers that must never be relayed back to a client.
*
* A relay sends its own credential upstream (the bifrost route sends
* `Authorization: Bearer ${BIFROST_API_KEY}` to the sidecar). If that upstream
* echoes the header back — or sets its own session cookie — copying the response
* headers wholesale hands it to whoever holds the relay token
* (GHSA-9m72-44hg-w32g). `set-cookie` matters as much as `authorization`: it is
* a session, and the browser would store it against OUR origin.
*/
const SENSITIVE_RESPONSE_HEADER_NAMES: ReadonlyArray<string> = [
"authorization",
"proxy-authorization",
"x-api-key",
"x-goog-api-key",
"api-key",
"cookie",
"set-cookie",
];
/**
* New Headers with the stale framing set AND any echoed credential/session
* header removed. Use this instead of `new Headers(upstream.headers)` on every
* path that relays an upstream response to a client. Does not mutate the input.
*/
export function stripSensitiveResponseHeaders(input: Headers): Headers {
return new Headers(
filterUpstreamResponseHeaderEntries(input.entries(), SENSITIVE_RESPONSE_HEADER_NAMES)
);
}
export { SENSITIVE_RESPONSE_HEADER_NAMES };

View File

@@ -29,9 +29,14 @@
*/
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { stripSensitiveResponseHeaders } from "@omniroute/open-sse/utils/upstreamResponseHeaders";
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
import { getRelayTokenByHash, checkRateLimit, recordRelayUsage } from "@/lib/db/relayProxies";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import {
buildErrorBody,
parseUpstreamError,
sanitizeErrorMessage,
} from "@omniroute/open-sse/utils/error";
import { getProviderPluginManifestHeader } from "@omniroute/open-sse/config/providerPluginManifestUrl.ts";
import { z } from "zod";
import {
@@ -299,7 +304,11 @@ export async function POST(request: Request) {
});
};
const newHeaders = new Headers(upstream.headers);
// Never copy the upstream headers wholesale: the sidecar receives our
// `Authorization: Bearer ${BIFROST_API_KEY}` and anything it echoes back —
// that header, its own set-cookie — would reach the relay-token holder
// (GHSA-9m72-44hg-w32g).
const newHeaders = stripSensitiveResponseHeaders(upstream.headers);
newHeaders.set("X-Routed-By", "bifrost");
newHeaders.set("X-Relay-Token", token.tokenPrefix + "...");
if (!wantsStream) {
@@ -321,6 +330,28 @@ export async function POST(request: Request) {
}
clearTimeout(tid);
// Normalize non-2xx through parseUpstreamError + buildErrorBody instead of
// relaying the sidecar body verbatim — parity with the TS sibling route and
// Hard Rule #12 (GHSA-9m72-44hg-w32g).
if (!upstream.ok) {
const parsed = await parseUpstreamError(upstream, null);
const errorBody = buildErrorBody(
parsed.statusCode,
sanitizeErrorMessage(parsed.message),
parsed.responseBody
);
newHeaders.set("Content-Type", "application/json");
if (parsed.retryAfterMs && parsed.retryAfterMs > 0) {
newHeaders.set("Retry-After", String(Math.ceil(parsed.retryAfterMs / 1000)));
}
recordUsage("error", parsed.statusCode);
return new Response(JSON.stringify(errorBody), {
status: parsed.statusCode,
headers: newHeaders,
});
}
recordUsage(upstream.status < 500 ? "success" : "error", upstream.status);
return new Response(upstream.body, {

View File

@@ -7,6 +7,7 @@
*/
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { stripSensitiveResponseHeaders } from "@omniroute/open-sse/utils/upstreamResponseHeaders";
import { handleChat } from "@/sse/handlers/chat";
import { withChatAdmission } from "@/shared/middleware/withChatAdmission";
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
@@ -109,7 +110,9 @@ async function forwardToBifrost(
signal: ac.signal,
});
const headers = new Headers(upstream.headers);
// Same strip as the bifrost sibling: an echoed upstream credential or
// set-cookie must not reach the relay-token holder (GHSA-9m72-44hg-w32g).
const headers = stripSensitiveResponseHeaders(upstream.headers);
headers.set("X-Routed-By", "bifrost");
headers.set("X-Routing-Backend", "bifrost");
headers.set("X-Relay-Token", token.tokenPrefix + "...");

View File

@@ -244,6 +244,7 @@
"tests/unit/embeddings-auth.test.ts",
"tests/unit/error-classification.test.ts",
"tests/unit/error-message-sanitization.test.ts",
"tests/unit/error-sanitizer-sk-key-qv45.test.ts",
"tests/unit/error-sensitive-redaction.test.ts",
"tests/unit/execute-chat-resource-pressure-breaker.test.ts",
"tests/unit/executor-antigravity.test.ts",

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"
);