fix(authz): treat every forwarding header as the mark of a proxied re… - #15060
Merged
diegosouzapw merged 2 commits intoSep 29, 2026
Merged
diegosouzapw merged 2 commits into
diegosouzapw merged 2 commits into
Conversation
…quest A reverse proxy on the same host reaches OmniRoute from a loopback socket, so the only thing that tells its callers apart from the local operator is the forwarding headers it adds. The server stamped a request as proxied only when X-Forwarded-For or X-Real-IP was present. The nginx example in docs/security/CORS.md sets Host and X-Forwarded-Proto and nothing else, so a caller behind that block arrived from loopback with no mark and was granted the loopback-only tier, which is what keeps spawn capable routes and the no-password bootstrap windows local. The live dashboard WebSocket had the same two-header check for its pre-setup anonymous window. Mark a request as proxied when it carries any x-forwarded-* header, X-Real-IP, Forwarded or Via, in one helper shared by the stamp and the WebSocket sidecar. Callers can send these headers too, but the mark only downgrades a loopback or private-network peer to remote and never grants anything, so forging one gains nothing. The nginx example now sets the address headers as well. A proxy that adds no header at all still cannot be told apart from a local caller; the docs say to keep the headers for that reason. The tests put a real listener, stamped the way the custom server does, behind a real same-host proxy and read the verdict for what arrives. Signed-off-by: Minxi Hou <houminxi@gmail.com>
# Conflicts: # scripts/dev/peer-stamp.mjs # src/server/authz/peerStamp.ts
diegosouzapw
merged commit Sep 29, 2026
d3da8bb
into
diegosouzapw:release/v3.8.51
11 of 16 checks passed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A reverse proxy on the same host reaches OmniRoute from a loopback
socket, so the only thing that tells its callers apart from the local
operator is the forwarding headers it adds. The server stamped a request
as proxied only when X-Forwarded-For or X-Real-IP was present. The nginx
example in docs/security/CORS.md sets Host and X-Forwarded-Proto and
nothing else, so a caller behind that block arrived from loopback with no
mark and was granted the loopback-only tier, which is what keeps spawn
capable routes and the no-password bootstrap windows local. The live
dashboard WebSocket had the same two-header check for its pre-setup
anonymous window.
Mark a request as proxied when it carries any x-forwarded-* header,
X-Real-IP, Forwarded or Via, in one helper shared by the stamp and the
WebSocket sidecar. Callers can send these headers too, but the mark only
downgrades a loopback or private-network peer to remote and never grants
anything, so forging one gains nothing. The nginx example now sets the
address headers as well. A proxy that adds no header at all still cannot
be told apart from a local caller; the docs say to keep the headers for
that reason.
The tests put a real listener, stamped the way the custom server does,
behind a real same-host proxy and read the verdict for what arrives.
Related Issues
Validation
tests/unit/authz/peer-stamp-proxy-hop-headers.test.ts,tests/unit/live-ws-require-login-14256.test.tsnpm run lintTests Added Or Updated
tests/unit/authz/peer-stamp-proxy-hop-headers.test.tstests/unit/live-ws-require-login-14256.test.tsCoverage Notes
Reviewer Notes
Maintainer rework (merge-batch 2026-09-28 (release drain))
resolveClientIp). Kept the tip's shape and applied this PR's delta on top: the marker is nowhasProxyHopHeader(headers) && isTrustedProxyPeer(ip)(or a Cloudflare edge withcf-connecting-ip), so the wider header set still cannot flip the marker from a public peer.peer-stamp-proxy-hop-headers.test.tscovers it). Behaviour change to note: a same-host proxy that only setsX-Forwarded-Proto/X-Forwarded-Host/Forwarded/Viais now treated as remote, so the loopback-only tier (spawn routes, no-password bootstrap) is no longer reachable through it — documented indocs/security/CORS.md.