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>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-24 15:47:34 -03:00
committed by GitHub
parent 56d64e29a4
commit bbc7bf4351
5 changed files with 230 additions and 49 deletions

View File

@@ -1,7 +1,6 @@
import {
PUBLIC_READONLY_API_ROUTE_PREFIXES,
PUBLIC_READONLY_METHODS,
isPublicApiRoute,
isPublicReadonlyCorsRoute,
} from "../../shared/constants/publicApiRoutes";
import type { ClassificationReason, RouteClassification } from "./types";
@@ -135,8 +134,9 @@ export function classifyRoute(rawPath: string, method: string = "GET"): RouteCla
}
function matchesReadonlyPublic(path: string, method: string): boolean {
if (!PUBLIC_READONLY_METHODS.has(String(method).toUpperCase())) return false;
return PUBLIC_READONLY_API_ROUTE_PREFIXES.some((p) => path.startsWith(p));
// 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 {

View File

@@ -1,30 +1,27 @@
// Public API surface, split by SHAPE — this file is matched two different ways
// and the distinction is load-bearing (GHSA-74g9-q8f6-793h).
//
// A prefix is matched with `startsWith()`, so it also matches every adjacent
// path that merely shares its leading characters. `/api/usage/om-usage` as a
// prefix marked `/api/usage/om-usage<anything>` PUBLIC — and Next resolves that
// to the dynamic route `/api/usage/[connectionId]`, whose handler carries no
// auth of its own because it relies on being classified MANAGEMENT. Ten other
// entries had no shadowing sibling in the route tree today, but any route added
// later under a dynamic segment adjacent to one of them would inherit the same
// bypass silently.
//
// So: PREFIXES are genuine subtrees and MUST end in "/" (asserted by
// tests/unit/authz/public-route-exact-match.test.ts); single routes live in an
// EXACT set instead.
// Genuine subtrees. Every entry MUST end in "/".
const PUBLIC_API_ROUTE_PREFIXES = [
"/api/auth/login",
"/api/auth/logout",
"/api/auth/status",
"/api/auth/oidc/",
"/api/init",
"/api/v1/",
"/api/sync/bundle",
"/api/oauth/",
// Public, ticket-gated Codex device-flow completion (validate + persist).
// The handler enforces its own single-use ticket check; no dashboard auth.
"/api/codex/connect/",
// Remote-mode bootstrap: exchange the management password for a scoped CLI
// access token. The handler enforces its own password check + lockout — there
// is no token yet at this point, so it cannot require management auth.
"/api/cli/connect",
// Terminal-friendly @@om-usage equivalent for CLI clients (Claude Code/Codex).
// The handler enforces its own auth via extractUsageCommandApiKey/isValidApiKey
// and the allowUsageCommand flag — it must not be gated by management auth.
"/api/usage/om-usage",
// Chaos Mode external dispatch endpoint (POST /api/skills/collect/chaos).
// This entry only bypasses the dashboard requireLogin (cookie) gate — the
// handler enforces its own Bearer-token auth (validateApiKey +
// chaosModeEnabled check) before doing any work. See src/app/api/skills/
// collect/chaos/route.ts. Do not widen this prefix to cover other
// /api/skills/collect/* routes without the same per-handler auth.
"/api/skills/collect/chaos",
// Telegram Bot API update webhook + Mini App proxy. Telegram POSTs updates
// here without any dashboard cookie/API key; the handler enforces its own
// auth (503 when TELEGRAM_BOT_TOKEN is unset; 401 on invalid initData
@@ -38,18 +35,45 @@ const PUBLIC_API_ROUTE_PREFIXES = [
"/api/cursor-cli/",
];
const PUBLIC_READONLY_API_ROUTE_PREFIXES = [
// Single routes, public by EXACT path (both spellings) — never by prefix.
const PUBLIC_API_ROUTES_EXACT = new Set([
"/api/auth/login",
"/api/auth/logout",
"/api/auth/status",
"/api/init",
"/api/sync/bundle",
// Remote-mode bootstrap: exchange the management password for a scoped CLI
// access token. The handler enforces its own password check + lockout — there
// is no token yet at this point, so it cannot require management auth.
"/api/cli/connect",
// Terminal-friendly @@om-usage equivalent for CLI clients (Claude Code/Codex).
// The handler enforces its own auth via extractUsageCommandApiKey/isValidApiKey
// and the allowUsageCommand flag — it must not be gated by management auth.
// EXACT: the sibling `/api/usage/[connectionId]` has no auth of its own.
"/api/usage/om-usage",
// Chaos Mode external dispatch endpoint (POST /api/skills/collect/chaos).
// This entry only bypasses the dashboard requireLogin (cookie) gate — the
// handler enforces its own Bearer-token auth (validateApiKey +
// chaosModeEnabled check) before doing any work. See src/app/api/skills/
// collect/chaos/route.ts. Do not widen it to other /api/skills/collect/*
// routes without the same per-handler auth.
"/api/skills/collect/chaos",
]);
// Read-only single routes that ALSO take the CORS origin relaxation: they
// classify as `public_readonly_prefix`, which authz/pipeline.ts keys on.
const PUBLIC_READONLY_CORS_API_ROUTES = [
"/api/health/ping",
"/api/monitoring/health",
"/api/settings/require-login",
];
// Read-only routes public by EXACT path, never by prefix.
// Read-only routes public by EXACT path, WITHOUT the CORS relaxation.
//
// `/api/health` has to be reachable without a key — a probe has none, and a 401 there is
// indistinguishable from a wrong key or a missing route. It cannot go in the prefix list
// above: `startsWith("/api/health")` would also expose `/api/health/degradation`, which is
// authenticated today.
// indistinguishable from a wrong key or a missing route. It stays in its own set (rather than
// joining PUBLIC_READONLY_CORS_API_ROUTES) so it keeps classifying as `public_prefix`: moving it
// would silently widen CORS on it.
const PUBLIC_READONLY_API_ROUTES_EXACT = new Set(["/api/health"]);
const PUBLIC_READONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
@@ -64,6 +88,13 @@ function pathMatchesExactRoute(pathname: string, routePath: string): boolean {
return pathname === routePath || pathname === `${routePath}/`;
}
function matchesAnyExactRoute(pathname: string, routes: Iterable<string>): boolean {
for (const route of routes) {
if (pathMatchesExactRoute(pathname, route)) return true;
}
return false;
}
function isPublicCloudApiRoute(pathname: string, method: string): boolean {
const normalizedMethod = String(method).toUpperCase();
return PUBLIC_CLOUD_API_ROUTES.some(
@@ -82,6 +113,17 @@ const LOCAL_ONLY_OAUTH_IMPORT_ROUTES = [
"/api/oauth/raycast/auto-import",
];
/**
* Whether the route classifies as read-only PUBLIC *with* the CORS origin
* relaxation (authz/classify.ts reason `public_readonly_prefix`). Exported as a
* predicate rather than as the raw list so a caller cannot reintroduce the
* prefix match this file exists to prevent.
*/
export function isPublicReadonlyCorsRoute(pathname: string, method = "GET"): boolean {
if (!PUBLIC_READONLY_METHODS.has(String(method).toUpperCase())) return false;
return matchesAnyExactRoute(pathname, PUBLIC_READONLY_CORS_API_ROUTES);
}
export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
if (
LOCAL_ONLY_OAUTH_IMPORT_ROUTES.some(
@@ -95,6 +137,10 @@ export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
return true;
}
if (matchesAnyExactRoute(pathname, PUBLIC_API_ROUTES_EXACT)) {
return true;
}
if (PUBLIC_API_ROUTE_PREFIXES.some((route) => pathname.startsWith(route))) {
return true;
}
@@ -103,18 +149,17 @@ export function isPublicApiRoute(pathname: string, method = "GET"): boolean {
return false;
}
for (const route of PUBLIC_READONLY_API_ROUTES_EXACT) {
if (pathMatchesExactRoute(pathname, route)) {
return true;
}
if (matchesAnyExactRoute(pathname, PUBLIC_READONLY_API_ROUTES_EXACT)) {
return true;
}
return PUBLIC_READONLY_API_ROUTE_PREFIXES.some((route) => pathname.startsWith(route));
return isPublicReadonlyCorsRoute(pathname, method);
}
export {
PUBLIC_API_ROUTE_PREFIXES,
PUBLIC_READONLY_API_ROUTE_PREFIXES,
PUBLIC_API_ROUTES_EXACT,
PUBLIC_READONLY_CORS_API_ROUTES,
PUBLIC_READONLY_API_ROUTES_EXACT,
PUBLIC_READONLY_METHODS,
};

View File

@@ -1,5 +1,5 @@
import { DASHBOARD_CSRF_HEADER } from "@/shared/constants/dashboardCsrf";
import { PUBLIC_API_ROUTE_PREFIXES } from "@/shared/constants/publicApiRoutes";
import { isPublicApiRoute } from "@/shared/constants/publicApiRoutes";
interface CachedDashboardCsrfToken {
token: string;
@@ -113,11 +113,7 @@ function isClientApiPath(pathname: string): boolean {
);
}
function isPublicApiPath(pathname: string): boolean {
return PUBLIC_API_ROUTE_PREFIXES.some((prefix) => pathname.startsWith(prefix));
}
function shouldAttachDashboardCsrf(url: URL): boolean {
function shouldAttachDashboardCsrf(url: URL, method: string): boolean {
if (
TOP_LEVEL_MANAGEMENT_PATH_PREFIXES.some(
(prefix) => url.pathname === prefix || url.pathname.startsWith(prefix + "/")
@@ -129,7 +125,10 @@ function shouldAttachDashboardCsrf(url: URL): boolean {
return (
url.pathname.startsWith("/api/") &&
url.pathname !== "/api/auth/csrf" &&
!isPublicApiPath(url.pathname) &&
// Share the server's PUBLIC classification instead of re-scanning the
// prefix list here — a second copy is a second chance to disagree with the
// authz pipeline (GHSA-74g9-q8f6-793h).
!isPublicApiRoute(url.pathname, method) &&
!isClientApiPath(url.pathname)
);
}
@@ -150,7 +149,7 @@ function sameOriginDashboardMutation(input: RequestInfo | URL, init?: RequestIni
return false;
}
return url.origin === window.location.origin && shouldAttachDashboardCsrf(url);
return url.origin === window.location.origin && shouldAttachDashboardCsrf(url, method);
}
function mergedHeaders(input: RequestInfo | URL, init?: RequestInit): Headers {