Skip to content

fix(authz): treat every forwarding header as the mark of a proxied re… - #15060

Merged
diegosouzapw merged 2 commits into
diegosouzapw:release/v3.8.51from
HouMinXi:fix/peer-stamp-bare-proxy-marker
Sep 29, 2026
Merged

diegosouzapw merged 2 commits into
diegosouzapw:release/v3.8.51from
HouMinXi:fix/peer-stamp-bare-proxy-marker

Conversation

@HouMinXi

@HouMinXi HouMinXi commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

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

  • None. This fixes a defect found by review, not a filed issue.

Validation

  • Change type: other
  • Focused tests: tests/unit/authz/peer-stamp-proxy-hop-headers.test.ts, tests/unit/live-ws-require-login-14256.test.ts
  • npm run lint
  • Reconciled with the current active release base
  • Production-code changes include a new or updated automated test in this PR

Tests Added Or Updated

  • tests/unit/authz/peer-stamp-proxy-hop-headers.test.ts
  • tests/unit/live-ws-require-login-14256.test.ts

Coverage Notes

  • The change is covered by the test files listed above. No coverage drop is expected; the new tests exercise the paths this PR adds.

Reviewer Notes

  • Any forwarding header now marks a request as proxied. The mark only downgrades a loopback or private peer to remote; it never grants access, so a caller forging one gains nothing.

Maintainer rework (merge-batch 2026-09-28 (release drain))

  • Reconciled with the tip, which meanwhile landed fix(authz): judge the IP allow/deny list by the connection unless a t… #15038/fix(ws): report the real client address from the WebSocket relays #15040 (forwarding headers only count from a peer that may be a proxy, plus resolveClientIp). Kept the tip's shape and applied this PR's delta on top: the marker is now hasProxyHopHeader(headers) && isTrustedProxyPeer(ip) (or a Cloudflare edge with cf-connecting-ip), so the wider header set still cannot flip the marker from a public peer.
  • A loopback request with no forwarding header stays local (peer-stamp-proxy-hop-headers.test.ts covers it). Behaviour change to note: a same-host proxy that only sets X-Forwarded-Proto/X-Forwarded-Host/Forwarded/Via is now treated as remote, so the loopback-only tier (spawn routes, no-password bootstrap) is no longer reachable through it — documented in docs/security/CORS.md.
  • Validated after the merge: 35 test files around peer stamping, route guard, live WS and client-address relays, all green.

…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
diegosouzapw merged commit d3da8bb into diegosouzapw:release/v3.8.51 Sep 29, 2026
11 of 16 checks passed
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.

2 participants