Skip to content

[Security] StripUntrustedProxyHeadersHandler does not strip X-Forwarded-Host header #2170

Description

@refleeexzz

The StripUntrustedProxyHeadersHandler class is documented to "strip out any X-Forwarded-* headers from inbound http requests if connection is not trusted" (class Javadoc, line 37). However, the actual implementation omits several critical proxy headers from the strip set, most notably X-Forwarded-Host, which is then trusted and forwarded to origin services.

This creates a gap between the documented security posture and actual behavior when operators configure AllowWhen.NEVER (the recommended secure configuration per the Zuul wiki for deployments without a trusted ELB in front).

Affected Code

File: zuul-core/src/main/java/com/netflix/netty/common/proxyprotocol/StripUntrustedProxyHeadersHandler.java

Lines 50-55:

private static final Collection<AsciiString> HEADERS_TO_STRIP = Sets.newHashSet(
        new AsciiString("x-forwarded-for"),
        new AsciiString("x-forwarded-port"),
        new AsciiString("x-forwarded-proto"),
        new AsciiString("x-forwarded-proto-version"),
        new AsciiString("x-real-ip"));

Missing from strip set:

  • x-forwarded-host
  • x-forwarded-prefix
  • RFC 7239 forwarded

Impact

When StripUntrustedProxyHeadersHandler is configured with AllowWhen.NEVER (the recommended secure configuration per the Zuul wiki), operators reasonably expect that client-supplied X-Forwarded-* headers are removed. However, X-Forwarded-Host is not stripped.

Note: The framework default is AllowWhen.ALWAYS per CommonChannelConfigKeys.java, but the security-conscious configuration recommended by Netflix is NEVER for deployments without a trusted load balancer.

The method HttpRequestMessageImpl.getOriginalHost() (lines 505-515) then trusts X-Forwarded-Host with top priority over the actual Host header:

static String getOriginalHost(Headers headers, String serverName) throws URISyntaxException {
    String xForwardedHost = headers.getFirst(HttpHeaderNames.X_FORWARDED_HOST);
    if (xForwardedHost != null) {
        return xForwardedHost;  // client-controlled, returned immediately
    }
    // ... fallback to Host header
}

This value is then:

  1. Forwarded to origin services via ProxyUtils.addXForwardedHeaders() (with overwrite=false preserving the client value)
  2. Used in reconstructURI() (line 645) where it appears in access logs and response headers (for example, in the sample's X-Originating-URL)

Exploitation Scenario

Consider a deployment configured with AllowWhen.NEVER (following Netflix's security recommendation) to reject client proxy headers:

Attacker request:

GET /account/reset HTTP/1.1
Host: gateway.example.com
X-Forwarded-Host: evil.attacker.com

What happens:

  • The stripXFFHeaders() method removes X-Forwarded-For, -Port, -Proto, and -Real-IP
  • But X-Forwarded-Host: evil.attacker.com remains untouched
  • The getOriginalHost() method returns evil.attacker.com
  • The origin service receives: X-Forwarded-Host: evil.attacker.com

If the origin service builds absolute URLs from X-Forwarded-Host (password reset links, redirects) or uses it as a cache key, this enables:

  • Password reset link poisoning
  • Web cache poisoning (if cache is keyed on host)
  • Open redirect (if origin trusts the header for redirects)

Scope Note

This finding is about defense-in-depth and hardening. The actual impact depends on downstream origin behavior:

  • Zuul itself is not directly vulnerable (routing is VIP-based, not header-based)
  • Origin services that trust X-Forwarded-Host for URL construction or caching are at risk
  • Operators deploying Zuul with the NEVER policy reasonably expect full X-Forwarded-* protection based on the Javadoc

Recommended Fix

Option 1 (minimal change): Add the missing headers to HEADERS_TO_STRIP:

private static final Collection<AsciiString> HEADERS_TO_STRIP = Sets.newHashSet(
        new AsciiString("x-forwarded-for"),
        new AsciiString("x-forwarded-host"),        // ADD THIS
        new AsciiString("x-forwarded-prefix"),      // ADD THIS
        new AsciiString("x-forwarded-port"),
        new AsciiString("x-forwarded-proto"),
        new AsciiString("x-forwarded-proto-version"),
        new AsciiString("x-real-ip"),
        new AsciiString("forwarded"));              // ADD THIS (RFC 7239)

Option 2 (defense-in-depth): Make getOriginalHost() prefer the validated Host header when AllowWhen.NEVER is active, rather than trusting X-Forwarded-Host unconditionally.

Option 3 (documentation): If the current behavior is intentional, update the class Javadoc to clarify that X-Forwarded-Host and Forwarded are not stripped even in NEVER mode.

Verified Safe Areas (for context)

I reviewed the following areas and confirmed they are not vulnerable:

  • HTTP/1.1 request smuggling protections (Http1FramingEnforcingHandler) are solid
  • HTTP/2 per-stream content-length enforcement is working correctly
  • Multiple Host headers are properly rejected
  • Routing is VIP-based (no SSRF via Host or X-Forwarded-Host injection)

Steps to Reproduce

  1. Deploy Zuul with StripUntrustedProxyHeadersHandler(AllowWhen.NEVER)
  2. Send the following request:
    GET / HTTP/1.1
    Host: gateway.example.com
    X-Forwarded-Host: attacker.com
  3. Observe in origin logs or Zuul's X-Originating-URL response header that it shows http://attacker.com/ (the attacker-controlled host)

References

  • RFC 7230 § 5.4: Host header handling
  • RFC 7239: Forwarded HTTP Extension (standardized proxy headers)
  • OWASP: Web Cache Poisoning via Host Header Injection

Additional Context

This gap was discovered during a security audit of Zuul's proxy header handling as part of research into API gateway hardening patterns. The audit also confirmed that Zuul's HTTP/1.1 and HTTP/2 request smuggling protections are working correctly (see "Verified Safe Areas" above).

Disclosure

  • Discovered: 2026-07-03
  • Researcher: refleeexzz
  • Coordination: Reporting publicly via GitHub as this is a defense-in-depth gap with no immediate production impact to Netflix deployments

I'm happy to provide a PR implementing any of the recommended fixes if the maintainers would like. Thank you for maintaining Zuul and for considering this report!

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions