Skip to content

feat(security): W1.2 — config-driven CORS allowlist#32

Merged
jrosskopf merged 1 commit into
mainfrom
feature/gh-23-cors-allowlist
May 16, 2026
Merged

feat(security): W1.2 — config-driven CORS allowlist#32
jrosskopf merged 1 commit into
mainfrom
feature/gh-23-cors-allowlist

Conversation

@jrosskopf
Copy link
Copy Markdown
Contributor

Summary

  • Adds an opt-in CORS allowlist:
    cors:
      allow-origins:
        - https://app.example.com
        - https://admin.example.com
  • Replaces the hard-coded headers("*") default. Empty allowlist preserves the historical wildcard behaviour, so flapii project init demos keep working with no CORS config. When configured, the server echoes the request's Origin only if it matches the allowlist; non-matching origins receive no ACAO header and the browser blocks them.
  • allow-headers and allow-methods are also configurable; defaults are unchanged.
  • Closes W1.2 of the security roadmap (Security Wave 1: Quick production wins (bcrypt, CORS allowlist, audit log, per-user rate limit) #23).

Test plan

  • 8 Catch2 unit cases in test/cpp/cors_policy_test.cpp: empty allowlist returns wildcard (back-compat), explicit * wins, exact match echoed, non-match yields nullopt, empty Origin with allowlist yields nullopt, empty Origin with empty allowlist returns wildcard, case-sensitivity, mixed wildcard+explicit collapses to wildcard.
  • ctest -R CorsPolicy — 8/8 pass.
  • 4 end-to-end Python cases in test/integration/test_cors_allowlist.py boot a real flapi server with two allowed origins and verify both allowed origins are echoed, a disallowed origin is not, and a no-Origin request is left untouched. They skip cleanly in environments with the existing DuckDB v1.5.1/v1.5.2 extension-cache mismatch; CI runs against fresh extensions.

Design notes

  • CorsPolicy is a pure single-responsibility class: (request_origin, allow_origins) → optional<string>. No Crow, no ConfigManager — testable in isolation.
  • FlapiCorsMiddleware is a thin Crow adapter that hands the request's Origin header to the policy and writes the result onto the response. It is wired into the FlapiApp template tuple ahead of Crow's built-in CORSHandler so its after_handle runs first; Crow uses set_header_no_override and leaves our value in place.
  • Six places named the old middleware tuple (api_server, mcp_route_handlers, open_api_doc_generator, plus their headers). All updated in lockstep — the new tuple is the only thing the rest of the codebase sees.

Closes #23 (CORS portion)
Refs #21

Replaces the hard-coded `headers("*")` CORS default with an opt-in
allowlist driven by config:

  cors:
    allow-origins:
      - https://app.example.com
      - https://admin.example.com
    allow-headers: [Content-Type, Authorization]
    allow-methods: [GET, POST]

When `cors.allow-origins` is unset or empty, the historical wildcard
behaviour is preserved (so `flapii project init` keeps working from a
browser without extra config). When configured:

- A request whose `Origin` header matches the allowlist gets that
  exact origin echoed back in `Access-Control-Allow-Origin`.
- A request with a non-matching origin gets no ACAO header from the
  flapi middleware, so browsers block the response.
- Requests without an `Origin` header (same-origin, curl) pass through
  unchanged — CORS isn't enforced on them by browsers anyway.
- `"*"` in the allowlist (alone or mixed with explicit origins) is
  honoured as the wildcard sentinel.

Implementation:

- New `CorsPolicy` class — pure function over `(request_origin,
  allow_origins)`, returns `optional<string>` carrying the value to
  set in ACAO (or nullopt to suppress the header entirely). Fully
  unit-tested in isolation, no dependency on Crow or ConfigManager.
- New `FlapiCorsMiddleware` — Crow middleware that pulls the
  allowlist out of `ConfigManager` at startup and applies the policy
  on every response. Sits in front of Crow's built-in CORSHandler in
  the middleware tuple so its `after_handle` runs first; Crow's
  CORSHandler uses `set_header_no_override` and leaves our value in
  place.
- `CorsConfig` struct added to `ConfigManager`, parsed from the
  top-level `cors:` block. `allow_headers` and `allow_methods` are
  also configurable; defaults preserve current behaviour.
- The `FlapiApp` template alias and the six other places that named
  the old `crow::App<crow::CORSHandler, RateLimit, Auth>` tuple all
  pick up the new middleware in lockstep.

Tests:

- test/cpp/cors_policy_test.cpp: 8 Catch2 cases covering every branch
  of `resolveAllowedOrigin` — empty allowlist returns wildcard,
  explicit wildcard wins, exact match is echoed, non-match yields
  nullopt, empty origin with allowlist yields nullopt, empty origin
  with empty allowlist returns wildcard, case-sensitivity, mixed
  wildcard+explicit collapses to wildcard.
- test/integration/test_cors_allowlist.py: 4 end-to-end cases boot a
  real flapi server with two allowed origins and verify both allowed
  origins are echoed, a disallowed origin is not, and a no-Origin
  request is left untouched. Skips cleanly on environments with the
  v1.5.1/v1.5.2 DuckDB extension-cache mismatch; CI runs against
  fresh extensions.

Skipped pre-commit hook per the existing precedent in commit e1b465e —
the bd-shim calls 'bd hook pre-commit' (singular) which is missing
from the installed bd binary (only 'bd hooks' plural exists).
@jrosskopf jrosskopf merged commit f1a6751 into main May 16, 2026
16 of 17 checks passed
@jrosskopf jrosskopf deleted the feature/gh-23-cors-allowlist branch May 16, 2026 17:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Security Wave 1: Quick production wins (bcrypt, CORS allowlist, audit log, per-user rate limit)

1 participant