Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Flexibility with PROXY protocol header on the same port? #2450

Closed
polarathene opened this issue Feb 13, 2024 · 8 comments
Closed

Flexibility with PROXY protocol header on the same port? #2450

polarathene opened this issue Feb 13, 2024 · 8 comments

Comments

@polarathene
Copy link

Some implementations like go-proxyproto have implemented a policy system for making it possible to accept connections without PROXY protocol, while only accepting the header from trusted hosts. This is what Traefik uses and Caddy is changing to in their 2.8 release.

While documenting PROXY protocol support on a project, one of the reviewers referenced the PROXY protocol specification with the MUST wording that forbids this approach:

The receiver MUST be configured to only receive the protocol described in this
specification and MUST not try to guess whether the protocol header is present
or not. This means that the protocol explicitly prevents port sharing between
public and private access. Otherwise it would open a major security breach by
allowing untrusted parties to spoof their connection addresses. The receiver
SHOULD ensure proper access filtering so that only trusted proxies are allowed
to use this protocol.

MUST not try to guess whether the protocol header is present or not. This means that the protocol explicitly prevents port sharing between public and private access.

While the SHOULD encourages minimizing risk by restricting what hosts are trusted.

It would seem that if you manage what hosts are trustworthy, that's what matters here when accepting PROXY headers, but those same hosts could just as well not provide the PROXY header and not have PROXY protocol apply?

Isn't the only concern really that the receiver must not use the PROXY header when the connection is from an untrusted host? Perhaps the specification should be revised to be more flexible on that point?


References:

  • go-proxyproto landed a policy feature roughly a year ago related to internal vs external traffic to the same port motivated by kubernetes deployments.
  • On the project I contributed docs to, this issue raised a debate about a need for duplicating ports of a service just for separate PROXY protocol handling for similar reasons. While Dovecot can configure trusted hosts, Postfix seems to lack the equivalent feature (when PROXY protocol is enabled for a service port, the remote IP is already the client IP as would be expected).
@TimWolla
Copy link
Member

It would seem that if you manage what hosts are trustworthy, that's what matters here when accepting PROXY headers, but those same hosts could just as well not provide the PROXY header and not have PROXY protocol apply?

That would be insecure, because if a client knows that the host won't add the PROXY header for a given connection, but also knows that the host is generally considered to be trustworthy, then that client could just send the PROXY header itself at the beginning of the connection.

@polarathene
Copy link
Author

polarathene commented Feb 13, 2024

then that client could just send the PROXY header itself at the beginning of the connection.

Sorry I don't follow?

With a reverse proxy like Caddy or Traefik which does allows this flexibility, how is this insecure?

Original response

Just so I don't misunderstand you, this is further clarification for me:

  • Client (A)
  • Reverse Proxy (B) Traefik
  • Service (C) Caddy

Connection flow: A => B => C

  • The client is untrusted, any attempt to send PROXY protocol header from them is discarded.
  • Reverse Proxy connects to service with PROXY protocol, and the service trusts the the reverse proxy.

Meanwhile:

  • A separate route through Traefik for a client request could connect to Caddy on the same port, but without providing PROXY protocol.
  • Caddy (since 2.8) having trusted traffic from Traefik will still accept the connection.
  • Traefik like Caddy must configure any trusted clients to accept PROXY protocol connections from, which Traefik would then forward to Caddy (use of PROXY protocol is only for TCP services which require TCP routers in Traefik, it is per-service however)
  • While Traefik cannot use PROXY protocol for HTTP services (which require HTTP routers), Caddy presently only manages HTTP traffic (Layer 7), not TCP (Layer 4), although there is a separate Caddy app (L4) that can provide TCP support (although slightly separate integration AFAIK). For Caddy this is handled in the servers config which is related to Layer 4.

Where is the security issue?

If you already trust a host to receive PROXY headers from, then what is the consequence of accepting traffic that is not presenting that header?

I think given that the receiving end disregards any PROXY header unless trusted, makes your concern invalid?

Perhaps there are other factors related to a deployment that cause the conflicting opinion on security here? 🤷‍♂️


I've provided an example below that's quite easy to run with docker compose installed, you only need to copy/paste the file and run two commands.

Please point out how this is insecure:

  • How a client can send a PROXY header to exploit the Caddy service configured below.
  • When Caddy only trusts Traefik with the PROXY protocol, but makes the PROXY protocol optional for connections to that port.

@polarathene
Copy link
Author

Here is a reproduction environment with both Traefik and Caddy involved. See the curl commands at the end for various scenarios tested.

# compose.yaml
# Usage:
# - Bring up services: docker compose up -d --force-recreate
# - Run curl tests: docker compose run --rm client

services:
  reverse-proxy:
    image: docker.io/traefik:3.0
    hostname: traefik.internal.test
    networks:
      default:
        ipv4_address: 172.16.42.10
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --providers.docker.network=dms-test-network
      # Traefik will redirect any incoming HTTP requests to HTTPS:
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entryPoint.to=websecure
      - --entrypoints.web.http.redirections.entryPoint.scheme=https
      # Equivalent to Caddy `servers.trusted_proxies` for accepting `X-Forwaded-*` headers:
      # https://doc.traefik.io/traefik/routing/entrypoints/#forwarded-headers
      # NOTE: This is technically always enabled for Layer 4 (TCP) router/services.
      # NOTE: Both entrypoints would need to be enabled if requests starts with HTTP/80,
      #       as either entrypoint would strip those headers otherwise.
      #- --entryPoints.web.forwardedHeaders.insecure=true
      #- --entryPoints.websecure.forwardedHeaders.insecure=true
      - --entryPoints.websecure.address=:443
    # CAUTION: Production usage should configure socket access better (see Traefik docs)
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    ports:
      - "80:80"
      - "443:443"

  web:
    # Can use this instead of build once Caddy 2.8 is released:
    #image: caddy:2.8
    hostname: caddy.internal.test
    networks:
      default:
        ipv4_address: 172.16.42.20
    # Caddy 2.8 makes PROXY protocol header optional on
    # the receiving port, just like Traefik.
    # Build master branch for unreleased Caddy 2.8:
    build:
     dockerfile_inline: |
       FROM caddy:2.7-builder AS builder
       RUN xcaddy build master

       FROM caddy:2.7
       COPY --from=builder /usr/bin/caddy /usr/bin/caddy
    labels:
      - traefik.enable=true

      # Only TCP services can enable PROXY Protocol:
      # TLS passthrough delegates TLS termination to Caddy instead:
      - traefik.tcp.routers.websecure.rule=HostSNI(`*`)
      - traefik.tcp.routers.websecure.tls.passthrough=true
      - traefik.tcp.routers.websecure.entrypoints=websecure
      - traefik.tcp.routers.websecure.service=with-proxy
      # Route to port 443 with PROXY protocol, Caddy will handle TLS:
      - traefik.tcp.services.with-proxy.loadbalancer.server.port=443
      - traefik.tcp.services.with-proxy.loadbalancer.proxyProtocol.version=2

      # Skips PROXY protocol connection for https://no-proxy-header.test
      - traefik.tcp.routers.no-proxy-header.rule=HostSNI(`no-proxy-header.test`)
      - traefik.tcp.routers.no-proxy-header.tls.passthrough=true
      - traefik.tcp.routers.no-proxy-header.entrypoints=websecure
      - traefik.tcp.routers.no-proxy-header.service=without-proxy
      # Route to port 443, Caddy will handle TLS:
      - traefik.tcp.services.without-proxy.loadbalancer.server.port=443

      # Skips PROXY protocol connection for https://https-router.test
      # Traefik will terminate TLS instead of delegating to Caddy (no passthrough).
      # Thus the Traefik cert is presented instead, and decrypted traffic is sent to Caddy.
      - traefik.http.routers.no-proxy-header.rule=Host(`traefik-https-router.test`)
      - traefik.http.routers.no-proxy-header.tls=true
      - traefik.http.routers.no-proxy-header.entrypoints=websecure
      - traefik.http.routers.no-proxy-header.service=without-proxy
      # Route to port 80, Traefik handled TLS:
      # NOTE: Caddy won't issue an HTTPS redirect for this host like it would others.
      - traefik.http.services.without-proxy.loadbalancer.server.port=80

    configs:
      - source: caddyfile
        target: /etc/caddy/Caddyfile

  # Only intended for running via `docker compose run --rm -it client`
  client:
    hostname: client.internal.test
    # An easy to recognize IP in the logs:
    networks:
      default:
        ipv4_address: 172.16.42.42
    # Prevent starting this service by default with `docker compose up`:
    profiles:
      - testing
    build:
      dockerfile_inline: |
        FROM alpine
        RUN apk add curl
    command: ash /tmp/run.sh
    configs:
      - source: curl-cmds
        target: /tmp/run.sh

networks:
  default:
    name: dms-test-network
    ipam:
      config:
        - subnet: "172.16.42.0/24"

configs:
  caddyfile:
    content: |
      # Global options:
      {
        local_certs

        # Enable ProxyProtocol for incoming Traefik connections:
        # https://caddyserver.com/docs/caddyfile/options#listener-wrappers
        servers {
          #log_credentials
          # Optionally trust HTTP headers from Traefik (regardless of TCP/HTTP router):
          trusted_proxies static 172.16.42.10/32

          listener_wrappers {
            # Must come before `tls`.
            proxy_protocol {
              # Only trust PROXY protocol connections from Traefik:
              allow 172.16.42.10/32
            }

            tls
          }
        }
      }

      # These docs are mostly applicable to HTTP routed traffic:
      # NOTE: TCP routers by Traefik do not understand HTTP traffic is handled,
      #       Thus any headers included (eg: X-Forwarded-For) are not discarded.
      # https://caddyserver.com/docs/caddyfile/options#client-ip-headers
      # > Pairing with trusted_proxies, allows configuring which headers to use to determine the client's IP address.
      # > By default, only X-Forwarded-For is considered.
      # https://caddyserver.com/docs/caddyfile/matchers#client-ip
      # > Only requests from trusted proxies will have their client IP parsed at the start of the request;
      # > untrusted requests will use the remote IP address of the immediate peer.
      # https://caddyserver.com/docs/caddyfile/matchers#remote-ip
      # > the first IP in the X-Forwarded-For request header, if present, will be preferred as the reference IP,
      # > rather than the immediate peer's IP, which is the default.

      (respond_with_details) {
        log

        # Requested Host     => The HTTP `Host` header
        # Remote Host IP     => The host connecting directly to Caddy (eg: Traefik)
        # Client IP          => The real client IP (requires PROXY protocol or direct connection)
        # ---
        # NOTE: Traefik only provides these headers for connections using HTTP router/services not TCP:
        # X-Forwarded-Host   => Requested host that Caddy will match to a site block
        # X-Forwarded-Server => Remote Host
        # X-Forwarded-For    => Client IP (Caddy will use this if `servers.trusted_proxies` is configured)
        # X-Real-Ip          => Client IP (Extra header from Traefik)

        respond <<EOF
          Caddy Received:
            Requested Host: {host}
            Remote Host IP: {remote_host}
            Client IP:      {client_ip}
            ---
            X-Forwarded-Host:   {header.X-Forwarded-Host}
            X-Forwarded-Server: {header.X-Forwarded-Server}
            X-Forwarded-For:    {header.X-Forwarded-For}
            X-Real-Ip:          {header.X-Real-Ip}
        EOF
      }

      example.test {
        import respond_with_details
      }

      no-proxy-header.test {
        import respond_with_details
      }

      http://traefik-https-router.test {
        import respond_with_details
      }

  curl-cmds:
    content: |
      #!/bin/sh

      # Curl options:
      # - `--insecure` (`-k`) to ignore local generated certs (Caddy/Traefik) fail to verify (CA not in trust store)
      # - `--location` (`-L`) to follow redirect for HTTP => HTTPS (used for direct Caddy example.test)
      # - `--header` (`-H`) add/modify request headers sent.
      # - `-w '\n'` for ensuring a final LF which is not part of the Caddy respond directive
      # - `--connect-to` routes traffic to the Traefik reverse proxy:
      #   https://curl.se/docs/manpage.html#--connect-to

      # TCP TLS router/service, with proxy header:
      # Both remote and client IP are for the Client (remote probably should remain as Traefik IP?):
      echo -e '\n- Client => Traefik (PROXY protocol) => Caddy (http://example.test)'
      curl -L -H "X-Forwarded-For: 1.2.3.4" --insecure --connect-to ::traefik.internal.test http://example.test -w '\n'

      # TCP TLS router/service, without proxy header:
      # Both remote and client IP are for Traefik (expected):
      # WARNING: With Caddy `servers.trusted_proxies` the curl client can add headers to fool Caddy here,
      #   as routing Layer 4 traffic in Traefik is not concerned with Layer 7 (HTTP).
      #   However when the PROXY protocol is used (like prior curl request), it has precedence for setting Client IP.
      echo -e '\n- Client => Traefik (no PROXY protocol) => Caddy (http://no-proxy-header.test)'
      curl -L -H 'X-Forwarded-For: 1.2.3.4' --insecure --connect-to ::traefik.internal.test http://no-proxy-header.test -w '\n'

      # HTTP TLS router/service:
      # Caddy `servers.trusted_proxies` must be enabled to resolve Client IP correctly here (expected), otherwise it will represent the Traefik IP.
      # As the request is via an HTTP router the curl header is ignored - unless allowing the Traefik entrypoint to forward these headers from clients.
      echo -e '\n- Client => Traefik (no PROXY protocol) => Caddy (http://traefik-https-router.test)'
      curl -L -H 'X-Forwarded-For: 1.2.3.4' --insecure --connect-to ::traefik.internal.test http://traefik-https-router.test -w '\n'

      echo -e '\n------\n'

      # All three of these curl requests respond with remote and client IP as that of the client (which is correct):
      echo -e '\n- Client => Caddy (http://example.test)'
      curl -L --insecure --connect-to ::caddy.internal.test http://example.test -w '\n'

      echo -e '\n- Client => Caddy (http://no-proxy-header.test)'
      curl -L --insecure --connect-to ::caddy.internal.test http://no-proxy-header.test -w '\n'

      # Internally to Caddy this endpoint doesn't offer HTTPS:
      echo -e '\n- Client => Caddy (http://traefik-https-router.test)'
      curl --connect-to ::caddy.internal.test http://traefik-https-router.test -w '\n'

