ip access control vs x-forwarded-for: two java footguns

a note on a configuration footgun that shows up in two java web servers, and on why these are documentation and naming problems rather than new vulnerabilities. both jetty and undertow ship an ip-based access-control handler, and both ship a feature that rewrites the client address from X-Forwarded-For. each is fine alone. combined — the canonical “we are behind a reverse proxy” setup — the ip allow-list ends up evaluating a value the client controls. this is publicly known in both projects; what is worth writing down is the precise gap between what each project’s docs (or method names) say and what the code does.

the shape

an ip access-control list is a security boundary: “only these source addresses may reach this handler.” a forwarded-header customizer is a convenience: “when a trusted proxy tells us the real client address, believe it.” the danger is the seam. if the acl reads the same “client address” the customizer rewrote, and the customizer trusts a client-supplied header (or picks the client-controlled entry from it), then the boundary is decided by the attacker. the durable defense is always the same: only trust forwarded headers from a configured set of trusted proxies, pick the correct hop rather than the leftmost client-controlled one, and never treat a client-supplied address as a boundary unless the proxy chain that produced it is itself trusted.

case 1: jetty — a false documented guarantee

jetty 12’s InetAccessHandler is the supported way to allow or deny by client ip. its javadoc states it

uses the real internet address of the connection, not one reported in the forwarded for headers, as this cannot be as easily forged.

(javadoc.jetty.org)

but ForwardedRequestCustomizer, the supported way to honor forwarded headers, wraps the request so everything — including the acl — sees the forwarded client address through Request.getConnectionMetaData().getRemoteSocketAddress() (jetty 12.1 programming guide). the acl predicate reads exactly that. so with both configured, a client whose real peer is not allow-listed can send X-Forwarded-For: 127.0.0.1 and pass. the javadoc’s promise — that it uses the real address “as this cannot be as easily forged” — is false in that configuration.

this is not new. a jetty maintainer answered a 2020 stack overflow question about exactly this combination by pointing to ForwardedRequestCustomizer (so #64753827), and a 2016 jetty issue (#844) already notes Forwarded-For is client-supplied and that the customizer takes the leftmost value. the behavior is by design. the defect is narrow: the security control’s own javadoc affirmatively guarantees a property the code does not provide, which is exactly what an operator reads when deciding to trust the acl as a boundary. (the legacy ee9-nested InetAccessHandler reads the raw channel address and does not diverge, so within jetty the jetty-core handler is the one that contradicts its docs.)

reproduced on the current release, org.eclipse.jetty:jetty-server:12.1.10, unmodified, with a 127.0.0.1-only acl and a ForwardedRequestCustomizer installed, client on a separate host so its real peer is non-loopback:

# control: no header -> acl denies the real peer
curl -s -o /dev/null -w '%{http_code}\n' http://server:8080/            # 403

# forged header -> acl reads 127.0.0.1 and allows
curl -s -w '%{http_code}\n' -H 'X-Forwarded-For: 127.0.0.1' http://server:8080/   # 200

case 2: undertow — a misleading method name

undertow’s ProxyPeerAddressHandler sets the peer address from X-Forwarded-For, and — to its credit — its javadoc warns correctly:

Handler that sets the peer address to the value of the X-Forwarded-For header. This should only be used behind a proxy that always sets this header, otherwise it is possible for an attacker to forge their peer address.

(ProxyPeerAddressHandler.java)

so the unconditional-trust part is a documented footgun, not a surprise. the sharper issue here is the selection logic. the handler picks the entry via a method named mostRecent(...), but that method returns the leftmost comma-separated entry of X-Forwarded-For — which is the oldest, most client-controlled value, the opposite of “most recent.” a correctly operated deployment behind a single trusted proxy appends the real client as the rightmost entry; mostRecent() ignores it and takes the leftmost one the client can write. IPAddressAccessControlHandler then decides allow/deny on exchange.getSourceAddress(), i.e. the value just rewritten from that leftmost entry. so even an operator who reads the warning and deploys behind a trusted proxy gets the spoofable hop selected.

reproduced on released io.undertow:undertow-core:2.3.24.Final, unmodified, 127.0.0.1-only IPAddressAccessControlHandler behind ProxyPeerAddressHandler, non-loopback client:

# control: no header                                  -> 403
# X-Forwarded-For: 127.0.0.1                           -> 200 (source rewritten to 127.0.0.1)
# X-Forwarded-For: 127.0.0.1, 8.8.8.8 (leftmost wins) -> 200

there is no cve or advisory for this interaction; the behavior is documented (the warning) and the selection bug is a long-standing naming/logic wart, not a newly hidden bypass.

the honest ask, per project

these do not warrant cves or “bypass” advisories. the reasonable requests are documentation and hardening:

  • jetty: correct the InetAccessHandler javadoc so it no longer promises it ignores forwarded headers; ideally offer a mode that evaluates the acl against the true connection address regardless of customizers (the behavior the docs describe and the ee9 handler already has), or warn at startup when both are combined.
  • undertow: fix or rename mostRecent() so it does not claim to pick the most-recent hop while returning the most client-controlled one; document that IPAddressAccessControlHandler behind ProxyPeerAddressHandler evaluates a client-influenced address; consider a trusted-proxy allow-list and rightmost-hop selection.

the minimum acceptable outcome in each case is that the docs and method names no longer describe a safety property the code does not provide.

the general lesson

this is one small, repeatable shape: a security control plus a header-trust feature, each correct alone, dangerous together, described by documentation (or a method name) that reflects the safe-in-isolation behavior rather than the combined one. it recurs across proxies and frameworks that derive a “client ip” from X-Forwarded-For and then use it for an access decision, a rate limit, or an audit log. trust forwarded headers only from configured trusted proxies, select the correct hop, and never treat a client-supplied address as a boundary unless the chain that produced it is trusted.

references

jetty:

undertow:

on this page