Skip to content

Fix origin check with default ports and X-Forwarded-Port - #8059

Open
harshbhx wants to merge 1 commit into
coder:mainfrom
harshbhx:fix/6166-origin-ignore-port
Open

harshbhx wants to merge 1 commit into
coder:mainfrom
harshbhx:fix/6166-origin-ignore-port

Conversation

@harshbhx

Copy link
Copy Markdown
Contributor

Fixes #6166

Summary

The origin check now treats a default port (:443 for https, :80 for http) as no port, and uses X-Forwarded-Port when X-Forwarded-Host has no port. Everything else is still an exact match. This is the approach agreed in the issue discussion.

Problem

authenticateOrigin compares the forwarded host to the origin's host as plain strings (src/node/http.ts#L406). Two reverse-proxy setups fail that comparison even though the request is same-origin, and the websocket then closes with 1006:

Origin Proxy config Host code-server sees Result today
https://example.com X-Forwarded-Host $host:$server_port example.com:443 ❌ rejected
https://example.com:8080 X-Forwarded-Host $host + X-Forwarded-Port $server_port example.com ❌ rejected

In the second case nginx's $host drops the port and sends it separately in X-Forwarded-Port, but code-server never reads that header. This is the standard config @smerschjohann suggested in the issue, so it should just work.

Solution

  1. Default ports are normalized. hostMatchesOrigin() parses the host and treats the origin protocol's default port the same as no port. example.com:443 matches https://example.com, but example.com:80 does not (that's a different origin).
  2. X-Forwarded-Port fills in a missing port. When the host comes from X-Forwarded-Host and has no port, the first value of X-Forwarded-Port is appended. A port already in the host wins, and non-numeric values are ignored.
  3. No other leniency. A host without a port still doesn't match an origin on :8080, and different ports are still rejected.

The host is parsed with a non-special URL scheme (host://…), which keeps the port exactly as written (special schemes drop default ports) and handles bracketed IPv6 such as [::1]:8080.

X-Forwarded-Port is only applied for the origin check (getHost(req, true)). getHost(req) is unchanged because the domain proxy matches its result against an anchored regex (code-{{port}}.example.com), and an appended port would break that.

I didn't use X-Forwarded-Port with the Host or Forwarded headers, to keep the change to what was discussed. Forwarded can already carry the port in host=.

Changes

File Change
src/node/http.ts New hostMatchesOrigin() (exact match with default-port normalization) replaces the string comparison. New addForwardedPort(). getHost() gets an optional withForwardedPort flag, used only by authenticateOrigin
test/unit/node/http.test.ts New origin cases (default ports, wrong-protocol default port, missing port, IPv6). New x-forwarded-port block, plus a test that getHost() still returns the host without the port

Before / After

Origin: https://example.com
X-Forwarded-Host: example.com:443
  before: 403 host "example.com:443" does not match origin "example.com"
  after:  allowed

Origin: https://example.com:8080
X-Forwarded-Host: example.com
X-Forwarded-Port: 8080
  before: 403 host "example.com" does not match origin "example.com:8080"
  after:  allowed

Origin: https://example.com:8080
X-Forwarded-Host: example.com          (no X-Forwarded-Port)
  before: 403
  after:  403 (unchanged, no port is assumed)

How to test

  1. ./test/node_modules/.bin/jest --forceExit --coverage=false test/unit/node/http.test.ts
  2. Optionally, put code-server behind nginx on a non-default port with:
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Forwarded-Port $server_port;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection upgrade;
    The workbench websocket should connect instead of closing with 1006.

Testing done

  • http.test.ts: 127/127 passing. The shared origin cases each run against Host, X-Forwarded-Host and three Forwarded header variants
  • Against main, 15 of the new tests fail (default-port and X-Forwarded-Port cases)
  • Against a looser "ignore the port when the host has none" version, 10 fail, so the stricter behaviour is pinned down too
  • Full unit suite: 409/409 passing
  • prettier --check, eslint and tsc --noEmit clean
  • Not tested behind a real nginx proxy

Notes for reviewers

  • trusted-origins matching is unchanged and still runs before this check.
  • Only the first value of a comma-separated X-Forwarded-Port is used, the same way X-Forwarded-Host is handled.
  • Browsers can't set X-Forwarded-Port on a websocket handshake, and the hostname still has to match exactly, so the header can't be used to let a different site through.

The origin check compared the host header to the origin's host as plain
strings, so `example.com:443` did not match `https://example.com`, and a
proxy forwarding `$host` (no port) together with X-Forwarded-Port failed
on any non-default port.

Default ports (80 for http, 443 for https) are now treated as no port,
and when X-Forwarded-Host has no port the port from X-Forwarded-Port is
used.  Everything else is still an exact match.  getHost() is unchanged
for the domain proxy.

Fixes coder#6166
@harshbhx
harshbhx requested a review from a team as a code owner October 15, 2026 20:50
Copilot AI balanced review requested due to automatic review settings October 15, 2026 20:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

This branch has not been deployed

No deployments
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.

Account for hosts with/without ports in origin check

2 participants