fix(security): close 4 STILL-REAL advisory findings (ACP RCE hardening, db-backups tier, uppercase authz bypass, spawn-veto drift) (#11028)

5 — 4 achados STILL-REAL de advisories de segurança, cada um com TDD (RED→GREEN) e crédito ao reporter original: ACP RCE hardening (resolveVersionProbe), db-backups Tier-2 allowlist, uppercase authz bypass (matcher case-insensitive), spawn-veto drift (chatgpt-web-codex-doctor). typecheck/lint limpos, suíte authz/acp/cors verde. UNSTABLE é o base-red inherited #9985, já documentado no corpo da PR.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-21 13:57:42 -03:00
committed by GitHub
parent c130f2aa1c
commit 60829241fd
10 changed files with 234 additions and 22 deletions

View File

@@ -16,30 +16,39 @@ function normalizePathname(rawPath: string): { path: string; reason?: Classifica
if (!path.startsWith("/")) path = "/" + path;
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
if (path === "/codex" || path.startsWith("/codex/")) {
// 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 (path === "/v1/v1" || path.startsWith("/v1/v1/")) {
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 (path === "/v1beta" || path.startsWith("/v1beta/")) {
if (lower === "/v1beta" || lower.startsWith("/v1beta/")) {
const tail = path.slice("/v1beta".length) || "";
return { path: "/api/v1beta" + tail, reason: "client_api_alias" };
}
if (path === "/v1" || path.startsWith("/v1/")) {
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 (path === alias) {
if (lower === alias) {
return { path: canonical, reason: "client_api_alias" };
}
if (path.startsWith(alias + "/")) {
if (lower.startsWith(alias + "/")) {
return { path: canonical + path.slice(alias.length), reason: "client_api_alias" };
}
}

View File

@@ -119,6 +119,11 @@ export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray<string> = [
"/api/shutdown",
"/api/providers/health-autopilot/actions",
"/api/settings/database",
// Full-database export/import: a credential dump and an irreversible replace.
// Must stay authenticated even under requireLogin=false, for the same reason
// /api/settings/database already does. isAlwaysProtectedPath matches on a path
// boundary, so this covers export, exportAll and import. (GHSA-mghq-58h3-qcqj)
"/api/db-backups",
];
export function isLoopbackHost(hostHeader: string | null): boolean {