fix(server): only believe X-Forwarded-Proto from a trusted proxy (#474) - #505
Merged
Conversation
The `/mcp` plaintext guard (#471) refuses a bearer token over a plaintext non-loopback connection, but it honoured `X-Forwarded-Proto: https` from any peer — so the guard could be switched off by the caller, including by the operator it exists to protect, copy-pasting a header out of a smoke-test recipe onto a real network. The forwarded scheme is now believed only when the immediate peer — the socket address, which no client can set — is a trusted proxy. New `managementApi.trustedProxies` / `PADDOCK_MANAGEMENT_TRUSTED_PROXIES` takes IPs, CIDRs, the proxy-addr presets, or `none` / `all`. The default (loopback + the private address space) keeps every TLS-terminating sidecar recipe working while refusing a public peer's claim, and the guard logs a one-per-peer warning while it is leaning on that default rather than a proxy the operator named. Deliberately not built on Fastify's `trustProxy`: in 4.28 that makes `req.protocol` return `x-forwarded-proto` whenever the header is present, with no trust check (the trust function gates `req.ip`/`req.ips` only), which would reproduce the bug being fixed. Not an authentication change: `/mcp` still requires a valid bearer token. Co-Authored-By: Claude <noreply@anthropic.com>
Deploying paddock with
|
| Latest commit: |
16cecc9
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f9a49971.paddock-7u2.pages.dev |
| Branch Preview URL: | https://fix-474-forwarded-proto-trus.paddock-7u2.pages.dev |
Merged
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.
Closes #474.
The bug
isSecureRequest()honouredX-Forwarded-Proto: httpsfrom any peer with notrusted-proxy check, so the
/mcpplaintext guard could be turned off by thecaller — with a header copy-pasted out of a smoke-test recipe.
Severity — calibrated deliberately
Not an auth bypass, and this PR does not claim to be one. A valid bearer
token is still required; spoofing the header buys an attacker nothing they could
not already do. The guard exists so an operator doesn't ship their own token
in clear over a network believing they were covered. What was broken is that the
one signal the guard relied on was set by the party it was supposed to protect
the operator from — including themselves.
So the goal was: make the guard rest on something the caller cannot set, without
turning a footgun-preventer into a thing that 403s working deployments.
The fix
X-Forwarded-Protois believed only when the immediate peer — the socketaddress, not any header — is a trusted proxy.
New config
managementApi.trustedProxies(YAML) /PADDOCK_MANAGEMENT_TRUSTED_PROXIES(env wins, as everywhere else): IPs, CIDRs, the
proxy-addrpresetsloopback/linklocal/uniquelocal, or the wordsnone/all. Anunparseable entry is dropped with a config error rather than failing the boot —
that direction can only make the guard stricter.
Default:
loopback, linklocal, uniquelocal. In every recipe the TLSterminator sits on the host or on a private container/pod network, so this keeps
auth-basic/(Caddy + nginx sidecars on a Compose bridge), the k8s ingress, andthe
docker/recipe working untouched — while a public peer can no longerswitch the guard off, which it could before.
The default's honest limit is that it cannot tell your proxy at
172.18.0.5froma laptop at
192.168.1.50— nothing in an IP packet says which one is a proxy.So when the verdict rests on the default list rather than a proxy you named,
the server logs a one-per-peer warning naming the address and pointing at the
setting. Naming your terminator is what upgrades this from footgun-preventer to
control;
noneis the strict posture.What I rejected, and why
trustProxy(what the issue suggests). It does not do what theissue expects in the version we pin: in
fastify@4.28.1(
lib/request.js:134), the trust-proxyprotocolgetter returnsx-forwarded-protowhenever the header is present — the compiled trustfunction gates
req.ip/req.ipsonly, neverprotocol. Building the guard onreq.protocolwould have reproduced the exact bug while looking fixed. It isalso a server-wide switch, and I did not want a spoofable
req.protocol/req.hostnamesitting in the app for the next caller to trust. I do useproxy-addrdirectly (fastify's own CIDR matcher; now an explicit dependency)rather than hand-rolling IPv6/IPv4-mapped range logic in a security check.
172.17.0.1) as loopback-equivalent.The issue asks for this to be decided on purpose: no. A request from
another host to a
0.0.0.0-published port is SNAT'd to that same address, so"peer is the gateway" proves nothing about where the traffic came from. The
documented smoke test now goes in through the container's own loopback
(
docker compose exec paddock curl http://127.0.0.1:4000/mcp …), which is areal loopback peer and needs no header at all — strictly better than the
workaround
paddock-deploywas about to bless.none(trust nothing). Correct on paper, and it would 403every existing sidecar deployment on upgrade for a low-severity,
operator-protecting guard. The trade the issue explicitly asks for is the other
way round.
HostmatchingpublicUrl. Cute, and it separates"real proxy" from "copy-pasted curl" — but
Hostis equally client-settable,and it fails exactly when someone curls the public hostname over http, which is
the footgun. Security-by-plausibility; dropped.
PADDOCK_TRUSTED_PROXIES. Kept it namespaced tomanagementApibecause that is the only decision it makes today.
PADDOCK_AUTH_MODE=trusted-headerstill trusts its identity header from any peer by design — that mode assumes
the proxy is the sole ingress, which is a network posture, not a header check.
Documented rather than silently widened.
Threat model, stated plainly
asserting a scheme the connection doesn't have, from anywhere but a peer the
operator has vouched for — and, under any configuration, from a public peer.
works; a valid token was always required. It does not authenticate the proxy
(any host at a trusted address is trusted — this is IP trust, not mTLS), and
under the default list any private-network peer can still present a forwarded
scheme. It is a transport-hygiene guard, not an access control.
can still assert https and leak a token in clear on that LAN. That is now
visible (a warning naming the peer) and fixable in one line (name the
proxy, or
none) rather than silent.Verification
Unit + integration (
packages/server/test/unit/trusted-proxy.test.ts, and a new/mcp — transport guardblock inmcp-gate.test.tsdriving real peer addressesthrough
inject({ remoteAddress })). Full server suite green.Live, on a real server with real peers (
pm-hosted dev instance, throwawaytoken, Caddy in front). This box's TLS terminator is a genuinely separate host —
192.168.1.33— which made the matrix honest:trustedProxies127.0.0.1(loopback)127.0.0.1https192.168.1.80(off-host shape)insecure_transport192.168.1.80https192.168.1.80http192.168.1.33(real TLS)https192.168.1.80https[192.168.1.33]192.168.1.33https[192.168.1.33]192.168.1.80httpsnone127.0.0.1nonehttpsnoneServer logs confirmed the reasons (
"via":"spoofable","via":"plaintext") andthat the default-list warning fires exactly once per distinct peer.
Not exercised live (covered by unit tests only, and by the identical code
path the refused rows above take): a peer with a public source address, and
a peer inside a Docker container. This box has no public source address to send
from, and its Docker daemon is remote, so a container there cannot reach the
test server.
isTrustedProxy("203.0.113.9", default) === falseis asserted intrusted-proxy.test.ts; a public peer reaches the samevia: "spoofable"→ 403branch that
192.168.1.80demonstrably took under an explicit list.Docs
docs/CONFIGURATION.mdgains a Trusted proxies section: the value table, therecommended posture, the Docker-NAT explanation, the in-container smoke-test
command, and the explicit note about
trusted-headerauth being a separatequestion. Changeset included (patch).
🤖 Generated with Claude Code