Behind a Proxy

Why the client IP and the URL scheme are wrong behind a load balancer, and the proxy and host settings that put them right.

Syntaxframework.trusted_proxies: '%env(TRUSTED_PROXIES)%'

A reverse proxy is a server that receives the request first and passes it on to your application: a load balancer, an ingress controller, a CDN, or Nginx in front of PHP-FPM. From the application's point of view the connection comes from the proxy. So $request->getClientIp() returns the proxy's address, and if the proxy terminated TLS and spoke plain HTTP to you, $request->isSecure() is false and generated absolute URLs begin with http://. Redirects then drop users out of HTTPS, and rate limits, audit logs and geolocation all record one address for everybody.

The headers, and why they are ignored by default

Proxies pass the original values along in headers: X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port, X-Forwarded-Prefix, or the single Forwarded header. Any client can send those headers too. Symfony therefore ignores them until you name the proxies you trust in framework.trusted_proxies and list the headers to honour in framework.trusted_headers. Trust is decided by source address, which is why the configuration is addresses and CIDR ranges rather than names.

Hosts

framework.trusted_hosts is the companion setting, and it defaults to an empty list, meaning every host is accepted. The Host header is client-supplied and it feeds generated URLs and absolute redirects. With a list configured, a request for an unlisted host is rejected with a 400 response instead of producing links that point at somebody else's domain.

Example

Example · php
// src/Controller/DiagnosticsController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class DiagnosticsController extends AbstractController
{
    // The attribute class is Symfony\Component\Routing\Attribute\Route.
    // There is no docblock form of this; attributes are the only syntax.
    #[Route('/_diagnostics/request', name: 'app_diagnostics_request', methods: ['GET'])]
    public function __invoke(Request $request): JsonResponse
    {
        // Every one of these reads the X-Forwarded-* headers only when the
        // address the request came from is a trusted proxy. Otherwise they
        // describe the connection to the proxy, not the one to the user.
        return $this->json([
            'client_ip' => $request->getClientIp(),
            'scheme' => $request->getScheme(),
            'secure' => $request->isSecure(),
            'host' => $request->getHost(),
            'uri' => $request->getUri(),
        ]);
    }
}

When to use it

  • Get real client IP addresses into logs and rate limits behind a load balancer.
  • Stop generated URLs and redirects downgrading users from https to http.
  • Reject requests carrying a forged Host header before they reach a controller.
  • Configure a Symfony service running behind an ingress controller whose pod addresses change.

More examples

Trusting the proxy by address

The usual production shape. The addresses belong to the infrastructure, not to the code, so they come from an environment variable and differ between staging and production. Name only the headers your proxy actually sets: honouring x-forwarded-host when nothing sets it is one more input an attacker can reach. If your proxy uses the RFC 7239 Forwarded header instead, the list is trusted_headers: ['forwarded'].

Example · yaml
# config/packages/framework.yaml
framework:
    trusted_proxies: '%env(TRUSTED_PROXIES)%'
    trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port', 'x-forwarded-prefix']

# .env
# TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8

The two shorthands

Reach for private_ranges when the proxy sits inside a private network whose addresses you do not control, which is the normal case on Kubernetes where pod IPs change. REMOTE_ADDR goes further: it trusts the immediate peer whatever its address, so it is only safe when nothing but your proxy can open a connection to the application port. Both are expanded by the framework at runtime, so they behave exactly as the longhand list would.

Example · yaml
# config/packages/framework.yaml
framework:
    # shorthand: replaced at runtime by IpUtils::PRIVATE_SUBNETS,
    # the same thing as writing out 10.0.0.0/8, 172.16.0.0/12,
    # 192.168.0.0/16, 127.0.0.0/8 and the IPv6 equivalents
    trusted_proxies: 'private_ranges'

    # shorthand: replaced at runtime by $_SERVER['REMOTE_ADDR'],
    # which means "trust whatever is immediately in front of me"
    # trusted_proxies: 'REMOTE_ADDR'

Rejecting unknown hosts

Set this once you know the domains the application answers on. Anything else gets a 400 response. It closes a specific problem: a request with a forged Host header can otherwise poison generated URLs, which is how a password reset email ends up linking to an attacker's domain. A health probe that connects by IP address needs the IP listed here too, or it will start failing the moment you add this.

Example · yaml
# config/packages/framework.yaml
framework:
    trusted_hosts:
        - 'example.com'
        - 'www.example.com'
        - 'api.example.com'

Discussion

  • Be the first to comment on this lesson.