Files
certctl/scripts/ci-guards/cors-wildcard-allowlist.sh
T
shankar0123 00eace8068 fix(api/cors): narrow Bundle-2 routes from wildcard to NewCORS(corsCfg)
Closes CRIT-3 of the 2026-05-10 audit. Bundle 2's OIDC handshake +
back-channel-logout + logout + bootstrap + breakglass-login routes were
wrapped by middleware.CORS — a hard-coded
Access-Control-Allow-Origin: * middleware that ignored the operator's
CERTCTL_CORS_ORIGINS knob (CWE-942). The properly-configured
middleware.NewCORS(corsCfg) exists right next to it but wasn't used here.
The deprecation comment on middleware.CORS said "Kept for health endpoints"
but Bundle 2 added four additional call sites without converting them.

This commit:

- Renames middleware.CORS -> middleware.CORSWildcard with a stronger doc
  block making the security tradeoff explicit at every remaining call
  site. The doc references the CI guard + the 2026-05-10 audit closure.

- Adds a CorsCfg middleware.CORSConfig field to router.HandlerRegistry
  and threads it from cmd/server/main.go using the existing
  cfg.CORS.AllowedOrigins value. The same config that drives the global
  corsMiddleware now also drives the per-route NewCORS wraps for the
  auth-exempt direct r.mux.Handle blocks.

- Swaps middleware.CORS -> middleware.NewCORS(reg.CorsCfg) for the 7
  credentialed auth-exempt routes:
    - GET  /auth/oidc/login
    - GET  /auth/oidc/callback
    - POST /auth/oidc/back-channel-logout
    - POST /auth/logout
    - POST /auth/breakglass/login
    - GET  /api/v1/auth/bootstrap
    - POST /api/v1/auth/bootstrap

- Keeps middleware.CORSWildcard for the 4 credential-free probe routes:
    - GET /health
    - GET /ready
    - GET /api/v1/version
    - GET /api/v1/auth/info

- Adds scripts/ci-guards/cors-wildcard-allowlist.sh — pins the 4-route
  allowlist; fails CI when a new middleware.CORSWildcard wrap appears
  outside the allowlist. Adding a new wildcard call site requires
  updating the allowlist AND documenting why in the commit body.

Operators who configured CERTCTL_CORS_ORIGINS=https://admin.example.com
expecting the OIDC + BCL + breakglass-login routes to honor it now do.
Previously those routes ignored the knob and emitted ACAO: * regardless.

Verification gate green:
- gofmt -l . clean
- go vet ./... clean
- go test -short -count=1 ./internal/api/... ./internal/auth/...
  ./internal/domain/auth/ ./internal/service/auth/ ./cmd/server/ pass
- go build ./... clean
- scripts/ci-guards/cors-wildcard-allowlist.sh passes (4 allowlisted
  routes; zero violations)

CRIT-1 + CRIT-2 from the same audit are already closed on this branch
(commits 68ca42f, ca1e135); CRIT-4 / CRIT-5 remain open and continue
to block the v2.1.0 tag. Spec:
cowork/auth-bundles-fixes-2026-05-10/03-crit-3-cors-narrow.md.

Refs: cowork/auth-bundles-audit-2026-05-10.md CRIT-3
2026-05-10 20:12:19 +00:00

86 lines
3.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# cors-wildcard-allowlist.sh — Audit 2026-05-10 CRIT-3 ratchet.
#
# middleware.CORSWildcard (formerly middleware.CORS) emits
# Access-Control-Allow-Origin: * unconditionally, ignoring the operator's
# CERTCTL_CORS_ORIGINS knob (CWE-942). It is ONLY safe to use on endpoints
# that (a) carry no credentials and (b) must be reachable from any origin
# (health probes, version probes, the GUI's pre-login auth-info probe).
#
# This guard greps for every middleware.CORSWildcard call site, extracts
# the nearest preceding r.mux.Handle("…") route string, and asserts that
# the route appears in the documented ALLOWLIST below. Adding a new
# wildcard-CORS wrap therefore requires either:
#
# 1. Adding the route to ALLOWLIST below AND documenting why in the
# commit body, or
# 2. Switching the call site to middleware.NewCORS(reg.CorsCfg).
#
# Closes CRIT-3 of cowork/auth-bundles-audit-2026-05-10.md. See also
# internal/api/middleware/middleware.go::CORSWildcard for the doc block.
set -euo pipefail
ROUTER=internal/api/router/router.go
# Routes allowed to use middleware.CORSWildcard. Every entry must be a
# credential-free endpoint that operators expect to be reachable from any
# origin (Kubernetes probes, Prometheus, the pre-login GUI).
ALLOWLIST=(
"GET /health" # K8s/Docker liveness probe
"GET /ready" # K8s/Docker readiness probe
"GET /api/v1/version" # rollout probes; pre-auth
"GET /api/v1/auth/info" # GUI reads before login
)
if [[ ! -f "$ROUTER" ]]; then
echo "FAIL: $ROUTER not found (run from certctl/ root)"
exit 1
fi
# Extract every (route, wrap) pair from the router by finding each
# r.mux.Handle("ROUTE", ...) block and checking whether its wrapping list
# contains middleware.CORSWildcard.
python3 - <<PY
import re, sys
ALLOWLIST = [
"GET /health",
"GET /ready",
"GET /api/v1/version",
"GET /api/v1/auth/info",
]
allowset = set(ALLOWLIST)
with open("$ROUTER", "r") as f:
src = f.read()
# Find every r.mux.Handle("ROUTE", middleware.Chain(... or direct chain)) block
# and check whether middleware.CORSWildcard appears within the next ~400 chars.
violations = []
seen = []
for m in re.finditer(r'r\.mux\.Handle\("([^"]+)",', src):
route = m.group(1)
region = src[m.end(): m.end() + 600]
if "middleware.CORSWildcard" not in region:
continue
seen.append(route)
if route not in allowset:
violations.append(route)
if violations:
print("FAIL: middleware.CORSWildcard call sites outside the allowlist:")
for r in violations:
print(" " + r)
print()
print("If a new wildcard-CORS endpoint is intentional, add the route to")
print("ALLOWLIST in scripts/ci-guards/cors-wildcard-allowlist.sh AND")
print("document why in the commit body. Otherwise switch the call site")
print("to middleware.NewCORS(reg.CorsCfg).")
sys.exit(1)
print(f"OK: {len(seen)} middleware.CORSWildcard call site(s); all allowlisted.")
for r in seen:
print(f" - {r}")
PY