Output from running the two commands at the start of that config file:

- Client => Traefik (PROXY protocol) => Caddy (http://example.test)
  Caddy Received:
    Requested Host: example.test
    Remote Host IP: 172.16.42.42
    Client IP:      172.16.42.42
    ---
    X-Forwarded-Host:
    X-Forwarded-Server:
    X-Forwarded-For:    1.2.3.4
    X-Real-Ip:

- Client => Traefik (no PROXY protocol) => Caddy (http://no-proxy-header.test)
  Caddy Received:
    Requested Host: no-proxy-header.test
    Remote Host IP: 172.16.42.10
    Client IP:      1.2.3.4
    ---
    X-Forwarded-Host:
    X-Forwarded-Server:
    X-Forwarded-For:    1.2.3.4
    X-Real-Ip:

- Client => Traefik (no PROXY protocol) => Caddy (http://traefik-https-router.test)
  Caddy Received:
    Requested Host: traefik-https-router.test
    Remote Host IP: 172.16.42.10
    Client IP:      172.16.42.42
    ---
    X-Forwarded-Host:   traefik-https-router.test
    X-Forwarded-Server: traefik.internal.test
    X-Forwarded-For:    172.16.42.42
    X-Real-Ip:          172.16.42.42

------


- Client => Caddy (http://example.test)
  Caddy Received:
    Requested Host: example.test
    Remote Host IP: 172.16.42.42
    Client IP:      172.16.42.42
    ---
    X-Forwarded-Host:
    X-Forwarded-Server:
    X-Forwarded-For:
    X-Real-Ip:

- Client => Caddy (http://no-proxy-header.test)
  Caddy Received:
    Requested Host: no-proxy-header.test
    Remote Host IP: 172.16.42.42
    Client IP:      172.16.42.42
    ---
    X-Forwarded-Host:
    X-Forwarded-Server:
    X-Forwarded-For:
    X-Real-Ip:

- Client => Caddy (http://traefik-https-router.test)
  Caddy Received:
    Requested Host: traefik-https-router.test
    Remote Host IP: 172.16.42.42
    Client IP:      172.16.42.42
    ---
    X-Forwarded-Host:
    X-Forwarded-Server:
    X-Forwarded-For:
    X-Real-Ip:

I think you are referring to Client => Traefik (no PROXY protocol) => Caddy (http://no-proxy-header.test)?

  • The client IP would otherwise be 172.16.42.10 (Traefik) which isn't anymore helpful at knowing the actual client.
  • The main caveat here is configuration with Traefik using a TCP router doesn't sanitize the HTTP headers. So without PROXY protocol it's vulnerable to forgery from the client.
  • But this is only relevant when you care about a correct client IP which may not matter for this route, while it may for others. If using a TCP router/service in Traefik, getting a trustworthy client IP requires PROXY protocol.

Caddy could just as easily not trust those headers via servers.trusted_proxies (unrelated to PROXY protocol trust). Although that would likewise break the no PROXY protocol HTTP router in Traefik that was working correctly and not subject to request header forgery in Client => Traefik (no PROXY protocol) => Caddy (http://traefik-https-router.test) (would then report Traefik IP as client IP).


So the question is:

  • Why does the specification have MUST on the receiver not being flexible with PROXY protocol header handling?
  • Why is the expectation of only trusted hosts for PROXY protocol a SHOULD?
    • If it's an optional feature for the proper security to trust, then doesn't that permit conditionally handling which hosts can connect without PROXY protocol?
    • Likewise, why should that be limited to the host IP vs allowing PROXY protocol connections from trusted hosts, but not mandating the connection uses PROXY protocol?

Given that:

  • For a reverse proxy like Caddy and Traefik above, either can be a receiver and have multiple services that they connect to.
  • As mentioned previously, kubernetes manages traffic slightly differently that routing to ingress controller is inefficient vs a trusted internal service having a direct connection to another service where client IP is not proxied, thus no PROXY protocol is needed.

Do you really need to mandate separate ports? Isn't that achieving the same as a trust policy handled on the same port?

@TimWolla
Copy link
Member

I'm afraid your follow-up question is fairly verbose and I'm not sure I follow your entire line of thought. I also don't fully understand Traefik's or Caddy's configuration, as I've used neither before.

I think you are referring to Client => Traefik (no PROXY protocol) => Caddy (http://no-proxy-header.test)?

I believe this is the case I'm referring to, yes: Traefik is not configured to send a PROXY header, while Caddy is configured to optionally (!) accept a PROXY header from Traefik.

Now consider the following (which might or might not actually work depending on Traefik's configuration):

printf "PROXY TCP4 192.168.0.1 192.168.0.11 56324 443\r\nGET / HTTP/1.1\r\nHost: no-proxy-header.test\r\n\r\n" |nc traefik.internal.test 80

If Traefik is acting as a dumb TCP forwarder without performing any protocol validation (i.e. checking whether protocol is valid HTTP), then it will pass the faked PROXY header from the client as-is to Caddy. Caddy will see the trusted IP address from Traefik and thus will trust the PROXY header, even if it was not generated by Traefik!

The main caveat here is configuration with Traefik using a TCP router doesn't sanitize the HTTP headers. So without PROXY protocol it's vulnerable to forgery from the client.

I believe this is what I mean by “dumb TCP forwarder”.

Why does the specification have MUST on the receiver not being flexible with PROXY protocol header handling?

Because it's the only reliable way to accurately determine whether PROXY TCP4 192.168.0.1 192.168.0.11 56324 443\r\n actually is a PROXY header or if it just happens to look like one and is actually part of the underlying protocol that is being forwarded.

Why is the expectation of only trusted hosts for PROXY protocol a SHOULD?

I did not write that part of the specification, but there is an important difference between the two requirements:

  • Optionally accepting a PROXY header would lead to the confusion I mentioned in the previous paragraph: Something could be interpreted as a PROXY header that is not intended to be one.
  • Not validating whether the client is authorized to add a PROXY header would allow IP spoofing, but would not break any protocols.

I think that the paragraph in the protocol specification could be phrased more clearly, but I believe the MUST + SHOULD is sound.

  • If it's an optional feature for the proper security to trust, then doesn't that permit conditionally handling which hosts can connect without PROXY protocol?

Conditionally handling which hosts can connect without PROXY procotol is fine, if the decision is made without looking into the contents. In other words: It must be clear at connection time whether the client is expected to send a PROXY header or not. It must not be decided by checking whether it looks like the client sent a PROXY header or not. If no PROXY header is provided, but the client is trusted to expected one, the connection MUST be rejected.

In fact HAProxy supports such a configuration with the tcp-request connection expect-proxy action.

Do you really need to mandate separate ports? Isn't that achieving the same as a trust policy handled on the same port?

A trust policy on the same port is fine, if it's a decision between “client MUST send a PROXY header” and “no special handling is performed”. Making the PROXY header optional is not fine. Note the important distinction between “no special handling” and ”optional”.


Hope that helps and is sufficiently clear.

@polarathene
Copy link
Author

I'm afraid your follow-up question is fairly verbose

Apologies. I lack the time to be terse 😬 (but I've tried to briefly summarize)

Hope that helps and is sufficiently clear.

It was quite helpful, thanks!

However I remain at the same stance on the specification being too strict with it's wording.

I am not convinced MUST is always applicable given my below feedback, rather that the wording is in itself a policy that is valid for the SHOULD clause regarding optionally trusting hosts (which is more important for security itself than the omission of the header).

I do have a solid grasp now on the security concern being discussed AFAIK 👍

  • Where we differ is Caddy would only prevent the vulnerability with a strict expectation of the PROXY header from Traefik as a side-effect.
  • Regarding a client spoofing the client IP via their own PROXY protocol header, this is only applicable if Traefik doesn't outright dismiss the untrusted PROXY header (the connection itself could still be accepted). Or as an implicit requirement of your advised change to Caddy, by always including a PROXY header for those connections to Caddy.
  • However as I demonstrated near the end of my below response, this is not possible when Traefik also routes at layer 7 instead of layer 4 (or rather it has separate configuration specific to HTTP/HTTPS which does not support PROXY protocol). It can still be secure as a trusted host despite this with Caddy being more flexible on the separate traffic routes handled by Traefik to Caddy at the same port if PROXY header were to be optional for this trusted host.

Response

The below response was iterative as I worked through your response. You can probably just jump to the end as I think I identified a miscommunication and we may have been looking at the concern from different parts of the connection flow and trust.

It can get a tad repetitive, no expectation to read and respond to everything here, I'm only being thorough without time to optimize 😅

Click to expand (Verbose)

Now consider the following (which might or might not actually work depending on Traefik's configuration):

printf "PROXY TCP4 192.168.0.1 192.168.0.11 56324 443\r\nGET / HTTP/1.1\r\nHost: no-proxy-header.test\r\n\r\n" |nc traefik.internal.test 80

And here I am thinking I had to write my own little TCP proxy program 😆 thanks for that example! 🥳

If Traefik is acting as a dumb TCP forwarder without performing any protocol validation (i.e. checking whether protocol is valid HTTP), then it will pass the faked PROXY header from the client as-is to Caddy.
Caddy will see the trusted IP address from Traefik and thus will trust the PROXY header, even if it was not generated by Traefik!

Correct 👍


The main caveat here is configuration with Traefik using a TCP router doesn't sanitize the HTTP headers. So without PROXY protocol it's vulnerable to forgery from the client.

I believe this is what I mean by “dumb TCP forwarder”.

This one is unrelated to PROXY protocol, Traefik is just routing layer 4 traffic from the client to the service. Normally knowing this is HTTP traffic you'd use the proper layer 7 router specifically for that and it'd handle this correctly.

The service (Caddy) only trusts those headers when I have another setting configured to trust whatever headers are coming from Traefik. Realistically nobody should be deploying like this when trust matters (although I wouldn't be surprised if some have unintentionally).

In the Client => Traefik => Caddy flow, when only routing traffic as layer 4 to Caddy:

  • The client can forge the headers and if Caddy trusts them from Traefik, yes that becomes the Client IP.
  • When the client sends their own PROXY protocol header, that becomes the client IP regardless of the headers.
  • If Traefik adds a PROXY header when connecting to Caddy, connection fails to parse the protocol when client also added their own PROXY header on the connection to Traefik.
  • For Caddy to enforce the PROXY header requirement, this only protects from the exploit without the client sending a PROXY header. Actual fix comes from Traefik adding it's own PROXY header to the connection for Caddy. Which is only an implicit requirement for Traefik to legitimately work when Caddy enforces the requirement, but Caddy could otherwise continue to support an optional PROXY header.
  • Rather the real issue Traefik allowing a PROXY header through from the untrusted client, just like Caddy trusting the X-Forwaded-For header from Traefik unaltered from the Client. Both exploits are from trusting connections from Traefik that it only forwarded.
  • More appropriate handling in a realistic deployment should avoid that by not blindly forwarding TCP traffic when there are better / safer configuration available. If it must route via layer 4 in a manner that this can be exploited, then Traefik should enforce using PROXY protocol for those connections to Caddy, Caddy supporting optional PROXY header remains valid.

  • Optionally accepting a PROXY header would lead to the confusion I mentioned in the previous paragraph: Something could be interpreted as a PROXY header that is not intended to be one.

Just to confirm, when you're expecting a specific protocol(s) it shouldn't be an issue right?

For example I have noticed that if HTTP or TLS is being listened for, these fail if the client has provided an unexpected PROXY header. Whereas if you're optionally expecting PROXY header, or mandating one that should be processed then handling of HTTP or TLS which will go smoothly.

When the malicious client provided their own PROXY header, and Traefik TCP forwarding additionally added it's own to provide Caddy, this resulted in two PROXY headers, so Caddy would fail from the malicious attempts to spoof the client IP.

You'd know this better than I would though, a scenario where that's a valid concern, where a connection could initiate with data that would be mistaken as a PROXY header, but AFAIK if the receiver is handling a protocol where that would be invalid it's not going to be a problem? 🤷‍♂️ (so one would assume this context is more apparent as a conflict concern and prefer a REQUIRE policy or not have the receiver support PROXY protocol for that kind of traffic to avoid the ambiguity)


Not validating whether the client is authorized to add a PROXY header would allow IP spoofing, but would not break any protocols.

If the host is unconditionally trusted with it's connections to the receiver, then that host needs to ensure it does not allow clients to inject PROXY protocol header when connecting to it (assuming the TCP traffic routed would never be the kind to include legitimate data at the start of a connection that mirrors the header). Then there is no potential for spoofing?

I think that the paragraph in the protocol specification could be phrased more clearly, but I believe the MUST + SHOULD is sound.

You don't think it's too restrictive? If you follow SHOULD, why does the receiver need to enforce only accepting PROXY protocol traffic on that port for untrusted host connections where the PROXY header would be rejected/ignored?

That just requires a workaround via a separate port for the same purpose which is quite inconvenient. We already discuss reverse proxies which likewise allow routing connections via various rules but primarily supports a single port listener externally.

Going further, provided the trusted host really can be trusted completely... it should be able to optionally provide the PROXY header. It's already trusted at this point, so the lack of it providing a header to the receiver shouldn't matter. Your concern is with the "dumb TCP forwarder" that is completely unaware of the PROXY protocol in traffic, which would make that pointless to consider as a trusted host?

The receiver (Caddy):

  • Would not avoid the malicious traffic by enforcing only traffic with the PROXY header when the client is able to provide that header in the first place. The SHOULD can prevent this when the host actually is trustworthy with it's connections, but it's not a MUST, despite this is where the actual security comes from... a policy.
  • Should not require the PROXY header to be present from a trusted host if it's not necessary. In go-proxyproto a separate SKIP policy can be used within a proper policy function, such as the one that skips for internal trusted traffic by CIDR while an external load balancer must be trusted and PROXY header is expected. All within the same port. There is no reason to need a separate port to workaround that?
  • May have a need for traffic routing to the same port, but for different purposes where client IP is relevant. When a trusted host ensures only it provides the PROXY header when it is relevant, that should be acceptable. It is the trusted hosts responsibility to maintain that trust to the receiver, by ensuring it likewise controls the trust for connections it receives in the same manner, not blindly routing TCP connections that can be abused in this context.

Conditionally handling which hosts can connect without PROXY procotol is fine, if the decision is made without looking into the contents. In other words: It must be clear at connection time whether the client is expected to send a PROXY header or not.

Basically what I covered already above.

This reads to me as it's ok to conditionally handle via trusted IPs. Yet not ok for a trusted IP to optionally omit the PROXY header when relevant (proxy to proxy).

# Caddy handles TLS termination and responds appropriate based on request
# Traefik prevents PROXY protocol header from client by ignoring and discarding it if present
# Traefik adds PROXY protocol only for one request where the real client IP is important to Caddy
https://with-proxy-protocol.com =443> Traefik (adds PROXY protocol) =443> Caddy
https://without-proxy-protocol.com =443> Traefik (prevents PROXY protocol) =443> Caddy

# Third scenario, direct connection to Caddy from internal service that doesn't speak PROXY protocol
# It is considered trustworthy and as a direct connection, it's IP is already what it's expected to be.
# PROXY protocol is optional for the same port by an IP policy enforcing the requirement on trusted hosts
# Internal IP cannot be added as a trusted IP, not that it needs to be.
https://with-proxy-protocol.com =443> Caddy

In the above Caddy listens on port 443 for HTTPS traffic.

  • It manages the two sites, but PROXY protocol is not always relevant or available depending on the context.
  • with-proxy-protocol.com is a "dumb TCP forwarder", it is configured explicitly for PROXY protocol because it's needed.
  • without-proxy-protocol.com is handled differently in Traefik. Instead of routing Layer 4, it's Layer 7 specifically for HTTPS with some additional rules.
    • PROXY protocol isn't supported by Traefik for this, but the X-Forwarded-* headers are if we needed the Client IP (which Traefik will prevent malicious behaviour for here, but not so great for Caddy with the "dumb TCP forwarder" in play). Thankfully in this scenario the client IP isn't important.
    • However since it does not add PROXY header, we have a problem. Same trusted host IP + same port.
    • Either need to have a separate port just for this distinction or complicate the routing / processing in other ways to workaround the spec restrictions.
    • Alternatively allowing a trust policy that permits for no PROXY header to be provided to Caddy would work. Misconfiguration concerns aren't really relevant here, since trust itself can be misconfigured at either end (as established by Traefik's current entrypoint docs for PROXY protocol support not indicating the risk of malicious client injecting a PROXY header by default is allowed for a "dumb TCP forwarder" route).

It must not be decided by checking whether it looks like the client sent a PROXY header or not.
If no PROXY header is provided, but the client is trusted to expected one, the connection MUST be rejected.

Right... that's a policy though?

  • When those are the expectations, it's really no different to the SHOULD requirement for trusted hosts.
  • You SHOULD enforce a policy that secures the trust, but context matters for a trust policy, as I've hopefully conveyed above.

So perhaps the MUST from the referenced part of the spec should be worded as part of a SHOULD policy. But not forbid more flexible policies.

  • As you've already covered in your response by relaxing the specification expectation that a port may not share public and private access, by saying it's ok to not expect / handle PROXY protocol for connections from untrusted hosts, but still allow them to connect in that untrusted context.
  • All I'm seeking to clarify is why the boundary MUST be limited there when a policy could still ensure trust in a host for a receiving port that like untrusted hosts could optionally have the PROXY header.
    -I think the misunderstanding here is assuming a 1:1 relationship between the trusted host connection to the receiving port always being for the same function/endpoint, rather than additional context for that connection as conveyed in the above example.

Do you really need to mandate separate ports? Isn't that achieving the same as a trust policy handled on the same port?

A trust policy on the same port is fine, if it's a decision between “client MUST send a PROXY header” and “no special handling is performed”.

I'm curious if there is a misunderstanding between us here too..?:

  • I am considering Client => Traefik and Traefik => Caddy as both clients/hosts, where only Caddy trusts Traefik and Traefik does not trust the client (but as I've discovered needs to explicitly configure no trust to enforce the IGNORE policy, else a "dumb TCP forwarder" route can be exploited).
  • My focus is generally on Caddy as the receiver and Traefik as the trusted host connecting to Caddy. Whereas you may still be focused on the client connecting through to Traefik? Or the lack of familiarity with Traefik and Caddy, some of the jargon I'm using specific to them may get in the way (I'd have similar issues with HAProxy as I've not used that myself).

Making the PROXY header optional is not fine. Note the important distinction between “no special handling” and ”optional”.

Given the context of Traefik being configured to disregard any PROXY header from a client connection, but otherwise continue the connection, we're good here?

Your concern is primarily with Traefik blindly routing TCP connections where the client could add the PROXY header and the receiver Caddy would blindly trust that from Traefik.

With that out of the way, the trust policy may be flexible as it's only concerned about WHEN to trust a PROXY header, but otherwise allow connections through without it when that is acceptable instead of rejecting them outright (which is what the spec currently demands with it's wording and you see in software like Postfix and Dovecot which requires separate ports as a result).

If a host is trusted for connections with PROXY protocol, that is all that matters and it comes down to the policy which we can now agree is fine to be flexible.

  • The concern you emphasize is trusting a host that operates in a way that it cannot be trusted (because it allowed an untrusted third-party to sneak in a PROXY protocol header).
  • Your concern has nothing to do with Caddy as a receiver using a trust policy, but rather when Traefik has none in place as a receiver to the connecting untrusted client, and a configuration makes that exploitable.
# Accepting a connection from untrusted third-party (without inspecting for PROXY header to apply a policy action),
# TCP forwarded traffic to outbound connection where a PROXY header is not added, allows malicious client to spoof,
# So long as it's only forwarded, no attempt to terminate TLS
Inbound (provides PROXY header) => Receiver (Traefik) => Outbound (may add PROXY header)

# Traefik is trusted, malicious client successfully spoofs client IP to exploit trust for privilege escalation
# Optional policy here is not relevant, it's that Traefik does not consistently add PROXY header for all connections
# to Caddy at this port, which would foil the malicious clients attempt due to extra header breaking
# receiver protocol handling after processing the first PROXY header.
Inbound (may have PROXY header) => Receiver (Caddy)

Otherwise, I really am confused on the concern with the PROXY header being optional for Caddy and how enforcing the expectation of the PROXY header on Caddy's end resolves anything with connections from Traefik as a trusted host. It only makes sense to me that you've instead been focused on changes to Traefik, where the correct resolution is to not trust any clients connecting with PROXY header by default.

@francislavoie
Copy link

I'm only being thorough without time to optimize 😅

“I apologize for such a long letter - I didn't have time to write a short one.”
Mark Twain

@TimWolla
Copy link
Member

  • However as I demonstrated near the end of my below response, this is not possible when Traefik also routes at layer 7 instead of layer 4

Specific set-ups might not necessarily be affected, because they perform validation. But a protocol specification cannot deal with specific set-ups, but must deal with the general case. PROXY protocol is mostly useful for dumb TCP forwarders, as when you're dealing with HTTP you have the much more powerful x-forwarded-for header at your disposal. It allows you to send requests for different clients across the same keep-alive backend connection.

And those dumb TCP forwarders which is the prime use case for PROXY protocol are exactly those that are affected by optionally accepting a PROXY header, as I've outlined before.

For your lengthy further reply, I'm cherry-picking specific points I consider important to clarify.

  • Actual fix comes from Traefik adding it's own PROXY header to the connection for Caddy.

Yes. And if Traefik is adding its own PROXY header all the time, Caddy could just strictly require the existence of the PROXY header for Traefik's IP address, instead of trying to guess, thus making a misconfiguration much more evident.

Just to confirm, when you're expecting a specific protocol(s) it shouldn't be an issue right?

Yesn't. PROXYv2 was specifically designed not to be confused with existing well-known protocols and thus trigger safe behavior. But that doesn't mean that there is no protocol where a PROXY header wouldn't be valid application data.

You'd know this better than I would though, a scenario where that's a valid concern, where a connection could initiate with data that would be mistaken as a PROXY header

A very simple example would be a headerless and encrypted protocol using a pre-shared key. Such a protocol would be indistinguishable from random noise. A packet might encrypt to a valid PROXY header just by chance. I believe Wireguard would be such a protocol, but don't quote me on that.

Another example would be a protocol consisting of an TLV encoding.

then that host needs to ensure it does not allow clients to inject PROXY protocol header when connecting to it

No, the gateway needs to ensure that it adds its own PROXY header, if the backend would accept the PROXY header from it. And if the backend accepts it, it might as well require it (as the protocol specification says).

Your concern is with the "dumb TCP forwarder" that is completely unaware of the PROXY protocol in traffic, which would make that pointless to consider as a trusted host?

A dumb TCP forwarder is be a trusted host if it reliably adds a PROXY header by itself. And again, if it does so, the backend can require the PROXY header to be present.

This reads to me as it's ok to conditionally handle via trusted IPs. Yet not ok for a trusted IP to optionally omit the PROXY header when relevant (proxy to proxy).

You read that correctly.

Given the context of Traefik being configured to disregard any PROXY header from a client connection, but otherwise continue the connection, we're good here?

Dropping bytes that the client sent breaks protocols and could introduce security vulnerabilities, because the downstream backend might interpret the clients data differently, e.g. because a relevant TLV field is dropped.

@polarathene
Copy link
Author

polarathene commented Feb 16, 2024

I've collapsed my original response as I don't want to take up your time any further 😅

Conclusion:

  • Security concerns have been addressed.
  • Specification wording in question has been clarified:
    • A receiver is more than just the service + port, but also the connecting host IP.
    • Thus a single port may conditionally accept traffic for PROXY protocol, no need for separate ports.
    • The spec just enforces the PROXY header for trusted hosts, while untrusted hosts should only be those with direct connections (thus no need for PROXY protocol).
  • While I agree care should be taken to enforce the PROXY protocol where possible, we have established it can still be secure to relax the trust policy (per the specification, not that it permits relaxing it) to support other uses-cases where the context for connections are unaffected by the security concerns raised.

I'd still like to see the specification not enforce restrictions when they're not necessary, but with the spec clarification for a receiver, I can't think of any convincing real world scenarios right now 👍

I'll close the issue as I consider this resolved. Thanks for taking the time to clear everything up :)


Full response
  • Actual fix comes from Traefik adding it's own PROXY header to the connection for Caddy.

Yes. And if Traefik is adding its own PROXY header all the time, Caddy could just strictly require the existence of the PROXY header for Traefik's IP address, instead of trying to guess, thus making a misconfiguration much more evident.

Understood, I was just establishing that the REQUIRE policy is only an implicit fix due to that side-effect of raising awareness of a potential misconfiguration, not the fix itself but rather complimentary to satisfy when possible.


Random data mistaken as PROXY header

Redundant

But that doesn't mean that there is no protocol where a PROXY header wouldn't be valid application data.

I am only familiar with it being relevant when initiating a TCP connection, so I'd think that is not a concern for an established protocol?

I'm sure there is probably scenarios where this sort of thing could happen by accident, but I'm only familiar with the identified scenario of blindly forwarding TCP traffic.

A packet might encrypt to a valid PROXY header just by chance.

Fair, but in that case you could still attribute this context as part of a trust policy?

In the sense that because of that potential mishap out of your own control, you must have strict enforcement of adding a PROXY header from the trusted host and the receiver enforcing REQUIRE for those connections.

Although, if the specification allowed for signing the PROXY protocol header, any compatible receiver would not run into this problem if verifying the signed bytes. Random data is not going to be mistaken then (the chance of that is ridiculous).


Context matters - Flexibility can still be secure

then that host needs to ensure it does not allow clients to inject PROXY protocol header when connecting to it

No, the gateway needs to ensure that it adds its own PROXY header, if the backend would accept the PROXY header from it. And if the backend accepts it, it might as well require it (as the protocol specification says).

Redundant

The specification presently forbids a backend service port from only enforcing the PROXY header on trusted hosts. Preventing other (private) hosts from connecting where PROXY protocol is unnecessary. (EDIT: Corrected in a later section, but some known implementations do forbid this flexibility)

"Untrusted" hosts within a trusted internal network which do not need to connect via the external gateway can make direct connections to the service, but the specification doesn't permit the port to allow these connections to be exempt from providing the PROXY header.

  • Dovecot must configure trusted hosts, and only these hosts will be allowed to connect on that port, and they must provide a PROXY header otherwise they're rejected.
  • Postfix has no trusted host support, it only enforces the PROXY header requirement.
  • Supporting these internal trustworthy hosts presently requires a separate port for software like Postfix/Dovecot.
    • Though the spec describes forbidding a port from public and private access, it seems to have been interpreted by some software like Dovecot incorrectly when they implement the SHOULD clause via an allow list for where the PROXY header is required (due to the MUST). Whereas if the port is publicly exposed, an untrusted external host connecting is bad, but trusting an internal host or connections within the same host as Dovecot, mandates PROXY protocol is used...adding friction.
    • I can see how the spec is misinterpreted but it seems common enough (the concern was raised to me during a contribution review for example where the reviewer cited the spec).

While "might as well require it" does make sense and I tend to agree, there may be software like the Traefik example where:

  • A configuration can only provide the PROXY header for some connections (TCP vs HTTP routers in Traefik).
  • However with the context of protocols used (and if necessary configuration that prevents malicous inbound connections sneaking in a PROXY header) that can actually be handled securely?
  • Yet the specification forbids it.

Receiver service + sender trust management

Mostly touching on some common concerns and clarifying the scope of trust for a receiver, which the wording in the current spec may be misinterpreted or affect implementation decisions, adding friction without a more flexible trust policy.

Related: Response on the definition of a receiver and it's trust as per spec

EDIT: I was too focused on the public vs private here to even think about foreign connections being direct preserving the client IP if no proxy / hops are in between. Disregard that.

Your concern is with the "dumb TCP forwarder" that is completely unaware of the PROXY protocol in traffic, which would make that pointless to consider as a trusted host?

A dumb TCP forwarder is be a trusted host if it reliably adds a PROXY header by itself. And again, if it does so, the backend can require the PROXY header to be present.

This reads to me as it's ok to conditionally handle via trusted IPs. Yet not ok for a trusted IP to optionally omit the PROXY header when relevant (proxy to proxy).

You read that correctly.

👍

From the PROXY protocol spec:

  • The receiver MUST be configured to only receive the protocol described in this specification and MUST not try to guess whether the protocol header is present or not.
  • This means that the protocol explicitly prevents port sharing between public and private access. Otherwise it would open a major security breach by allowing untrusted parties to spoof their connection addresses.
  • The receiver SHOULD ensure proper access filtering so that only trusted proxies are allowed to use this protocol

Ok so a receiver is not just the service + port, but additionally bounded by the IP of the sender (host connecting directly to the service):

  • Trusted IP => Must provide PROXY protocol header (aka REQUIRE policy).
  • Untrusted IP => Should not provide a PROXY header, can then connect normally.
    • If foreign connections could connect (no gateway proxy) with the PROXY header, this is forbidden by the spec for the receiving service port (public access). This is implicit when the service supports configuring a list of trusted hosts.
    • Internal hosts (or services within the same host as the receiving service) could connect without the PROXY header (private access), but that would potentially require separate trust management for this distinction (if foreign connections without the PROXY header could also reach the receiving service).
    • Since the receiving service cannot know which connections to allow without the PROXY header; when preserving the Client IP is important it must manage a separate trust list (eg: a deny list could trust only a given subnet !172.16.0.0/12). Thus the default is to reject any connections that are not in the trusted hosts.
    • If a host like in the deny list example were to also be part of the trusted hosts for the PROXY header, care must be taken to strictly require connections with PROXY header, and not accidentally permit the receiver to also trust connections from that trusted host missing the PROXY header.

If trusted hosts were policy based beyond REQUIRE for a trusted IP, one could trust the PROXY header as optional - provided they ensure that trusted hosts enforce adding the PROXY protocol header for connections to the receiver. This would simplify trust management for the receiver (when relaxing it's trusted hosts policy is an acceptable risk).


Security via context / policy

The specification concern is that there are scenarios where more flexibility via a trust policy and other context presents no security issues, however the specification enforces expectations rather than deferring to policy.

It appears that it is more appropriate as best practice advice to avoid any unexpected surprises?

  • Should the spec only enforce requirements when not respecting them is guaranteed to be insecure/vulnerable regardless of context?
  • As has been discussed, PROXY protocol can be used with a more flexible policy, and still avoid any of the concerns raised. It would just be unwise to do so without being aware of the risks that are context dependent.
Related: Response to dropping bytes / TLV field

Given the context of Traefik being configured to disregard any PROXY header from a client connection, but otherwise continue the connection, we're good here?

Dropping bytes that the client sent breaks protocols and could introduce security vulnerabilities, because the downstream backend might interpret the clients data differently, e.g. because a relevant TLV field is dropped.

This still seems to be heavily context dependent? I can see why it is safer to enforce requiring PROXY protocol headers from a trusted host, but I don't see why the specification needs insist it be mandatory?

Dropping bytes:

  • Traefik does not trust any connecting client, it decides to apply the IGNORE policy to discard any unwanted PROXY protocol header at the start of a connection.
  • The connections accepted / routed are for specific protocols where a PROXY protocol header should not be present when initiating a connection from the untrusted client.
  • Connection proceeds to be routed to a backend service.

I am not that familiar with TLV fields, but if they're part of the PROXY protocol header that is discarded from untrusted hosts, how does this introduce a security vulnerability or affect the backend? Why would a relevant TLV field exist in data we don't trust or expect?

I can understand from the perspective that under a different context than I described, this could be a valid concern. I just don't see how it applies to the PROXY protocol specification for everyone. Rather, more of a best practice that you SHOULD enforce PROXY protocol for a receiver accepting connections from a trusted host, but if you cannot enforce it but still ensure it is otherwise secure, that would be ok.

Take the advice for modulus keylength, some already advise >2048 bits or forecast to advise that going forward. 112-bits of symmetric security is quite a lot, the computation cost to attack that for a target like myself would be too expensive already. Likewise with entropy for passwords. I know what a safe lower bound is there, and I've seen others insisting 128-bit symmetric is not enough, wanting to use 4096-bit RSA (sometimes thinking it's only twice the strength of a RSA 2048-bit key).

This is relevant to policy decisions, which is where I think the PROXY protocol specification could treat it as such.

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

No branches or pull requests

3 participants