Skip to content

fix(security): tolerate Chromium 150+ stripping port from Origin while keeping port CSRF defense - #615

Open
LCZcoding wants to merge 1 commit into
agegr:mainfrom
LCZcoding:fix/chromium-port-stripping
Open

fix(security): tolerate Chromium 150+ stripping port from Origin while keeping port CSRF defense#615
LCZcoding wants to merge 1 commit into
agegr:mainfrom
LCZcoding:fix/chromium-port-stripping

Conversation

@LCZcoding

@LCZcoding LCZcoding commented Aug 26, 2026

Copy link
Copy Markdown

Summary

isApiRequestOriginAllowed (lib/request-security.ts) rejects every /api/* request with 403 on Chromium 150+, because Chrome now strips the port from the Origin header for same-origin requests on non-default ports.

Fixes #542

Evidence

Requested by the reviewer on #544: HAR or raw headers from a failing POST request, with the exact Chrome version.

Exact Chrome version (from chrome://version/):

Google Chrome    152.0.7977.65 (正式版本) (64 位)
revision         fc4d67f1788019a27e32511137ceccbd2fafdaaa-refs/branch-heads/7977@{#1892}
OS               Windows 11 Version 25H2 (Build 26200.9168)
JS engine        V8 15.2.124.18
User-Agent       Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36

The User-Agent only carries the major version (152.0.0.0); the build version is 152.0.7977.65. The behavior described below was verified against this exact build.

Failing POST request, captured from DevTools → Network:

POST /api/default-cwd HTTP/1.1
Host: 127.0.0.1:30141
Origin: http://127.0.0.1                          ← port stripped by Chrome
Referer: http://127.0.0.1:30141/                  ← Referer also strips the port
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
sec-ch-ua: "Chromium";v="152", "Not?A_Brand";v="24", "Google Chrome";v="152"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Content-Length: 0

Response: 403 Forbidden, body {"error":"Untrusted API request"}.

The same browser also fires GET requests in cors mode (e.g. /api/agent/running polled every ~2.5s) which produce identical behavior — Origin: http://127.0.0.1 paired with Host: 127.0.0.1:30141. HAR of one such session attached to the original #544.

Why the reviewer couldn't reproduce this

Origin is only sent when Sec-Fetch-Mode: cors (or similar) is set. A naïve reproduction that uses cURL, opens the URL differently, or runs against a default port will see 200 responses — the bug only surfaces for browser fetches against non-default ports.

Chrome version note

The capture is from Chrome 152.0.7977.65, which is newer than the 151 the reviewer reported testing on. If a real Chrome 151 client behaves differently (i.e. preserves the port on POST), please share its raw Origin / Host headers and we can revisit. Practically the fix should hold for either Chromium generation:

Client behavior Origin header Result
Chrome 151, port preserved http://127.0.0.1:30141 accepted (strict match)
Chrome 152+, port stripped http://127.0.0.1 accepted (port-aware match)
Cross-port CSRF attempt http://myapp.com:8080 vs Host: myapp.com:30141 rejected

So the patch is forward-compatible with both Chrome 151 and Chrome 152+ — the only branch where it changes behavior vs. the current main is when Chrome strips the port on a same-origin request, and that branch is precisely the broken one.

Reproduction

  1. npm install && npm run dev on a fresh checkout
  2. Open http://127.0.0.1:30141 in Chrome 150+
  3. DevTools → Network → any /api/* request → 403, body {"error":"Untrusted API request"}
  4. Inspect Request Headers — Origin lacks the :30141 suffix

Fix

Build on top of the proxy-rewrite handler that landed on main. Add a third accept path in isApiRequestOriginAllowed for the Chromium port-strip:

Origin Host Behavior
http://127.0.0.1 127.0.0.1:30141 accept (Chromium 150+ strip)
http://127.0.0.1:30141 127.0.0.1:30141 accept (port-matching baseline)
http://myapp.com:8080 myapp.com:30141 reject (cross-port CSRF)
https://myapp.com:30141 myapp.com:30141 reject (scheme downgrade)
http://attacker.example 127.0.0.1:30141 reject (cross-site)

Security

The Host allowlist (isApiRequestHostAllowed) is unchanged and remains the authoritative gate on where the request was addressed. The proxy-rewrite handler is unchanged. The only loosening vs. strict canonical-origin comparison is accepting same-origin requests where Chromium has stripped the port from Origin.

Tests

Existing 13 tests + 6 new ones, all 19/19 green:

  • allows same-origin requests when Chromium strips the port from Origin (loopback IP / LAN IP / loopback name)
  • still rejects when Origin hostname differs from Host even with port stripped (cross-name and DNS rebind regression)
  • rejects cross-port CSRF when Origin includes a mismatched port (the regression a hostname-only fix would have introduced)
  • accepts same-origin requests when Origin port matches the request port (pre-Chromium-150 baseline)
  • rejects Origin whose scheme differs even when hostname and port match (https → http downgrade)
  • accepts real-world Chrome 152 POST against /api with the port stripped from Origin (mirrors the captured POST /api/default-cwd exactly)

Related

…e keeping port CSRF defense

Chrome 152 strips the port from the Origin header for same-origin requests
against non-default-port backends, on both GET and POST in cors mode.
Captured headers from POST /api/default-cwd on Chrome 152:

  Host: 127.0.0.1:30141
  Origin: http://127.0.0.1          ← port stripped by Chromium
  Sec-Fetch-Site: same-origin
  Sec-Fetch-Mode: cors

The previous strict canonical-origin comparison therefore rejected every
legitimate /api/* call against a non-default-port pi-web server, producing
403 'Untrusted API request' on every fresh install (issue agegr#542).

This patch adds a third accept path to isApiRequestOriginAllowed, alongside
the existing strict canonical and proxy-rewrite checks:

  - Strict canonical-origin match (existing)
  - Proxy-rewritten Origin onto backend authority (existing, requires
    x-forwarded-proto + same-origin)
  - Chromium 150+ stripped the port from Origin (new): same-origin request
    where scheme + hostname match the request URL, and any port included
    in Origin matches the request port.

Scheme is taken from request.url, not the Host header, so an http<->https
mismatch is still rejected even though Host itself has no scheme. The
port is only enforced when Origin actually carries one — that is what
blocks cross-port same-site CSRF (e.g. attacker on myapp.com:8080 forging
a request to myapp.com:30141 with Origin: http://myapp.com:8080) without
breaking legitimate Chrome 152 clients.

Tests: 19/19 green. Four new regression tests cover cross-port CSRF,
port-matching baseline, https->http scheme mismatch, and a real Chrome
152 POST request captured against /api/default-cwd.
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.

Bug: All API requests return 403 on Chromium 150+ browsers (PR #496 has been submitted)

1 participant