fbd650901c57ba4a6f7b64273a9a3260189ec442
[GitHub/WoltLab/WCF.git] /
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Laminas\Diactoros\ServerRequestFilter;
6
7 use Laminas\Diactoros\Exception\InvalidForwardedHeaderNameException;
8 use Laminas\Diactoros\Exception\InvalidProxyAddressException;
9 use Psr\Http\Message\ServerRequestInterface;
10
11 use function assert;
12 use function count;
13 use function explode;
14 use function filter_var;
15 use function in_array;
16 use function is_string;
17 use function str_contains;
18 use function strtolower;
19
20 use const FILTER_FLAG_IPV4;
21 use const FILTER_FLAG_IPV6;
22 use const FILTER_VALIDATE_IP;
23
24 /**
25 * Modify the URI to reflect the X-Forwarded-* headers.
26 *
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
30 * those headers.
31 *
32 * @psalm-immutable
33 */
34 final class FilterUsingXForwardedHeaders implements FilterServerRequestInterface
35 {
36 public const HEADER_HOST = 'X-FORWARDED-HOST';
37 public const HEADER_PORT = 'X-FORWARDED-PORT';
38 public const HEADER_PROTO = 'X-FORWARDED-PROTO';
39
40 private const X_FORWARDED_HEADERS = [
41 self::HEADER_HOST,
42 self::HEADER_PORT,
43 self::HEADER_PROTO,
44 ];
45
46 /**
47 * Only allow construction via named constructors
48 *
49 * @param list<non-empty-string> $trustedProxies
50 * @param list<FilterUsingXForwardedHeaders::HEADER_*> $trustedHeaders
51 */
52 private function __construct(private array $trustedProxies = [], private array $trustedHeaders = [])
53 {
54 }
55
56 public function __invoke(ServerRequestInterface $request): ServerRequestInterface
57 {
58 $remoteAddress = $request->getServerParams()['REMOTE_ADDR'] ?? '';
59
60 if ('' === $remoteAddress || ! is_string($remoteAddress)) {
61 // Should we trigger a warning here?
62 return $request;
63 }
64
65 if (! $this->isFromTrustedProxy($remoteAddress)) {
66 // Do nothing
67 return $request;
68 }
69
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
76 continue;
77 }
78
79 switch ($headerName) {
80 case self::HEADER_HOST:
81 $uri = $uri->withHost($header);
82 break;
83 case self::HEADER_PORT:
84 $uri = $uri->withPort((int) $header);
85 break;
86 case self::HEADER_PROTO:
87 $scheme = strtolower($header) === 'https' ? 'https' : 'http';
88 $uri = $uri->withScheme($scheme);
89 break;
90 }
91 }
92
93 if ($uri !== $originalUri) {
94 return $request->withUri($uri);
95 }
96
97 return $request;
98 }
99
100 /**
101 * Indicate which proxies and which X-Forwarded headers to trust.
102 *
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
112 */
113 public static function trustProxies(
114 array $proxyCIDRList,
115 array $trustedHeaders = self::X_FORWARDED_HEADERS
116 ): self {
117 $proxyCIDRList = self::normalizeProxiesList($proxyCIDRList);
118 self::validateTrustedHeaders($trustedHeaders);
119
120 return new self($proxyCIDRList, $trustedHeaders);
121 }
122
123 /**
124 * Trust any X-FORWARDED-* headers from any address.
125 *
126 * This is functionally equivalent to calling `trustProxies(['*'])`.
127 *
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.).
133 */
134 public static function trustAny(): self
135 {
136 return self::trustProxies(['*']);
137 }
138
139 /**
140 * Trust X-Forwarded headers from reserved subnetworks.
141 *
142 * This is functionally equivalent to calling `trustProxies()` where the
143 * `$proxcyCIDRList` argument is a list with the following:
144 *
145 * - 10.0.0.0/8
146 * - 127.0.0.0/8
147 * - 172.16.0.0/12
148 * - 192.168.0.0/16
149 * - ::1/128 (IPv6 localhost)
150 * - fc00::/7 (IPv6 private networks)
151 * - fe80::/10 (IPv6 local-link addresses)
152 *
153 * @param list<FilterUsingXForwardedHeaders::HEADER_*> $trustedHeaders If
154 * the list is empty, all X-Forwarded headers are trusted.
155 * @throws InvalidForwardedHeaderNameException
156 */
157 public static function trustReservedSubnets(array $trustedHeaders = self::X_FORWARDED_HEADERS): self
158 {
159 return self::trustProxies([
160 '10.0.0.0/8',
161 '127.0.0.0/8',
162 '172.16.0.0/12',
163 '192.168.0.0/16',
164 '::1/128', // ipv6 localhost
165 'fc00::/7', // ipv6 private networks
166 'fe80::/10', // ipv6 local-link addresses
167 ], $trustedHeaders);
168 }
169
170 private function isFromTrustedProxy(string $remoteAddress): bool
171 {
172 foreach ($this->trustedProxies as $proxy) {
173 if (IPRange::matches($remoteAddress, $proxy)) {
174 return true;
175 }
176 }
177
178 return false;
179 }
180
181 /** @throws InvalidForwardedHeaderNameException */
182 private static function validateTrustedHeaders(array $headers): void
183 {
184 foreach ($headers as $header) {
185 if (! in_array($header, self::X_FORWARDED_HEADERS, true)) {
186 throw InvalidForwardedHeaderNameException::forHeader($header);
187 }
188 }
189 }
190
191 /**
192 * @param list<non-empty-string> $proxyCIDRList
193 * @return list<non-empty-string>
194 * @throws InvalidProxyAddressException
195 */
196 private static function normalizeProxiesList(array $proxyCIDRList): array
197 {
198 $foundWildcard = false;
199
200 foreach ($proxyCIDRList as $index => $cidr) {
201 if ($cidr === '*') {
202 unset($proxyCIDRList[$index]);
203 $foundWildcard = true;
204 continue;
205 }
206
207 if (! self::validateProxyCIDR($cidr)) {
208 throw InvalidProxyAddressException::forAddress($cidr);
209 }
210 }
211
212 if ($foundWildcard) {
213 $proxyCIDRList[] = '0.0.0.0/0';
214 $proxyCIDRList[] = '::/0';
215 }
216
217 return $proxyCIDRList;
218 }
219
220 private static function validateProxyCIDR(mixed $cidr): bool
221 {
222 if (! is_string($cidr) || '' === $cidr) {
223 return false;
224 }
225
226 $address = $cidr;
227 $mask = null;
228 if (str_contains($cidr, '/')) {
229 $parts = explode('/', $cidr, 2);
230 assert(count($parts) >= 2);
231 [$address, $mask] = $parts;
232 $mask = (int) $mask;
233 }
234
235 if (str_contains($address, ':')) {
236 // is IPV6
237 return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
238 && (
239 $mask === null
240 || (
241 $mask <= 128
242 && $mask >= 0
243 )
244 );
245 }
246
247 // is IPV4
248 return filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)
249 && (
250 $mask === null
251 || (
252 $mask <= 32
253 && $mask >= 0
254 )
255 );
256 }
257 }