mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 19:02:17 +03:00
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.
245 lines
9.8 KiB
TypeScript
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");
|
|
}
|
|
);
|
|
});
|