Skip to content

refactor(api): typed allowlist + enumeration test against route drift#4162

Merged
houko merged 15 commits into
mainfrom
fix/3712-auth-allowlist-drift
May 1, 2026
Merged

refactor(api): typed allowlist + enumeration test against route drift#4162
houko merged 15 commits into
mainfrom
fix/3712-auth-allowlist-drift

Conversation

@houko

@houko houko commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Typed public-route allowlist replacing the stringly-typed match block in middleware.rs, plus a catalog consistency test.

Follow-up commit (8b00b49) addresses reviewer-found security regressions:

  • CRITICAL: Removed prefix_get(\"/api/mcp/servers/\") from PUBLIC_ROUTES_GET_ONLY. The broad prefix was exposing GET /api/mcp/servers/{name} (server config including env vars) and GET /api/mcp/servers/{name}/auth/status (McpAuthState carrying OAuthTokens) without authentication. The MCP OAuth callback (/api/mcp/servers/*/auth/callback) remains public via the existing is_mcp_oauth_callback guard in auth() which correctly enforces both the prefix and the /auth/callback suffix.
  • CRITICAL: Restored exact_get(\"/api/config\") to PUBLIC_ROUTES_DASHBOARD_READS. The old allowlist had it in dashboard_read_exact; it was dropped during the refactor. The handler already redacts secrets and the dashboard SPA calls GET /api/config (via getFullConfig()) on every render.
  • HIGH: Rewrote the auth_public_allowlist.rs file header to accurately describe the test as a static documentation test with a hand-maintained table — not a live router drift gate. The previous comment falsely claimed that adding a route to server.rs without updating the table would fail CI. Proper router-enumeration drift gating is tracked as follow-up work per Auth public-allowlist hardcoded in middleware.rs while routes live in server.rs — drift hazard #3712 acceptance criteria.
  • MEDIUM: Updated PUBLIC_ROUTES_ALWAYS doc comment to accurately describe its grouping-by-semantic-role ordering (not alphabetical).
  • Added regression test mcp_servers_prefix_does_not_leak_protected_paths that asserts /{name} and /auth/status return 401 while /auth/callback stays non-401.

Test plan

  • cargo test -p librefang-api --test auth_public_allowlist — all existing + new regression test pass
  • Unauthenticated GET /api/mcp/servers/{name} returns 401
  • Unauthenticated GET /api/mcp/servers/{name}/auth/status returns 401
  • Unauthenticated GET /api/mcp/servers/{name}/auth/callback returns non-401 (OAuth redirect still works)
  • Unauthenticated GET /api/config returns non-401 when require_auth_for_reads is off (dashboard renders)

@houko houko left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues found on review — no mechanical auto-fixes applied since both require author judgement.

  1. Security regression (middleware.rs:496): prefix_get("/api/mcp/servers/") in PUBLIC_ROUTES_GET_ONLY makes every GET request under /api/mcp/servers/* unconditionally public. The is_mcp_oauth_callback guard intended to narrow this is dead code because always_public_get_only fires first. Old behaviour only exposed the specific …/auth/callback suffix.

  2. Silent auth-semantics change (tests/auth_public_allowlist.rs:231): /api/config moved from DashboardRead (public by default) to Authed. The PR description says no semantics changed — that's incorrect and should be updated regardless of which classification is ultimately correct.

Everything else looks clean: the typed-constant approach is solid, the alphabetical ordering is consistent, the three catalog-verification tests are a genuine improvement over ad-hoc matches!() drift.


Generated by Claude Code

Comment thread crates/librefang-api/src/middleware.rs Outdated
// i18n locale bundles — static, fetched before auth flow
PublicRoute::prefix_get("/locales/"),
// MCP OAuth callback: /api/mcp/servers/{name}/auth/callback
PublicRoute::prefix_get("/api/mcp/servers/"),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security regression: broad prefix makes all GET /api/mcp/servers/* paths unconditionally public.

matches_route returns true for any GET request whose path starts with /api/mcp/servers/. That value feeds directly into always_public_get_only, which is ORed into always_public before is_mcp_oauth_callback is consulted:

let always_public =
    always_public_method_free || always_public_get_only || is_mcp_oauth_callback;

Because always_public_get_only is already true for the whole prefix, is_mcp_oauth_callback is dead code — it can never flip always_public from false to true for any /api/mcp/servers/* path. The doc-comment on the constant says "The auth middleware applies that extra guard", but it does not — the prefix match wins first.

Old behaviour: only is_get && starts_with("/api/mcp/servers/") && ends_with("/auth/callback") was public.
New behaviour: every GET under /api/mcp/servers/ is public (e.g. GET /api/mcp/servers/prod/tools, GET /api/mcp/servers/prod/config, any future route).

Fix: remove prefix_get("/api/mcp/servers/") from PUBLIC_ROUTES_GET_ONLY and let is_mcp_oauth_callback remain the sole gate for that path family. If you want the test helper to enumerate MCP OAuth paths, add a dedicated PublicRoute variant that carries the suffix requirement, or simply document is_mcp_oauth_callback separately in the test.


Generated by Claude Code

re("/api/workflows", Expect::DashboardRead),
// Auth-required endpoints (must 401 without Bearer token)
re("/api/health/detail", Expect::Authed),
re("/api/config", Expect::Authed),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auth-semantic change contradicts the PR description.

The PR's "What is NOT changed" section states "No route's auth semantics changed", but /api/config was in dashboard_read_exact in the old middleware (public when require_auth_for_reads is off, which is the default). Classifying it as Authed here means any default-config deployment that relied on the dashboard loading /api/config without a token will start seeing 401s after this lands.

You noted the uncertainty yourself in "Routes I'm uncertain about". The classification here makes the right call if /api/config really does return sensitive fields — but please:

  1. Update the PR description to accurately reflect this semantic change.
  2. Confirm the intent: if /api/config should be fully auth-required even in no-require_auth_for_reads mode, keep Authed and note it explicitly. If it should follow the same pattern as other read endpoints, add it back to PUBLIC_ROUTES_DASHBOARD_READS.

Generated by Claude Code

@github-actions github-actions Bot added the size/L 250-999 lines changed label Apr 30, 2026
houko added 2 commits May 1, 2026 09:13
…e drift

Replace the inline string arrays in middleware::auth() with three typed
pub const slices (PUBLIC_ROUTES_ALWAYS, PUBLIC_ROUTES_GET_ONLY,
PUBLIC_ROUTES_DASHBOARD_READS) and a PublicRoute / PublicMethod /
PublicMatch set of types.  is_public() is rewritten to walk these
consts via a shared matches_route() helper.

Auth semantics are unchanged for every route; this is a pure
refactor of the allow-list representation.

Add crates/librefang-api/tests/auth_public_allowlist.rs with four tests:
- always_public_paths_are_in_catalog: catalog consistency check (no HTTP)
- dashboard_read_paths_are_in_catalog: same for dashboard-reads group
- authed_paths_are_not_in_any_public_catalog: guards against accidental
  promotion of auth-required routes into the public lists
- always_public_routes_are_reachable_without_token: HTTP check via
  tower::ServiceExt::oneshot
- authed_routes_require_token: HTTP check — Authed paths must 401
- dashboard_read_routes_reachable_without_auth_when_no_key_configured:
  HTTP check — DashboardRead paths pass through in no-auth dev mode

Routes I'm uncertain about:
- /api/config (GET): currently Authed in test table (not in any public
  list in middleware), but /api/config/schema is AlwaysPublic. If the
  full config endpoint should be dashboard-readable, add it to
  PUBLIC_ROUTES_DASHBOARD_READS and the test table.

Fixes #3712
CRITICAL #1: Remove `prefix_get("/api/mcp/servers/")` from
PUBLIC_ROUTES_GET_ONLY. The prefix was added to support the OAuth
callback but its broadness exposed GET /api/mcp/servers/{name}
(returns server config including env vars) and /auth/status (returns
McpAuthState carrying OAuthTokens) without authentication. The OAuth
callback path /api/mcp/servers/*/auth/callback remains public via the
dedicated is_mcp_oauth_callback guard (prefix + suffix check) that was
already present in auth() — no functional change to the callback path.

CRITICAL #2: Add `exact_get("/api/config")` back to
PUBLIC_ROUTES_DASHBOARD_READS. The old allowlist had it in
dashboard_read_exact; it was dropped when the allowlist was refactored.
The handler already redacts secrets, so public read was intentional.
The dashboard SPA calls GET /api/config via getFullConfig() on every
render.

HIGH: Rewrite auth_public_allowlist.rs file header to accurately
describe the test as a static documentation test with a hardcoded
hand-maintained table, not a live router drift gate. The previous
comment claimed adding a server.rs route without updating the table
would fail CI — that was false.

MEDIUM: Update PUBLIC_ROUTES_ALWAYS doc comment to accurately describe
its grouping-by-semantic-role ordering rather than claiming all three
slices are sorted alphabetically.

Also: add regression test mcp_servers_prefix_does_not_leak_protected_paths
that asserts /api/mcp/servers/{name} and /auth/status return 401 while
/auth/callback stays non-401. Update is_in_always_public() helper to
mirror the is_mcp_oauth_callback guard so catalog-consistency checks
still pass for callback paths. Remove stale comments referencing the
removed prefix entry.
@houko
houko force-pushed the fix/3712-auth-allowlist-drift branch from 8b00b49 to 1d7369d Compare May 1, 2026 00:14
@houko
houko merged commit 1072811 into main May 1, 2026
13 checks passed
@houko
houko deleted the fix/3712-auth-allowlist-drift branch May 1, 2026 05:28
@houko houko mentioned this pull request May 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant