Files
OmniRoute/src/server/authz/classify.ts
Diego Rodrigues de Sa e Souza bbc7bf4351 fix(authz): match exact public routes exactly, not as prefixes (#11417)
`isPublicApiRoute()` matched every entry of PUBLIC_API_ROUTE_PREFIXES with
`startsWith()`, but 11 of the 15 entries name ONE route, not a subtree. As a
prefix each also marked every adjacent path sharing its leading characters as
PUBLIC, which skips the MANAGEMENT auth gate.

That is reachable today: Next resolves `/api/usage/om-usage<anything>` to the
dynamic route `/api/usage/[connectionId]`, and that handler carries no auth of
its own — it relies entirely on being classified MANAGEMENT. An unauthenticated
caller therefore reaches `fetchAndPersistProviderLimits()`, which is an
existence oracle over connection ids (409/404/400/200) and, for a connection id
actually starting with `om-usage`, discloses live quota JSON and can drive an
OAuth token refresh (a write side effect) with no credentials.

Split the allowlist by shape:

- PUBLIC_API_ROUTE_PREFIXES keeps only genuine subtrees, every entry ending in
  "/" (asserted by a unit test, so the class cannot come back silently).
- PUBLIC_API_ROUTES_EXACT holds the single routes, matched exactly in both
  spellings.
- The three read-only "prefixes" were single routes too and move to
  PUBLIC_READONLY_CORS_API_ROUTES, matched exactly. classify.ts now asks
  `isPublicReadonlyCorsRoute()` instead of scanning the raw list, so the CORS
  origin relaxation pipeline.ts keys on cannot be inherited by a sibling either
  (`/api/monitoring/health-detail` was taking it).
- `/api/health` deliberately stays in its own set so it keeps classifying as
  `public_prefix`; folding it into the read-only set would widen CORS on it.

dashboardCsrf.ts had a second copy of the prefix scan; it now shares
`isPublicApiRoute()` so the client CSRF exemption and the server classification
cannot disagree. Side effect in the safe direction: the three LOCAL_ONLY oauth
auto-import routes were CSRF-exempt on the client while the server already
required the token — the client now attaches it.

Reported by @ntdat812 (GHSA-74g9-q8f6-793h), with the shape of the fix and the
two gotchas above called out in the report.

Closes GHSA-74g9-q8f6-793h

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
Co-authored-by: Nguyen Thanh Dat <ntdat812.dev@gmail.com>
2026-08-24 15:47:34 -03:00

145 lines
4.5 KiB
TypeScript

import {
isPublicApiRoute,
isPublicReadonlyCorsRoute,
} from "../../shared/constants/publicApiRoutes";
import type { ClassificationReason, RouteClassification } from "./types";
const CLIENT_API_ALIAS_PREFIXES: ReadonlyArray<{ alias: string; canonical: string }> = [
{ alias: "/chat/completions", canonical: "/api/v1/chat/completions" },
{ alias: "/responses", canonical: "/api/v1/responses" },
{ alias: "/models", canonical: "/api/v1/models" },
];
function normalizePathname(rawPath: string): { path: string; reason?: ClassificationReason } {
let path = rawPath || "/";
if (!path.startsWith("/")) path = "/" + path;
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
// Client-API aliases are matched case-insensitively on the control segment.
// Next's rewrite layer accepts `/V1/...`, `/CODEX`, etc. and routes them to
// the client handler, so the classifier must recognize the same casing —
// otherwise an uppercase alias falls through to the management fallback and
// the request is treated as a different route class than it is actually
// dispatched to (GHSA-jvqc-mp9f-q936). Only the leading control segment is
// lowercased for detection; the original-case tail is preserved.
const lower = path.toLowerCase();
if (lower === "/codex" || lower.startsWith("/codex/")) {
return { path: "/api/v1/responses", reason: "client_api_codex_alias" };
}
if (lower === "/v1/v1" || lower.startsWith("/v1/v1/")) {
const tail = path.slice("/v1/v1".length) || "";
return { path: "/api/v1" + tail, reason: "client_api_double_prefix" };
}
if (lower === "/v1beta" || lower.startsWith("/v1beta/")) {
const tail = path.slice("/v1beta".length) || "";
return { path: "/api/v1beta" + tail, reason: "client_api_alias" };
}
if (lower === "/v1" || lower.startsWith("/v1/")) {
const tail = path.slice("/v1".length) || "";
return { path: "/api/v1" + tail, reason: "client_api_alias" };
}
for (const { alias, canonical } of CLIENT_API_ALIAS_PREFIXES) {
if (lower === alias) {
return { path: canonical, reason: "client_api_alias" };
}
if (lower.startsWith(alias + "/")) {
return { path: canonical + path.slice(alias.length), reason: "client_api_alias" };
}
}
return { path };
}
export function classifyRoute(rawPath: string, method: string = "GET"): RouteClassification {
const { path: normalizedPath, reason: aliasReason } = normalizePathname(rawPath);
if (normalizedPath === "/" || normalizedPath === "") {
return {
routeClass: "MANAGEMENT",
reason: "root_redirect",
normalizedPath: "/",
};
}
if (normalizedPath === "/dashboard/onboarding") {
return {
routeClass: "PUBLIC",
reason: "setup_wizard",
normalizedPath,
};
}
// Public, ticket-gated device-flow connect pages (e.g. /connect/codex/{token}).
// Anyone with the shared link completes the provider login in their own browser.
if (normalizedPath === "/connect" || normalizedPath.startsWith("/connect/")) {
return {
routeClass: "PUBLIC",
reason: "public_connect_page",
normalizedPath,
};
}
if (normalizedPath.startsWith("/dashboard")) {
return {
routeClass: "MANAGEMENT",
reason: "dashboard_prefix",
normalizedPath,
};
}
if (normalizedPath === "/api/v1" || normalizedPath.startsWith("/api/v1/")) {
return {
routeClass: "CLIENT_API",
reason: aliasReason ?? "client_api_v1",
normalizedPath,
};
}
if (normalizedPath === "/api/v1beta" || normalizedPath.startsWith("/api/v1beta/")) {
return {
routeClass: "CLIENT_API",
reason: aliasReason ?? "client_api_v1",
normalizedPath,
};
}
if (normalizedPath.startsWith("/api/")) {
if (isClassifiedAsPublic(normalizedPath, method)) {
return {
routeClass: "PUBLIC",
reason: matchesReadonlyPublic(normalizedPath, method)
? "public_readonly_prefix"
: "public_prefix",
normalizedPath,
};
}
return {
routeClass: "MANAGEMENT",
reason: "management_api",
normalizedPath,
};
}
return {
routeClass: "MANAGEMENT",
reason: "fallback_management",
normalizedPath,
};
}
function matchesReadonlyPublic(path: string, method: string): boolean {
// Exact match, not startsWith: a prefix here would hand the CORS origin
// relaxation to every adjacent path too (GHSA-74g9-q8f6-793h).
return isPublicReadonlyCorsRoute(path, method);
}
function isClassifiedAsPublic(path: string, method: string): boolean {
return isPublicApiRoute(path, method);
}