Skip to content

fix: prevent CORS header duplication on middleware early-return responses#1347

Merged
sansyrox merged 2 commits into
mainfrom
fix/cors-preflight-regression
Mar 27, 2026
Merged

fix: prevent CORS header duplication on middleware early-return responses#1347
sansyrox merged 2 commits into
mainfrom
fix/cors-preflight-regression

Conversation

@sansyrox
Copy link
Copy Markdown
Member

@sansyrox sansyrox commented Mar 26, 2026

Summary

  • Fixes the regression where ALLOW_CORS breaks browser preflight (OPTIONS) requests in 0.82.0
  • The 0.82.0 performance commit changed the response finalization path to unconditionally extend global headers onto every response, including early-return responses from before-middleware. When ALLOW_CORS is used, the CORS middleware sets Access-Control-Allow-Origin on the preflight response, and then the global header merge duplicates it. Per the Fetch spec, browsers reject responses with multiple Access-Control-Allow-Origin values.
  • Fix: adds Headers::set_missing() — a merge method that only inserts headers for keys not already present on the response — and uses it in place of extend when applying global response headers. This gives middleware-set headers precedence over global defaults.

Changes

File What
src/types/headers.rs Add set_missing() method
src/server.rs Replace extend() with set_missing() at the global header merge point
integration_tests/cors_app.py Standalone test app with ALLOW_CORS enabled
integration_tests/test_cors_preflight.py 7 integration tests using real HTTP requests (no TestClient)

Test plan

  • New CORS integration tests (7 tests) — real subprocess server, real HTTP requests via requests
    • OPTIONS preflight returns 204 with correct CORS headers
    • No duplicate Access-Control-Allow-Origin header on preflight
    • Disallowed origin returns 403
    • Normal GET/POST with allowed origin carry CORS headers
    • Requests without Origin still get global CORS headers
    • Route-level custom headers not clobbered by globals
  • All 330 existing integration tests pass
  • All 17 unit tests pass

Closes #1346

Made with Cursor

Summary by CodeRabbit

  • Bug Fixes

    • Global CORS headers no longer overwrite route-specific response headers; endpoints' custom headers are preserved while missing CORS headers are filled in.
  • Tests

    • Added end-to-end CORS preflight and request tests, including a small test server for exercising allowed/disallowed origins, OPTIONS preflight behavior, and verification that custom response headers are retained.

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 26, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
robyn Ready Ready Preview, Comment Mar 26, 2026 11:32pm

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 26, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 75bc38df-b77a-4a88-b749-c62716eb31bc

📥 Commits

Reviewing files that changed from the base of the PR and between 7988838 and 5b1dd80.

📒 Files selected for processing (1)
  • integration_tests/test_cors_preflight.py
✅ Files skipped from review due to trivial changes (1)
  • integration_tests/test_cors_preflight.py

📝 Walkthrough

Walkthrough

Global response headers are now applied only when missing, preserving route-specific headers. A new Robyn CORS test app and integration tests (including OPTIONS preflight, allowed/disallowed origins, and header-duplication checks) were added.

Changes

Cohort / File(s) Summary
CORS Test App & Tests
integration_tests/cors_app.py, integration_tests/test_cors_preflight.py
Added a standalone Robyn test app (routes: GET /, POST /data, GET /custom-header) and integration tests that launch the app, exercise CORS preflight (OPTIONS) and normal requests, assert CORS headers for allowed origins, verify disallowed origin returns 403, and check no duplicate Access-Control-Allow-Origin and preservation of custom headers.
Header API & Server Behavior
src/types/headers.rs, src/server.rs
Added Headers::set_missing(&mut self, headers: &Headers) to insert only absent header keys. Server now uses set_missing when applying global response headers (instead of extend), so existing response headers are preserved.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I nudged the headers, quiet and sly,

Set missing ones—let the custom ones fly.
OPTIONS tiptoed, no duplicates found,
Origins welcomed when rightly bound.
A happy hop across the CORS ground.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: preventing CORS header duplication on middleware early-return responses, which directly addresses the core issue in the changeset.
Description check ✅ Passed The PR description comprehensively covers the issue, root cause, solution approach, files changed, and test plan with specific details and evidence of testing.
Linked Issues check ✅ Passed The PR fully addresses #1346 by implementing the fix for duplicate CORS headers, adding proper test coverage, and preserving middleware-set headers while preventing global header duplication.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the CORS regression: adding the set_missing() method, using it in global header merge, and adding integration tests to verify the fix.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/cors-preflight-regression

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented Mar 26, 2026

Merging this PR will not alter performance

✅ 189 untouched benchmarks


Comparing fix/cors-preflight-regression (5b1dd80) with main (a54ff96)

Open in CodSpeed

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/server.rs (1)

143-145: ⚠️ Potential issue | 🟠 Major

Fix header merge semantics in const-route bake path to match dynamic path.

Line 578 correctly uses set_missing() to preserve route-set headers over global defaults. However, ConstRouter::bake_global_headers() at line 187 in src/routers/const_router.rs uses extend(), which will append global headers unconditionally. This creates inconsistent behavior: const-route responses may have duplicate headers (e.g., duplicate Access-Control-Allow-Origin) while dynamic routes will not.

