Compare commits

..

1 Commits

Author SHA1 Message Date
Xiangzhe
6e112d4b4f fix(security): compare the OIDC state cookie in constant time
GHSA-7434-6q4c-33fh: the OIDC callback validated the CSRF `state` cookie with
`storedState !== returnedState`. `!==` short-circuits on the first differing
byte, so rejection time correlates with matching-prefix length (CWE-208).

The reporter scopes this honestly and so do we: `oidc_state` is a single-use
per-login nonce, cleared immediately after validation, and recovering it would
not by itself yield an authorization code. The reason to fix it is that this was
the one callback in the repo still comparing a secret with `!==` — every sibling
(the OAuth callback, the A2A token check, Telegram initData, the CLI token
check) already compares in constant time — and an inconsistent pattern is one
that gets copied into a context where it does matter.

Which is exactly what had already happened: `isInternalAdmissionBypass()` gated
an admission-lane bypass on `match[1].toLowerCase() === resolveSelfLoopBearer()
.toLowerCase()`. That one guards a shared secret with real consequences, so it
is fixed here too.

Both now use `timingSafeCompare()` (src/shared/utils/timingSafeCompare.ts). The
five lines it wraps were already copy-pasted into at least eight places; this is
the shared one. The existing copies are left alone — consolidating them is a
refactor, not a security fix, and does not belong in this diff.

tests/unit/timing-safe-compare.test.ts — 6 tests, the two callsite guards red
before the fix. The helper is covered for equal, differing-same-length,
differing-length, null/undefined identity, and byte-exactness (precomposed vs
decomposed "é" must not match).

Closes GHSA-7434-6q4c-33fh
2026-08-26 11:31:55 -03:00
9 changed files with 101 additions and 126 deletions

View File

@@ -1 +0,0 @@
- **fix(combos):** the combo builder's precision-select, global-model-search, and manual-entry flows now serialize a model step's `model` string using the provider's already-computed routing-alias prefix (e.g. `oc/`) instead of rebuilding it from the raw canonical `providerId`, fixing the no-auth "OpenCode Free" provider (`opencode`) being routed to the unrelated paid "OpenCode Zen" provider (`opencode-zen`) because `opencode` doubles as a manual routing-prefix override ([#11433](https://github.com/diegosouzapw/OmniRoute/issues/11433)).

View File

