fix(security): fail-closed CORS for cloud-agent management routes (#6543)

Merged — thank you, @developerjillur! Fail-closed CORS for the cookie/session-authed cloud-agent management routes (allowlist echo, credentials only for an explicitly allowlisted origin). Integrated into release/v3.8.47.
This commit is contained in:
Jillur Rahman
2026-07-08 10:35:46 +06:00
committed by GitHub
parent 899c40da67
commit 8d59e1f660
3 changed files with 87 additions and 5 deletions

View File

@@ -17,6 +17,7 @@ _Living section — bullets land here as PRs merge into `release/v3.8.47` (paral
- **fix(security):** SSRF-guard the provider-validation probes so they can no longer be used as an open relay to cloud-metadata endpoints — `directHttpsRequest()` (web-cookie / NVIDIA / Z.AI validation, all with a caller-controllable `baseUrl`) ran with `guard:"none"` + `allowRedirect:true`; it now applies `getProviderValidationGuard()` (default `block-metadata`: LAN/localhost allowed, `169.254.169.254`/link-local IMDS rejected, opt-out via `OMNIROUTE_ALLOW_PRIVATE_PROVIDER_URLS`) and `allowRedirect:false` so a provider can't 3xx-redirect the probe to metadata past the initial-URL guard. Regression guard: `tests/unit/provider-validation-ssrf-guard.test.ts`. ([#6542](https://github.com/diegosouzapw/OmniRoute/pull/6542)) — see PR. (thanks @developerjillur)
- **fix(startup):** AgentBridge's MITM proxy served a mismatched cert for 3 of the 4 antigravity/cloudcode-pa hosts it terminates TLS for, breaking interception ([#6494](https://github.com/diegosouzapw/OmniRoute/issues/6494)) — `src/mitm/server.cjs`'s `TARGET_HOSTS` decrypts all 4 hosts locally (`daily-cloudcode-pa.googleapis.com`, `cloudcode-pa.googleapis.com`, `daily-cloudcode-pa.sandbox.googleapis.com`, `autopush-cloudcode-pa.sandbox.googleapis.com`), but `src/mitm/cert/generate.ts`'s self-signed cert only carried a SAN entry for the first host — a request to any of the other 3 got served a cert whose CN/SAN didn't match (confirmed via `curl -k https://cloudcode-pa.googleapis.com/` showing `CN=daily-cloudcode-pa.googleapis.com`). `generateCert()` now sources its host list from `ANTIGRAVITY_TARGET.hosts` (`src/mitm/targets/antigravity.ts`, the single authoritative registry already kept in lock-step with `server.cjs`/`dnsConfig.ts`/`mitmToolHosts.ts` by their own drift tests) and emits a SAN entry for all 4 hosts instead of hard-coding a second, incomplete copy. Regression guard: `tests/unit/agentbridge-antigravity-cert-hosts-6494.test.ts` (asserts the host list covers all 4 hosts and that the real generated cert's SAN includes each one).
- **fix(resilience):** a `priority` combo never fell back when a target masked credit/quota exhaustion behind an HTTP 200 ([#6427](https://github.com/diegosouzapw/OmniRoute/issues/6427)) — `validateResponseQuality()` (`open-sse/services/combo/validateQuality.ts`) only inspected the response body's top-level `error` field when `choices` was ALSO missing/empty (the narrower #3424 empty-completion case); a masked 200 that echoed a non-empty stub `choices` alongside a structured error object, or a known exhaustion phrase (e.g. "insufficient credits", "quota exceeded") in the error envelope, slipped through as "valid" and the combo kept returning the dead target's response forever instead of failing over. The quality check now inspects the error envelope — a top-level OpenAI-shape `error` object, or a bounded, case-insensitive exhaustion-phrase match against `error.message`/`error.code`/`error.type`/top-level `message`/`detail` — unconditionally, before any shape-specific branch, and regardless of whether `choices`/`output` also look structurally present. The check never inspects `choices[].message.content`, so a legitimate completion that merely mentions "quota" or "credits" in assistant prose is not misclassified. Regression guard: `tests/unit/masked-200-exhaustion-fallback-6427.test.ts`.
- **fix(security):** fail-closed CORS for the cookie/session-authed cloud-agent management routes — `getCloudAgentCorsHeaders()` reflected any caller's `Origin` and paired it with `Allow-Credentials: true` (a CSRF/exfil hole); it now defers to the central allowlist (`resolveAllowedOrigin`), echoes only an allowlisted origin with `Vary: Origin`, and emits `Allow-Credentials` only for an explicitly allowlisted origin — never for a `CORS_ALLOW_ALL` wildcard echo. Regression guard: `tests/unit/cloud-agent-cors-failclosed.test.ts`. ([#6543](https://github.com/diegosouzapw/OmniRoute/pull/6543)) — see PR. (thanks @developerjillur)
- **fix(compression):** adaptive-compression ladder ranked 6 real catalog engines (`ccr`, `ionizer`, `relevance`, `llmlingua`, `llm`, `read-lifecycle`) as if they didn't exist ([#6533](https://github.com/diegosouzapw/OmniRoute/issues/6533)) — `ladder.ts`'s `AGGRESSIVENESS` and `REDUCTION_FACTOR` maps only covered the 7 engines wired into `DEFAULT_LADDER` (session-dedup/rtk/headroom/lite/caveman/aggressive/ultra); every other engine registered in `open-sse/services/compression/engines/index.ts` — including `ccr`/`llmlingua`, which the ladder doc comment already says are intentionally addable via `ladderOverride` — fell through to `aggressivenessOf()`'s `?? 0` default (same rank as `"off"`) and `expectedReductionFactor()`'s generic `?? 0.9` fallback, so `floor`-mode escalation could not rank or escalate past them once added to a custom ladder. Both maps now carry entries for all 6 missing real engines, rescaled ×10 (`off:0``ultra:70`) and placed by each engine's documented `stackPriority` (`ionizer` between `rtk`/`headroom`, `relevance` before `caveman`, `llmlingua`/`llm` between `aggressive` and `ultra`, etc.); `mcpAccessibility` — named in the report — is not a registered `CompressionEngine` (it's a separate MCP tool-response truncation mechanism) and was correctly left out. Regression guard: `tests/unit/ladder-engine-maps-6533.test.ts` (asserts every id from `listCompressionEngines()` ranks above `"off"` with a non-default reduction factor). (thanks @chirag127)
- **fix(api):** tool-call arguments could render as `[object Object]` sequences instead of the real JSON through the `/anthropic` (Anthropic-shape `/messages`) routing path ([#6459](https://github.com/diegosouzapw/OmniRoute/issues/6459)) — `appendToolCallArgumentDelta()` (`open-sse/utils/toolCallArguments.ts`), the shared accumulator the streaming `openai-to-claude` response translator, `openai-responses` translator, and `responsesTransformer` all call to build up a tool call's `arguments`/`input_json_delta` buffer, treated any non-string `incoming` fragment as an empty string. Some upstreams deliver the full `tool_calls[].function.arguments` value as an already-parsed JSON object/array instead of the OpenAI-contracted JSON-encoded string; the old code silently discarded that fragment, leaving `tool_use.input` empty, and left downstream buffers open to a plain string coercion of the object (`[object Object]`) once client-side concatenation kicked in. `appendToolCallArgumentDelta()` now `JSON.stringify()`s a non-string, non-null object/array fragment into a valid JSON fragment instead of dropping it, so the assembled `partial_json` always parses back into the original structured value. Regression guard: `tests/unit/anthropic-toolcall-args-6459.test.ts`. (thanks @chirag127)
- **fix(providers):** `fusion` combo returned the opaque `"All fusion panel models failed"` 503 even when only a minority of panel members were actually cooling down / rate-limited, and a user-supplied `fusionTuning.minPanel=1` was silently overridden ([#6454](https://github.com/diegosouzapw/OmniRoute/issues/6454)) — `handleFusionChat()` hard-clamped the quorum floor via `Math.min(Math.max(2, cfg.minPanel), panel.length)`, so an operator-configured `minPanel=1` never took effect: `collectPanel()`'s straggler-grace timer only starts once `ok >= minPanel`, and with the floor forced to 2 a single fast success plus N slow-failing stragglers never reached quorum, so the panel sat waiting instead of degrading to the survivor. Per-member failure reasons (`straggler_dropped`/`timeout`/`threw`/`status_XXX`/`empty_content`/`unparseable`) were also logged server-side but never surfaced in the 503 body, leaving operators unable to tell a rate-limit fan-fail from a broader outage. Fixed by honoring `Math.max(1, cfg.minPanel)` and threading a `failures: Array<{ model, reason }>` collector into the 503 message (`model=reason` per entry) — production fix already merged via #6521; this entry backfills the missing CHANGELOG bullet and adds an 11-member, `fusion-free`-scale regression test matching the original repro shape (a cooling minority must not sink a healthy majority; a genuinely all-failed panel still returns the documented 503). Regression guard: `tests/unit/services/fusion-min-panel-and-failure-detail.test.ts` + `tests/unit/fusion-partial-panel-failure-6454.test.ts`. (thanks @chirag127)

View File

@@ -1,18 +1,39 @@
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getProviderConnections } from "@/lib/db/providers";
import { resolveAllowedOrigin, getCorsStatus } from "@/server/cors/origins";
import type { AgentCredentials } from "./baseAgent.ts";
import type { CloudAgentTaskRow } from "./db.ts";
type JsonRecord = Record<string, unknown>;
export function getCloudAgentCorsHeaders(request?: Request) {
const origin = request?.headers.get("origin");
return {
"Access-Control-Allow-Origin": origin || "*",
/**
* CORS headers for the cloud-agent surface. These routes are MANAGEMENT
* (cookie/session) authed (`requireCloudAgentManagementAuth`), so their CORS
* must be fail-closed: the previous `origin || "*"` reflected ANY caller's
* origin AND paired it with `Allow-Credentials: true`, which lets any website
* make credentialed (cookie-bearing) requests against the management API — a
* classic CSRF/exfil hole. We now defer to the central allowlist
* (`resolveAllowedOrigin`): only an allowlisted origin is echoed, and
* `Allow-Credentials` is emitted ONLY for an EXPLICITLY allowlisted origin —
* never for a `CORS_ALLOW_ALL` wildcard echo. Same-origin dashboard calls need
* no ACAO at all. See docs/security/CORS.md.
*/
export function getCloudAgentCorsHeaders(request?: Request): Record<string, string> {
const requestOrigin = request?.headers.get("origin") ?? null;
const headers: Record<string, string> = {
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Allow-Credentials": "true",
};
const allowed = resolveAllowedOrigin(requestOrigin);
if (allowed) {
headers["Access-Control-Allow-Origin"] = allowed;
headers["Vary"] = "Origin";
const normalized = requestOrigin?.toLowerCase().replace(/\/+$/, "") ?? "";
if (normalized && getCorsStatus().allowedOrigins.includes(normalized)) {
headers["Access-Control-Allow-Credentials"] = "true";
}
}
return headers;
}
export function withCloudAgentCors(response: Response, request?: Request): Response {

View File

@@ -0,0 +1,60 @@
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { getCloudAgentCorsHeaders } from "../../src/lib/cloudAgent/api.ts";
// The cloud-agent routes are management (cookie/session) authed, so their CORS
// must be fail-closed. The previous `origin || "*"` reflected ANY caller's origin
// AND paired it with Allow-Credentials: true — letting any website make
// credentialed (cookie-bearing) requests to the management API (CSRF/exfil).
const ENV_KEYS = ["CORS_ALLOW_ALL", "CORS_ALLOWED_ORIGINS", "CORS_ORIGIN"] as const;
const snap: Record<string, string | undefined> = {};
function req(origin: string | null): Request {
return new Request(
"https://gateway.local/api/cloud-agent/tasks",
origin ? { headers: { origin } } : undefined,
);
}
describe("getCloudAgentCorsHeaders — fail-closed credentialed CORS", () => {
beforeEach(() => {
for (const k of ENV_KEYS) {
snap[k] = process.env[k];
delete process.env[k];
}
});
afterEach(() => {
for (const k of ENV_KEYS) {
if (snap[k] === undefined) delete process.env[k];
else process.env[k] = snap[k];
}
});
it("allowlisted origin -> echoes it, with credentials + Vary: Origin", () => {
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com";
const h = getCloudAgentCorsHeaders(req("https://app.example.com"));
assert.equal(h["Access-Control-Allow-Origin"], "https://app.example.com");
assert.equal(h["Access-Control-Allow-Credentials"], "true");
assert.equal(h["Vary"], "Origin");
});
it("non-allowlisted origin -> NO ACAO and NO credentials (fail-closed)", () => {
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com";
const h = getCloudAgentCorsHeaders(req("https://evil.example.com"));
assert.equal(h["Access-Control-Allow-Origin"], undefined);
assert.equal(h["Access-Control-Allow-Credentials"], undefined);
});
it("CORS_ALLOW_ALL wildcard -> NEVER pairs Allow-Credentials with a wildcard echo", () => {
process.env.CORS_ALLOW_ALL = "true";
const h = getCloudAgentCorsHeaders(req("https://anything.example.com"));
assert.equal(h["Access-Control-Allow-Credentials"], undefined);
});
it("no Origin header (same-origin dashboard) -> no ACAO", () => {
process.env.CORS_ALLOWED_ORIGINS = "https://app.example.com";
const h = getCloudAgentCorsHeaders(req(null));
assert.equal(h["Access-Control-Allow-Origin"], undefined);
});
});