3 declare(strict_types=1);
5 namespace Laminas\Diactoros\ServerRequestFilter;
7 use Laminas\Diactoros\Exception\InvalidForwardedHeaderNameException;
8 use Laminas\Diactoros\Exception\InvalidProxyAddressException;
9 use Psr\Http\Message\ServerRequestInterface;
14 use function filter_var;
15 use function in_array;
16 use function is_string;
17 use function str_contains;
18 use function strtolower;
20 use const FILTER_FLAG_IPV4;
21 use const FILTER_FLAG_IPV6;
22 use const FILTER_VALIDATE_IP;
25 * Modify the URI to reflect the X-Forwarded-* headers.
27 * If the request comes from a trusted proxy, this filter will analyze the
28 * various X-Forwarded-* headers, if any, and if they are marked as trusted,
29 * in order to return a new request that composes a URI instance that reflects
34 final class FilterUsingXForwardedHeaders implements FilterServerRequestInterface
36 public const HEADER_HOST = 'X-FORWARDED-HOST';
37 public const HEADER_PORT = 'X-FORWARDED-PORT';
38 public const HEADER_PROTO = 'X-FORWARDED-PROTO';
40 private const X_FORWARDED_HEADERS = [
47 * Only allow construction via named constructors
49 * @param list<non-empty-string> $trustedProxies
50 * @param list<FilterUsingXForwardedHeaders::HEADER_*> $trustedHeaders
52 private function __construct(private array $trustedProxies = [], private array $trustedHeaders = [])
56 public function __invoke(ServerRequestInterface $request): ServerRequestInterface
58 $remoteAddress = $request->getServerParams()['REMOTE_ADDR'] ?? '';
60 if ('' === $remoteAddress || ! is_string($remoteAddress)) {
61 // Should we trigger a warning here?
65 if (! $this->isFromTrustedProxy($remoteAddress)) {
70 // Update the URI based on the trusted headers
71 $uri = $originalUri = $request->getUri();
72 foreach ($this->trustedHeaders as $headerName) {
73 $header = $request->getHeaderLine($headerName);
74 if ('' === $header || str_contains($header, ',')) {
75 // Reject empty headers and/or headers with multiple values
79 switch ($headerName) {
80 case self::HEADER_HOST:
81 $uri = $uri->withHost($header);
83 case self::HEADER_PORT:
84 $uri = $uri->withPort((int) $header);
86 case self::HEADER_PROTO:
87 $scheme = strtolower($header) === 'https' ? 'https' : 'http';
88 $uri = $uri->withScheme($scheme);
93 if ($uri !== $originalUri) {
94 return $request->withUri($uri);
101 * Indicate which proxies and which X-Forwarded headers to trust.
103 * @param list<non-empty-string> $proxyCIDRList Each element may
104 * be an IP address or a subnet specified using CIDR notation; both IPv4
105 * and IPv6 are supported. The special string "*" will be translated to
106 * two entries, "0.0.0.0/0" and "::/0". An empty list indicates no
107 * proxies are trusted.
108 * @param list<FilterUsingXForwardedHeaders::HEADER_*> $trustedHeaders If
109 * the list is empty, all X-Forwarded headers are trusted.
110 * @throws InvalidProxyAddressException
111 * @throws InvalidForwardedHeaderNameException
113 public static function trustProxies(
114 array $proxyCIDRList,
115 array $trustedHeaders = self::X_FORWARDED_HEADERS
117 $proxyCIDRList = self::normalizeProxiesList($proxyCIDRList);
118 self::validateTrustedHeaders($trustedHeaders);
120 return new self($proxyCIDRList, $trustedHeaders);
124 * Trust any X-FORWARDED-* headers from any address.
126 * This is functionally equivalent to calling `trustProxies(['*'])`.
128 * WARNING: Only do this if you know for certain that your application
129 * sits behind a trusted proxy that cannot be spoofed. This should only
130 * be the case if your server is not publicly addressable, and all requests
131 * are routed via a reverse proxy (e.g., a load balancer, a server such as
132 * Caddy, when using Traefik, etc.).
134 public static function trustAny(): self
136 return self::trustProxies(['*']);
140 * Trust X-Forwarded headers from reserved subnetworks.
142 * This is functionally equivalent to calling `trustProxies()` where the
143 * `$proxcyCIDRList` argument is a list with the following:
149 * - ::1/128 (IPv6 localhost)
150 * - fc00::/7 (IPv6 private networks)
151 * - fe80::/10 (IPv6 local-link addresses)
153 * @param list<FilterUsingXForwardedHeaders::HEADER_*> $trustedHeaders If
154 * the list is empty, all X-Forwarded headers are trusted.
155 * @throws InvalidForwardedHeaderNameException
157 public static function trustReservedSubnets(array $trustedHeaders = self::X_FORWARDED_HEADERS): self
159 return self::trustProxies([
164 '::1/128', // ipv6 localhost
165 'fc00::/7', // ipv6 private networks
166 'fe80::/10', // ipv6 local-link addresses
170 private function isFromTrustedProxy(string $remoteAddress): bool
172 foreach ($this->trustedProxies as $proxy) {
173 if (IPRange::matches($remoteAddress, $proxy)) {
181 /** @throws InvalidForwardedHeaderNameException */
182 private static function validateTrustedHeaders(array $headers): void
184 foreach ($headers as $header) {
185 if (! in_array($header, self::X_FORWARDED_HEADERS, true)) {
186 throw InvalidForwardedHeaderNameException::forHeader($header);
192 * @param list<non-empty-string> $proxyCIDRList
193 * @return list<non-empty-string>
194 * @throws InvalidProxyAddressException
196 private static function normalizeProxiesList(array $proxyCIDRList): array
198 $foundWildcard = false;
200 foreach ($proxyCIDRList as $index => $cidr) {
202 unset($proxyCIDRList[$index]);
203 $foundWildcard = true;
207 if (! self::validateProxyCIDR($cidr)) {
208 throw InvalidProxyAddressException::forAddress($cidr);
212 if ($foundWildcard) {
213 $proxyCIDRList[] = '0.0.0.0/0';
214 $proxyCIDRList[] = '::/0';
217 return $proxyCIDRList;
220 private static function validateProxyCIDR(mixed $cidr): bool
222 if (! is_string($cidr) || '' === $cidr) {
228 if (str_contains($cidr, '/')) {
229 $parts = explode('/', $cidr, 2);
230 assert(count($parts) >= 2);
231 [$address, $mask] = $parts;
235 if (str_contains($address, ':')) {
237 return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
248 return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)