@@ -2177,9 +2177,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
builderConnectionId !== COMBO_BUILDER_AUTO_CONNECTION ? builderConnectionId : null,
connectionLabel: selectedBuilderConnection?.label || null,
allowedConnectionIds: builderEffectiveAllowedConnectionIds,
// #11433: use the already-corrected routing prefix (e.g. "oc" for
// OpenCode Free) instead of letting it default to the raw providerId.
modelPrefix: parseQualifiedModel(selectedBuilderModel.qualifiedModel)?.providerId,
})
: null;
const builderHasDuplicate =
@@ -2504,9 +2501,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
builderConnectionId !== COMBO_BUILDER_AUTO_CONNECTION ? builderConnectionId : null,
connectionLabel: selectedBuilderConnection?.label || null,
allowedConnectionIds: builderEffectiveAllowedConnectionIds,
// #11433: use the already-corrected routing prefix (e.g. "oc" for
// OpenCode Free) instead of letting it default to the raw providerId.
modelPrefix: parseQualifiedModel(selectedBuilderModel.qualifiedModel)?.providerId,
});
if (hasExactModelStepDuplicate(models, nextStep)) {

View File

@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getCachedSettings, updateSettings } from "@/lib/localDb";
import { SignJWT, jwtVerify, createRemoteJWKSet } from "jose";
import { cookies } from "next/headers";
import { timingSafeCompare } from "@/shared/utils/timingSafeCompare";
// Test seam (static) — allows tests to inject a cookie store and capture the minted auth_token.
// Mirrors the pattern in src/app/api/auth/login/route.ts
export const oidcCallbackInternals = {
@@ -54,7 +55,10 @@ export async function GET(request: Request) {
// Validate state from cookie (via seam so tests can capture)
const cookieStore = await oidcCallbackInternals.getCookieStore();
const storedState = cookieStore.get("oidc_state")?.value;
if (!storedState || storedState !== returnedState) {
// Constant-time: `!==` short-circuits on the first differing byte, so
// rejection time correlates with matching-prefix length (GHSA-7434-6q4c-33fh).
// The sibling OAuth callback already compares `state` this way.
if (!storedState || !timingSafeCompare(storedState, returnedState)) {
return NextResponse.redirect(new URL("/login?oidc_error=invalid_state", originEarly));
}

View File

@@ -83,7 +83,6 @@ export function buildPrecisionComboModelStep({
connectionLabel,
allowedConnectionIds = null,
weight = 0,
modelPrefix,
}: {
providerId: string;
modelId: string;
@@ -92,22 +91,9 @@ export function buildPrecisionComboModelStep({
/** #3266: account allowlist scoping round-robin to a subset of connections. */
allowedConnectionIds?: string[] | null;
weight?: number;
/**
* #11433: the routing-prefix segment to serialize into `model` (e.g. "oc"
* for the no-auth OpenCode Free provider), when it differs from the
* canonical `providerId`. Some canonical provider ids collide with an
* unrelated manual `ALIAS_TO_PROVIDER_ID` routing override (`opencode` →
* `opencode-zen`), so reconstructing `model` from the raw `providerId`
* alone can round-trip to the wrong provider on request routing. Falls
* back to `providerId` when omitted/blank. `step.providerId` always stays
* the canonical id regardless, so routing/duplicate-detection identity is
* unaffected.
*/
modelPrefix?: string | null;
}): ComboModelStep {
const normalizedProviderId = toTrimmedString(providerId) || "provider";
const normalizedModelId = toTrimmedString(modelId) || "model";
const normalizedModelPrefix = toTrimmedString(modelPrefix) || normalizedProviderId;
const normalizedConnectionId = toTrimmedString(connectionId);
const normalizedConnectionLabel = toTrimmedString(connectionLabel);
// A pinned single connection wins over an allowlist, so only carry the allowlist
@@ -124,7 +110,7 @@ export function buildPrecisionComboModelStep({
return {
kind: "model",
providerId: normalizedProviderId,
model: `${normalizedModelPrefix}/${normalizedModelId}`,
model: `${normalizedProviderId}/${normalizedModelId}`,
...(normalizedConnectionId ? { connectionId: normalizedConnectionId } : {}),
...(normalizedConnectionLabel ? { label: normalizedConnectionLabel } : {}),
...(normalizedAllowed.length > 0 ? { allowedConnectionIds: normalizedAllowed } : {}),
@@ -174,15 +160,10 @@ export function buildManualComboModelStep({
const providerId = resolveComboBuilderProviderId(parsed.providerId, providers);
if (!providerId) return null;
// #11433: preserve the user-typed prefix (e.g. "oc") as the routing prefix
// instead of letting buildPrecisionComboModelStep rebuild `model` from the
// resolved canonical providerId, which can collide with an unrelated
// manual alias override (e.g. "opencode" -> "opencode-zen").
return buildPrecisionComboModelStep({
providerId,
modelId: parsed.modelId,
weight,
modelPrefix: parsed.providerId,
});
}
@@ -244,7 +225,7 @@ type ComboBuilderGlobalProvider = {
displayName?: unknown;
connectionCount?: unknown;
connections?: unknown[];
models?: Array<{ id?: unknown; name?: unknown; qualifiedModel?: unknown }>;
models?: Array<{ id?: unknown; name?: unknown }>;
};
/**
@@ -271,18 +252,12 @@ export function buildGlobalModelList(
const modelId = toTrimmedString(model?.id);
if (!modelId) return;
const modelName = toTrimmedString(model?.name) || modelId;
// #11433: derive the routing prefix from the model's already-corrected
// `qualifiedModel` (e.g. "oc/<model>" for the OpenCode Free provider)
// instead of defaulting to the raw providerId, which can collide with
// an unrelated manual alias override.
const modelPrefix = parseQualifiedModel(model?.qualifiedModel)?.providerId || providerId;
const step = buildPrecisionComboModelStep({
providerId,
modelId,
connectionId: null,
connectionLabel: null,
allowedConnectionIds: [],
modelPrefix,
});
list.push({
providerId,

View File

@@ -1,4 +1,5 @@
import { createHmac } from "crypto";
import { timingSafeCompare } from "@/shared/utils/timingSafeCompare";
const ADMISSION_BYPASS_VALUE = "internal";
const SELF_LOOP_KEY = "sk_omniroute";
@@ -31,7 +32,10 @@ export function isInternalAdmissionBypass(request: Request): boolean {
const auth = request.headers.get("authorization") || "";
const match = /^bearer\s+(\S+)$/i.exec(auth.trim());
return Boolean(match && match[1].trim().toLowerCase() === resolveSelfLoopBearer().toLowerCase());
if (!match) return false;
// This gates an admission-lane bypass on a shared secret, so the compare is
// constant-time — `===` leaks matching-prefix length (GHSA-7434 class).
return timingSafeCompare(match[1].trim().toLowerCase(), resolveSelfLoopBearer().toLowerCase());
}
function fingerprint(value: string): string {

View File

@@ -0,0 +1,27 @@
import { timingSafeEqual } from "crypto";
/**
* Constant-time string comparison for secrets, tokens and single-use nonces.
*
* `===` short-circuits on the first differing byte, so rejection time
* correlates with how much of the value the caller already guessed (CWE-208).
* That is the comparison this repo already avoids in every OAuth callback, the
* A2A token check, the Telegram initData HMAC and the CLI token check — each of
* which grew its own private copy of these five lines. This is the shared one:
* reach for it instead of writing a ninth copy, and instead of `===`.
*
* Length is not secret here (it leaks through the early return, as it does in
* every other copy) — the value being protected is the content, not its size.
* `null`/`undefined` compare by identity so a missing secret never matches a
* present one.
*/
export function timingSafeCompare(
a: string | null | undefined,
b: string | null | undefined
): boolean {
if (a == null || b == null) return a === b;
const bufA = Buffer.from(String(a), "utf8");
const bufB = Buffer.from(String(b), "utf8");
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}

View File

@@ -42,12 +42,6 @@ test("buildPrecisionComboModelStep preserves provider/model/account triple", ()
});
test("buildManualComboModelStep resolves provider aliases and uses dynamic account", () => {
// #11433: `providerId` resolves to the canonical id ("codex") for
// duplicate-detection/routing identity, but the serialized `model` string
// now preserves the user-typed prefix ("cx/") verbatim instead of
// collapsing back to the canonical id — some canonical ids (e.g.
// "opencode") collide with an unrelated manual routing-alias override, so
// rebuilding `model` from the canonical id alone can silently misroute.
assert.deepEqual(
builderDraft.buildManualComboModelStep({
value: "cx/gpt-5.5",
@@ -56,7 +50,7 @@ test("buildManualComboModelStep resolves provider aliases and uses dynamic accou
{
kind: "model",
providerId: "codex",
model: "cx/gpt-5.5",
model: "codex/gpt-5.5",
weight: 0,
}
);

View File

@@ -1,83 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
buildPrecisionComboModelStep,
buildGlobalModelList,
buildManualComboModelStep,
} from "../../src/lib/combos/builderDraft.ts";
import { resolveProviderAlias, parseModel } from "../../open-sse/services/model.ts";
// Issue #11433: the combo builder's precision-select path builds a step's
// `model` string as `${providerId}/${modelId}` using the CANONICAL provider id.
// For the no-auth "opencode" (OpenCode Free) provider this produces
// `model: "opencode/<modelId>"`, but `opencode` is ALSO a manual routing-prefix
// override (`ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"`) intended only
// for user-typed `opencode/` prefixes referring to the OpenCode Zen (api-key)
// tier. Parsing the step's own `model` string therefore resolves to a
// DIFFERENT provider than the one recorded in `step.providerId`.
test('sanity: resolveProviderAlias("opencode") is the manual override causing the collision', () => {
// Documents the root cause directly: the manual alias override in
// open-sse/services/model.ts unconditionally rewrites "opencode" to
// "opencode-zen", even though "opencode" is also a registered canonical
// provider id (src/shared/constants/providers/noauth.ts).
assert.equal(resolveProviderAlias("opencode"), "opencode-zen");
});
test("issue #11433 fix: buildPrecisionComboModelStep honors an explicit modelPrefix override", () => {
// The combo builder call sites now thread through the already-computed
// routing-alias prefix (e.g. "oc") instead of letting the step default to
// the raw providerId, so the serialized `model` field round-trips to the
// correct provider.
const step = buildPrecisionComboModelStep({
providerId: "opencode",
modelId: "big-pickle",
modelPrefix: "oc",
});
assert.equal(step.providerId, "opencode");
assert.equal(step.model, "oc/big-pickle");
const parsed = parseModel(step.model);
assert.equal(parsed.provider, step.providerId);
});
test("issue #11433 fix: buildGlobalModelList derives modelPrefix from qualifiedModel for the no-auth OpenCode Free provider", () => {
// Mirrors what src/lib/combos/builderOptions.ts::rewriteQualifiedModelPrefix
// produces for the no-auth "opencode" provider entry: `qualifiedModel` is
// already rewritten to the "oc/" alias prefix, but (pre-fix)
// buildGlobalModelList ignored it and rebuilt `model` from the raw
// providerId, producing "opencode/big-pickle" which parses back to the
// wrong provider ("opencode-zen").
const [entry] = buildGlobalModelList([
{
providerId: "opencode",
displayName: "OpenCode Free",
connectionCount: 0,
connections: [],
models: [{ id: "big-pickle", name: "Big Pickle", qualifiedModel: "oc/big-pickle" }],
},
]);
assert.equal(entry.step.providerId, "opencode");
assert.equal(entry.step.model, "oc/big-pickle");
assert.equal(parseModel(entry.step.model).provider, entry.step.providerId);
});
test("issue #11433 fix: buildManualComboModelStep preserves a user-typed oc/<model> prefix", () => {
// buildManualComboModelStep resolves the typed alias ("oc") back to the
// canonical providerId ("opencode") before building the step. Pre-fix, it
// then handed that canonical id straight to buildPrecisionComboModelStep,
// which rebuilt `model` from it and collapsed "oc/<model>" back down to
// "opencode/<model>" — reproducing the same collision for manual entry.
const step = buildManualComboModelStep({
value: "oc/big-pickle",
providers: [{ providerId: "opencode", alias: "oc" }],
});
assert.ok(step);
assert.equal(step?.providerId, "opencode");
assert.equal(step?.model, "oc/big-pickle");
assert.equal(parseModel(step!.model).provider, step!.providerId);
});

View File

@@ -0,0 +1,61 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { timingSafeCompare } from "../../src/shared/utils/timingSafeCompare.ts";
// GHSA-7434-6q4c-33fh — the OIDC callback compared the CSRF `state` cookie with
// `!==` while every sibling callback already used a constant-time compare. Low
// severity on its own (single-use nonce), but the pattern gets copied, so the
// guard below pins the two callsites to the shared helper.
test("timingSafeCompare accepts identical values", () => {
assert.equal(timingSafeCompare("abc123", "abc123"), true);
assert.equal(timingSafeCompare("", ""), true);
});
test("timingSafeCompare rejects different values, including same-length ones", () => {
assert.equal(timingSafeCompare("abc123", "abc124"), false);
assert.equal(timingSafeCompare("abc123", "xbc123"), false);
assert.equal(timingSafeCompare("abc", "abcdef"), false);
assert.equal(timingSafeCompare("abcdef", "abc"), false);
});
test("timingSafeCompare compares null/undefined by identity, never as a match", () => {
assert.equal(timingSafeCompare(null, null), true);
assert.equal(timingSafeCompare(undefined, undefined), true);
assert.equal(timingSafeCompare(null, undefined), false);
assert.equal(timingSafeCompare(null, "abc"), false);
assert.equal(timingSafeCompare("abc", undefined), false);
assert.equal(timingSafeCompare(undefined, ""), false);
});
test("timingSafeCompare is byte-exact, not unicode-normalizing", () => {
// "é" precomposed vs decomposed — different bytes, must not match.
assert.equal(timingSafeCompare("é", "é"), false);
});
function sourceOf(relPath: string): string {
return readFileSync(fileURLToPath(new URL(`../../${relPath}`, import.meta.url)), "utf8");
}
test("the OIDC callback validates `state` with the constant-time helper", () => {
const source = sourceOf("src/app/api/auth/oidc/callback/route.ts");
assert.ok(
source.includes("timingSafeCompare"),
"oidc/callback must compare the state cookie in constant time (GHSA-7434-6q4c-33fh)"
);
assert.ok(
!/storedState\s*!==\s*returnedState/.test(source),
"the short-circuiting `!==` state comparison is back"
);
});
test("the internal admission bypass compares its bearer in constant time", () => {
const source = sourceOf("src/shared/middleware/chatAdmissionIdentity.ts");
assert.ok(
source.includes("timingSafeCompare"),
"isInternalAdmissionBypass gates a bypass on a shared secret — compare it in constant time"
);
});