Skip to content

fix: strip upstream CORS headers in proxy and tunnel - #454

Merged
alnr merged 2 commits into
masterfrom
fix/344-duplicate-cors
Jul 29, 2026
Merged

fix: strip upstream CORS headers in proxy and tunnel#454
alnr merged 2 commits into
masterfrom
fix/344-duplicate-cors

Conversation

@alnr

@alnr alnr commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #344

Problem

When the upstream application handles CORS itself, its headers were forwarded alongside the ones the proxy's own CORS middleware sets, so the response carried two of each:

Access-Control-Allow-Origin       count=2  [http://localhost:3000 http://localhost:3000]
Access-Control-Allow-Credentials  count=2  [true true]

A duplicated Access-Control-Allow-Origin is a hard failure per the Fetch spec, and a duplicated Access-Control-Allow-Credentials no longer reads as exactly true, which fails a credentialed request outright. The browser rejects a response that both the upstream and the proxy answered correctly, and the developer cannot fix it from their own application — which is why the reporter had to fork and patch the CLI.

Fix

The proxy terminates CORS: the browser talks to the proxy, so the proxy's configuration is what must apply. The upstream computed its headers for a different client — the proxy — so they are not meaningful at this boundary and are now stripped.

Access-Control-Expose-Headers is deliberately not stripped. Browsers merge duplicates of it into a single list rather than failing, so removing it would silently discard headers the upstream meant to expose. (The patch in the issue would have dropped it; the rest of that patch also hardcodes localhost:3000/4000 origins and forces the origin check to always pass, which would defeat --allowed-cors-origins.)

Review caught a regression this introduced: cross-origin HEAD requests lost CORS entirely. HEAD is CORS-safelisted so browsers send it without a preflight, but corsx.CORSDefaultAllowedMethods omits it, and for a method it does not allow rs/cors returns from handleActualRequest before adding any header. With the upstream's copy stripped, such a response reached the browser with no CORS headers at all — worse than the bug being fixed. HEAD is now allowed.

Two unrelated defects fixed in passing, both in the function this PR extracts:

  • filepath.Join was used to match the welcome-page URL. On Windows it joins with a backslash, so the comparison never matched and --default-redirect-url was silently ignored. Now path.Join.
  • The nested append(a, append(b, c...)...) chains could write into a package-level slice's backing array. Now slices.Concat.

Worth your judgement

When --allowed-cors-origins is set and the request origin is not on the list, the proxy adds no CORS headers of its own, so stripping the upstream's leaves the response with none — where previously the upstream's header made the request succeed.

I think that is correct: if you explicitly restrict origins, letting the upstream silently override that restriction defeats the flag. But it is a behaviour change with no diagnostic, so it is your call. If you would rather it degraded, the cleaner shape is to dedupe at the ResponseWriter (keep the proxy's value, fall back to the upstream's when the proxy set none) instead of deleting unconditionally.

Also noted but not changed, as it predates this PR: the welcome-page rewrite is not gated on a 3xx status, so any response carrying a Location header is rewritten.

Tests

  • TestRespMiddleware covers stripping, keeping Expose-Headers, and the welcome-page rewrite.
  • TestCORSHeadersAreNotDuplicated is the regression test, run over both GET and HEAD. It builds its handler from the same corsOptions helper runReverseProxy uses rather than restating the options — the earlier hand-copied literal had already drifted from the real configuration. Verified it fails without the fix (count=2), and that the HEAD case fails without the AllowedMethods change ([]).

🤖 Generated with Claude Code

https://claude.ai/code/session_01JYzGVwAKQ4ormxRHZg1eDu

alnr and others added 2 commits July 29, 2026 14:25
When the upstream application handles CORS itself, its headers were
forwarded alongside the ones the proxy's own CORS middleware sets, so
the response carried two of each. A duplicated
Access-Control-Allow-Origin is a hard failure per the Fetch spec, and a
duplicated Access-Control-Allow-Credentials no longer reads as exactly
"true", so credentials are dropped. The browser rejected a response that
both the upstream and the proxy had answered correctly, and the
developer could not fix it from their own application.

The proxy terminates CORS: the browser talks to the proxy, so the
proxy's configuration is what must apply. The upstream computed its
headers for a different client — the proxy — so they are not meaningful
at this boundary and are now removed.

Access-Control-Expose-Headers is deliberately left alone, because
browsers merge duplicates of it into a single list rather than failing;
stripping it would silently discard headers the upstream meant to
expose.

The response middleware is extracted into respMiddleware so it can be
tested directly, mirroring reqMiddleware.

Closes #344

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYzGVwAKQ4ormxRHZg1eDu
Stripping the upstream's Access-Control-Allow-Origin regressed
cross-origin HEAD requests. HEAD is CORS-safelisted, so browsers send it
without a preflight, but corsx.CORSDefaultAllowedMethods omits it — and
for a method it does not allow, rs/cors returns from handleActualRequest
before adding any header. The response therefore reached the browser
with no CORS headers at all, where before this branch the upstream's
copy made it work.

HEAD is now allowed, and the CORS configuration is extracted into
corsOptions so the strip list and the headers that replace it are
derived from one place. The regression test builds its handler from that
same helper instead of restating the options, which had already drifted
from the real configuration.

Also replaces filepath.Join with path.Join when matching the welcome
page URL. filepath.Join joins with a backslash on Windows, so the
redirect rewrite never fired there and --default-redirect-url was
silently ignored. And folds the nested append chains into slices.Concat,
which removes the risk of writing into a package-level slice's backing
array.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JYzGVwAKQ4ormxRHZg1eDu
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@alnr, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 59 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 33eed971-3cef-46da-950e-357774ffbfbe

📥 Commits

Reviewing files that changed from the base of the PR and between 6b55b72 and 9f37179.

📒 Files selected for processing (2)
  • cmd/cloudx/proxy/helpers.go
  • cmd/cloudx/proxy/helpers_test.go

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.

@alnr
alnr merged commit 1e8656a into master Jul 29, 2026
18 checks passed
@alnr
alnr deleted the fix/344-duplicate-cors branch July 29, 2026 15:23
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.

ory proxy exposes duplicate cors headers when already configured upstream

2 participants