Change line 187 from combined.extend(extra_headers.iter().cloned()); to use set_missing() semantics instead, ensuring both paths prefer route-set headers over global defaults.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server.rs` around lines 143 - 145, ConstRouter::bake_global_headers
currently appends global headers unconditionally using
combined.extend(extra_headers.iter().cloned()), which causes duplicate headers
and differs from the dynamic path that uses set_missing semantics; update
bake_global_headers to iterate extra_headers and only insert headers that are
not already present (i.e., use the same set_missing logic as the dynamic route
path) so route-set headers are preserved and global defaults are only applied
when missing.
🧹 Nitpick comments (1)
integration_tests/test_cors_preflight.py (1)

146-156: Assert the POST CORS headers too.

This currently passes even if POST responses lose Access-Control-Allow-Origin, so it doesn't actually cover the non-preflight POST branch this PR is meant to protect.

Possible fix
     assert resp.status_code == 200
+    assert resp.headers["Access-Control-Allow-Origin"] == ALLOWED_ORIGIN
+    assert "POST" in resp.headers["Access-Control-Allow-Methods"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@integration_tests/test_cors_preflight.py` around lines 146 - 156, The
test_post_with_allowed_origin is only asserting a 200 status and misses
verifying CORS response headers; update the test to also assert that
resp.headers contains "Access-Control-Allow-Origin" equal to ALLOWED_ORIGIN (and
any other expected CORS headers your server returns, e.g.,
"Access-Control-Allow-Credentials" if applicable) so the non-preflight POST
branch is actually covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@integration_tests/test_cors_preflight.py`:
- Around line 74-81: The HTTP calls in integration_tests/test_cors_preflight.py
currently use requests without a timeout (e.g., the requests.options(...) call)
which can hang; update every requests call in this file (including the
requests.options call shown and the other requests.* calls later) to include a
small timeout parameter such as timeout=5 (pass as a named argument, e.g.,
requests.options(..., timeout=5)) so tests fail fast instead of hanging.
- Around line 100-107: The test currently skips the duplicate-header check when
raw_headers lacks getall; instead assert that raw_headers exposes getall (or
that resp.raw has headers supporting getall) before attempting to read duplicate
Access-Control-Allow-Origin values so the test fails rather than silently
passing; update the logic around raw_headers / origin_values (variables
resp.raw, raw_headers, origin_values and method getall) to assert
hasattr(raw_headers, "getall") (or assert origin_values is not None) prior to
the len(...) check and then proceed to assert exactly one occurrence and that
resp.headers["Access-Control-Allow-Origin"] == ALLOWED_ORIGIN.
- Around line 26-52: The readiness loop in _start_cors_server currently only
checks for a listening socket and can be fooled by an unrelated process; add a
liveness check by calling process.poll() inside the while loop and if it returns
a non-None value (process exited) kill the process group (os.killpg) and raise a
ConnectionError (including any available context), so the function fails fast
when the spawned cors_app.py dies before becoming ready. Ensure the poll check
runs before attempting socket.connect and that the cleanup uses the same process
variable and exit handling as the existing timeout branch.

---

Outside diff comments:
In `@src/server.rs`:
- Around line 143-145: ConstRouter::bake_global_headers currently appends global
headers unconditionally using combined.extend(extra_headers.iter().cloned()),
which causes duplicate headers and differs from the dynamic path that uses
set_missing semantics; update bake_global_headers to iterate extra_headers and
only insert headers that are not already present (i.e., use the same set_missing
logic as the dynamic route path) so route-set headers are preserved and global
defaults are only applied when missing.

---

Nitpick comments:
In `@integration_tests/test_cors_preflight.py`:
- Around line 146-156: The test_post_with_allowed_origin is only asserting a 200
status and misses verifying CORS response headers; update the test to also
assert that resp.headers contains "Access-Control-Allow-Origin" equal to
ALLOWED_ORIGIN (and any other expected CORS headers your server returns, e.g.,
"Access-Control-Allow-Credentials" if applicable) so the non-preflight POST
branch is actually covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 154ba304-2999-4bff-ab0e-cbb4fb7ec756

📥 Commits

Reviewing files that changed from the base of the PR and between a54ff96 and 0854ade.

📒 Files selected for processing (4)
  • integration_tests/cors_app.py
  • integration_tests/test_cors_preflight.py
  • src/server.rs
  • src/types/headers.rs

Comment thread integration_tests/test_cors_preflight.py
Comment thread integration_tests/test_cors_preflight.py
Comment thread integration_tests/test_cors_preflight.py
…nses

The 0.82.0 performance commit changed the response finalization path so
that global response headers are unconditionally `extend`ed onto every
response, including early-return responses from before-middleware. When
ALLOW_CORS is used, this causes Access-Control-Allow-Origin (and other
CORS headers) to appear twice — once from the middleware and once from
the global defaults. Per the Fetch spec, browsers reject responses with
duplicate Access-Control-Allow-Origin values, breaking preflight.

Fix: replace `extend` (append) with `set_missing` (set-if-absent) when
merging global response headers. This gives middleware-set headers
precedence over global defaults, preventing duplication.

Closes #1346

Made-with: Cursor
@sansyrox sansyrox merged commit 55cff1c into main Mar 27, 2026
42 of 47 checks passed
@sansyrox sansyrox deleted the fix/cors-preflight-regression branch March 27, 2026 00:12
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.

[Regression] ALLOW_CORS breaks browser preflight (OPTIONS) in 0.82.0

1 participant