Files
OmniRoute/tests/unit/upstream-error-passthrough.test.ts
Diego Rodrigues de Sa e Souza 2265ce761f fix(security): harden public error boundaries (#12506)
Validado sobre o tip de `release/v3.8.51` depois de reconciliar com o #12620, que entrou primeiro nesta mesma sessão e ataca a mesma classe de problema por outra arquitetura.

**A colisão e como foi resolvida.** O #12620 consertou o GHSA-qv45-56jc-4wmj adicionando `RAW_CREDENTIAL_PATTERNS` a `error.ts` e importando-os em `upstreamErrorPassthrough.ts`. Este PR resolve o mesmo problema quebrando `error.ts` em `errorSanitization.ts` + `errorPathRedaction.ts`. Mantive a divisão em módulos deste PR, porque ao comparar os dois vocabulários o dele já era mais amplo: o `STRONG_CREDENTIAL_TOKEN` daqui cobre `sk-`/`sk_` **com lookbehind e uma variante para a forma embutida** (que pega `sk-proj-…`), mais Slack `xox-`, AWS `AKIA`/`ASIA`, `github_pat_`/`ghp_`/`glpat-` e JWT de três segmentos.

A única forma que o #12620 carregava e este conjunto não tinha era a chave do Google (`AIza…`) — adicionada aqui, com o mesmo quantificador limitado que os irmãos usam (AGENTS.md → PII §1, já que isso roda sobre corpos upstream não confiáveis).

**A verificação não foi por inspeção.** Rodei as suítes do próprio #12620 contra esta estrutura: **48/48** em `error-sanitizer-sk-key-qv45`, `bifrost-relay-response-leak-9m72`, `search-baseurl-client-override-3f8g` e `search-baseurl-ssrf-guard` — incluindo a asserção anti-drift daquela suíte, que é o oráculo certo aqui: *para todo corpo que a camada de passthrough recusa como vazante, o sanitizador de fallback não pode devolvê-lo intacto*. Ela passa, então a propriedade de segurança dos três GHSAs sobrevive à troca de arquitetura.

Os 21 arquivos de teste deste PR: **259/259**. `typecheck:core` limpo.
2026-09-03 21:31:13 -03:00

245 lines
9.8 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import {
shouldPassthroughUpstreamError,
buildPassthroughErrorResponse,
} from "../../open-sse/utils/upstreamErrorPassthrough.ts";
import { buildSanitizedUpstreamErrorResponse } from "../../open-sse/utils/upstreamErrorResponse.ts";
test("upstream error passthrough", async (t) => {
await t.test("4xx com corpo JSON de erro do provider é elegível", () => {
const body = {
type: "error",
error: { type: "invalid_request_error", message: "thinking.type: adaptive is not supported" },
};
assert.equal(shouldPassthroughUpstreamError(400, body), true);
});
await t.test("5xx NÃO é elegível (segue sanitizado)", () => {
assert.equal(shouldPassthroughUpstreamError(500, { error: { message: "x" } }), false);
});
await t.test("corpo com cara de vazamento interno (stack trace) NÃO é elegível", () => {
assert.equal(
shouldPassthroughUpstreamError(400, {
error: { message: "Error\n at /usr/lib/node_modules/omniroute/x.js:1" },
}),
false
);
});
await t.test(
"401/407 NÃO são elegíveis (credencial nossa pode vazar em www-authenticate)",
() => {
assert.equal(shouldPassthroughUpstreamError(401, { error: { message: "bad key" } }), false);
}
);
await t.test(
"corpo que ecoa uma credencial (Bearer/api_key/sk-) NÃO é elegível (#secret-leak hardening)",
() => {
// Some providers echo the offending request inside a 400/422 validation
// body. Passthrough must refuse so the key is not relayed to the client.
assert.equal(
shouldPassthroughUpstreamError(400, {
error: { message: "invalid request: Authorization: Bearer sk-live-abc123def456ghi" },
}),
false
);
assert.equal(
shouldPassthroughUpstreamError(422, {
error: { message: "bad field", received: { api_key: "sk-abc123def456" } },
}),
false
);
assert.equal(
shouldPassthroughUpstreamError(429, {
error: { message: 'rejected: {"api-key":"xyzabc123secret"}' },
}),
false
);
for (const message of [
String.raw`rejected api_key\t=opaque-tab-secret-9382746`,
String.raw`rejected api_key\u0009=opaque-unicode-tab-9382746`,
String.raw`rejected Bearer\\topaque-bearer-secret-9382746`,
"spawn failed: helper --api-key opaque-cli-key-9382746",
'spawn failed: helper --token "opaque cli token 9382746"',
"spawn failed: helper --password 'opaque-cli-password-9382746'",
`upstream echoed hf_${"A".repeat(34)}`,
]) {
assert.equal(shouldPassthroughUpstreamError(422, { error: { message } }), false, message);
}
}
);
await t.test(
"corpo de capacidade/quota sem segredo continua elegível (contrato Claude Code preservado)",
() => {
// The common safe case must preserve wording so Claude Code can match it
// after recursive sanitization and auto-disable capabilities.
assert.equal(
shouldPassthroughUpstreamError(400, {
error: { message: "thinking.type: adaptive is not supported" },
}),
true
);
assert.equal(
shouldPassthroughUpstreamError(429, {
error: { type: "rate_limit_error", message: "slow down, retry after 60s" },
}),
true
);
}
);
await t.test("buildPassthroughErrorResponse preserves an already-safe JSON body", async () => {
const body = {
type: "error",
error: { type: "invalid_request_error", message: "thinking.type: nope" },
};
const res = buildPassthroughErrorResponse(400, body);
assert.ok(res);
assert.equal(res.status, 400);
assert.deepEqual(await res.json(), body);
});
await t.test("retorna null quando inelegível", () => {
assert.equal(buildPassthroughErrorResponse(500, {}), null);
});
});
test("passthrough preserves multiline capability wording without stack frames", async () => {
const message = "validation failed\nthinking.type: adaptive is not supported";
const res = buildPassthroughErrorResponse(400, {
type: "error",
error: { type: "invalid_request_error", message },
});
assert.ok(res);
const body = (await res.json()) as { error?: { message?: string } };
assert.equal(body.error?.message, message);
});
test("passthrough removes basename and URL stack frames while preserving prose URLs", async () => {
const hostileMessages = [
"boom\n at handler (server.js:12:3)",
String.raw`boom\n at handler (server.js:12:3)`,
"boom at handler (http://127.0.0.1:3000/_next/server.js:12:3)",
"boom at handler (webpack-internal:///app/server.js:12:3)",
"boom\n at handler (http://127.0.0.1:3000/_next/server.js?build=abc:12:3)",
String.raw`boom\n at handler (webpack-internal:///app/server.js#chunk:12:3)`,
String.raw`boom at handler (\Windows\Temp\server.js:12:3)`,
"boom\nhandler@file:///home/runner/private.js:12:3",
String.raw`boom\nhandler@/home/runner/private.cts:12:3`,
"boom\nhandler@https://127.0.0.1:3000/_next/server.mts?build=abc:12:3",
"boom at handler (http://127.0.0.1:3000/_next/chunks/route:12:3)",
];
for (const message of hostileMessages) {
const response = buildPassthroughErrorResponse(400, {
type: "error",
error: { type: "invalid_request_error", message },
});
assert.ok(response);
const body = (await response.json()) as { error?: { message?: string } };
assert.equal(body.error?.message, "boom");
}
const prose = "See https://example.com/docs/error for recovery guidance";
const proseResponse = buildPassthroughErrorResponse(400, {
type: "error",
error: { type: "invalid_request_error", message: prose },
});
assert.ok(proseResponse);
const proseBody = (await proseResponse.json()) as { error?: { message?: string } };
assert.equal(proseBody.error?.message, prose);
const proseWithCoordinates = "See https://example.com/docs/error:12:3 for recovery guidance";
const proseWithCoordinatesResponse = buildPassthroughErrorResponse(400, {
type: "error",
error: { type: "invalid_request_error", message: proseWithCoordinates },
});
assert.ok(proseWithCoordinatesResponse);
const proseWithCoordinatesBody = (await proseWithCoordinatesResponse.json()) as {
error?: { message?: string };
};
assert.equal(proseWithCoordinatesBody.error?.message, proseWithCoordinates);
});
test("canonical upstream JSON projection redacts URL credentials", async () => {
const response = buildSanitizedUpstreamErrorResponse({
status: 422,
rawBody: JSON.stringify({
error: {
message:
"proxy failed https://svc-user:p4ss-opaque-9382@internal.example/v1?" +
"X-Amz-Signature=amz-secret&sig=sas-secret",
},
}),
fallbackMessage: "Upstream validation failed",
});
const serialized = await response.text();
assert.equal(response.status, 422);
assert.doesNotMatch(serialized, /svc-user|p4ss-opaque|amz-secret|sas-secret/i);
assert.match(serialized, /\[REDACTED\]/);
});
test("createErrorResult opt-in passthrough (opts.passthrough)", async (t) => {
await t.test(
"com opts.passthrough e corpo elegível, result.response preserva o JSON upstream seguro",
async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = {
type: "error",
error: {
type: "invalid_request_error",
message: "thinking.type: adaptive is not supported",
},
};
const result = createErrorResult(400, "msg", null, "code", "type", upstreamBody, {
passthrough: true,
});
assert.deepEqual(await result.response.json(), upstreamBody);
assert.equal(result.status, 400);
// Internal classification fields must never be affected by passthrough.
assert.equal(typeof result.error, "string");
assert.notEqual(result.error, JSON.stringify(upstreamBody));
}
);
await t.test("sem opts, comportamento atual (corpo sanitizado) é preservado", async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = {
type: "error",
error: { type: "invalid_request_error", message: "thinking.type: adaptive is not supported" },
};
const result = createErrorResult(400, "msg", null, "code", "type", upstreamBody);
const body = (await result.response.json()) as { error?: { message?: string } };
assert.ok(body.error?.message, "sanitized body keeps the wrapped error.message shape");
assert.ok(
!JSON.stringify(body).includes(" at /"),
"sanitized body never leaks stack-trace-like text"
);
});
await t.test("com retryAfterMs e passthrough elegível, header Retry-After é setado", async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = {
type: "error",
error: { type: "rate_limit_error", message: "slow down" },
};
const result = createErrorResult(429, "msg", 5000, "code", "type", upstreamBody, {
passthrough: true,
});
assert.equal(result.response.headers.get("Retry-After"), "5");
assert.deepEqual(await result.response.json(), upstreamBody);
});
await t.test(
"opts.passthrough true mas corpo inelegível (401) cai no corpo sanitizado atual",
async () => {
const { createErrorResult } = await import("../../open-sse/utils/error.ts");
const upstreamBody = { error: { message: "bad key" } };
const result = createErrorResult(401, "unauthorized", null, "code", "type", upstreamBody, {
passthrough: true,
});
const body = (await result.response.json()) as { error?: { message?: string } };
assert.notDeepEqual(body, upstreamBody);
assert.ok(body.error?.message, "sanitized body keeps the wrapped error.message shape");
}
);
});