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
This commit is contained in:
diegosouzapw
2026-09-03 12:53:32 -03:00
parent 03ea113145
commit df23f55c7b
4 changed files with 170 additions and 2 deletions

View File

@@ -29,6 +29,9 @@
*/
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { stripSensitiveResponseHeaders } from "@omniroute/open-sse/utils/upstreamResponseHeaders";
import { buildErrorBody, sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { parseUpstreamError } from "@omniroute/open-sse/handlers/usageExtractor";
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
import { getRelayTokenByHash, checkRateLimit, recordRelayUsage } from "@/lib/db/relayProxies";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
@@ -299,7 +302,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 +328,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 + "...");