• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

nette / http / 29282633795

13 Jul 2026 08:30PM UTC coverage: 83.393% (+0.2%) from 83.198%
29282633795

push

github

dg
HttpExtension: default to trusting X-Forwarded-For only (BC break!)

Changes the configured default of `proxyHeaders` from "both" to "xForwarded",
so an application behind a proxy no longer trusts a client-supplied "Forwarded"
header by default (the common proxy sets X-Forwarded-For). The RequestFactory
PHP default is intentionally left at "both" for BC; this hardens only the
framework configuration.

BC: deployments whose proxy uses the "Forwarded" header must now set
`proxyHeaders: forwarded` (or `both`).

1 of 1 new or added line in 1 file covered. (100.0%)

25 existing lines in 2 files now uncovered.

939 of 1126 relevant lines covered (83.39%)

0.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

96.06
/src/Http/RequestFactory.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the Nette Framework (https://nette.org)
5
 * Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace Nette\Http;
9

10
use Nette;
11
use Nette\Utils\Arrays;
12
use Nette\Utils\Strings;
13
use function array_filter, array_key_last, array_map, base64_encode, explode, file_get_contents, filter_input_array, filter_var, function_exists, get_debug_type, in_array, ini_get, is_array, is_string, min, preg_last_error, preg_match, preg_replace, rtrim, sprintf, str_contains, strcasecmp, strlen, strncmp, strpos, strrpos, strtolower, strtr, substr, trim;
14
use const PHP_SAPI;
15

16

17
/**
18
 * HTTP request factory.
19
 */
20
class RequestFactory
21
{
22
        /** @internal */
23
        private const ValidChars = '\x09\x0A\x0D\x20-\x7E\xA0-\x{10FFFF}';
24

25
        /**
26
         * Regex-based filters applied to the URL before parsing. 'path' filters run on the path component only;
27
         * 'url' filters run on the full request URI.
28
         * @var array<string, array<string, string>>
29
         */
30
        public array $urlFilters = [
31
                'path' => ['#//#' => '/'], // '%20' => ''
32
                'url' => [], // '#[.,)]$#D' => ''
33
        ];
34

35
        private bool $binary = false;
36
        private bool $forceHttps = false;
37

38
        /** @var list<string> */
39
        private array $proxies = [];
40

41
        private bool $forwarded = true;
42
        private bool $xForwarded = true;
43

44

45
        /**
46
         * Disables sanitization of request data (GET, POST, cookies, file names) for binary-safe handling.
47
         */
48
        public function setBinary(bool $binary = true): static
1✔
49
        {
50
                $this->binary = $binary;
1✔
51
                return $this;
1✔
52
        }
53

54

55
        /**
56
         * Sets the trusted proxy IP addresses or CIDR blocks used to resolve the real client IP and URL scheme,
57
         * and which forwarding headers to trust from them ("Forwarded" and/or "X-Forwarded-*").
58
         * @param string|list<string>  $proxy
59
         */
60
        public function setProxy(string|array $proxy, bool $forwarded = true, bool $xForwarded = true): static
1✔
61
        {
62
                $this->proxies = (array) $proxy;
1✔
63
                $this->forwarded = $forwarded;
1✔
64
                $this->xForwarded = $xForwarded;
1✔
65
                return $this;
1✔
66
        }
67

68

69
        /**
70
         * Forces the request scheme to HTTPS regardless of the server environment.
71
         */
72
        public function setForceHttps(bool $forceHttps = true): static
1✔
73
        {
74
                $this->forceHttps = $forceHttps;
1✔
75
                return $this;
1✔
76
        }
77

78

79
        /**
80
         * Returns new Request instance, using values from superglobals.
81
         */
82
        public function fromGlobals(): Request
83
        {
84
                $url = new Url;
1✔
85
                $this->getServer($url);
1✔
86
                $this->getPathAndQuery($url);
1✔
87
                [$post, $cookies] = $this->getGetPostCookie($url);
1✔
88
                [$remoteAddr, $remoteHost] = $this->getClient($url);
1✔
89
                if ($this->forceHttps) {
1✔
90
                        $url->setScheme('https');
1✔
91
                }
92

93
                return new Request(
1✔
94
                        new UrlScript($url, $this->getScriptPath($url)),
1✔
95
                        $post,
96
                        $this->getFiles(),
1✔
97
                        $cookies,
98
                        $this->getHeaders(),
1✔
99
                        $this->getMethod(),
1✔
100
                        $remoteAddr,
101
                        $remoteHost,
102
                        fn() => (string) file_get_contents('php://input'),
1✔
103
                );
104
        }
105

106

107
        private function getServer(Url $url): void
1✔
108
        {
109
                $url->setScheme(!empty($_SERVER['HTTPS']) && strcasecmp($_SERVER['HTTPS'], 'off') ? 'https' : 'http');
1✔
110

111
                if (
112
                        (isset($_SERVER[$tmp = 'HTTP_HOST']) || isset($_SERVER[$tmp = 'SERVER_NAME']))
1✔
113
                        && ($pair = $this->parseHostAndPort($_SERVER[$tmp]))
1✔
114
                ) {
115
                        $url->setHost($pair[0]);
1✔
116
                        if (isset($pair[1])) {
1✔
117
                                $url->setPort($pair[1]);
1✔
118
                        } elseif ($tmp === 'SERVER_NAME' && isset($_SERVER['SERVER_PORT'])) {
1✔
119
                                $url->setPort((int) $_SERVER['SERVER_PORT']);
1✔
120
                        }
121
                }
122
        }
1✔
123

124

125
        private function getPathAndQuery(Url $url): void
1✔
126
        {
127
                $requestUrl = $_SERVER['REQUEST_URI'] ?? '/';
1✔
128
                $requestUrl = preg_replace('#^\w++://[^/]++#', '', $requestUrl);
1✔
129
                $requestUrl = Strings::replace($requestUrl, $this->urlFilters['url']);
1✔
130

131
                $tmp = explode('?', $requestUrl, 2);
1✔
132
                $path = Url::unescape($tmp[0], '%/?#');
1✔
133
                $path = Strings::fixEncoding(Strings::replace($path, $this->urlFilters['path']));
1✔
134
                $url->setPath($path);
1✔
135
                $url->setQuery($tmp[1] ?? '');
1✔
136
        }
1✔
137

138

139
        private function getScriptPath(Url $url): string
1✔
140
        {
141
                if (PHP_SAPI === 'cli-server') {
1✔
UNCOV
142
                        return '/';
×
143
                }
144

145
                $path = $url->getPath();
1✔
146
                $lpath = strtolower($path);
1✔
147
                $script = strtolower($_SERVER['SCRIPT_NAME'] ?? '');
1✔
148
                if ($lpath !== $script) {
1✔
149
                        $max = min(strlen($lpath), strlen($script));
1✔
150
                        for ($i = 0; $i < $max && $lpath[$i] === $script[$i]; $i++);
1✔
151
                        $path = $i
1✔
152
                                ? substr($path, 0, strrpos($path, '/', $i - strlen($path) - 1) + 1)
1✔
153
                                : '/';
1✔
154
                }
155

156
                return $path;
1✔
157
        }
158

159

160
        /** @return array{mixed[], mixed[]} */
161
        private function getGetPostCookie(Url $url): array
1✔
162
        {
163
                $useFilter = (!in_array((string) ini_get('filter.default'), ['', 'unsafe_raw'], strict: true) || ini_get('filter.default_flags'));
1✔
164

165
                $query = $url->getQueryParameters();
1✔
166
                $post = $useFilter
1✔
UNCOV
167
                        ? filter_input_array(INPUT_POST, FILTER_UNSAFE_RAW)
×
168
                        : (empty($_POST) ? [] : $_POST);
1✔
169
                $cookies = $useFilter
1✔
UNCOV
170
                        ? filter_input_array(INPUT_COOKIE, FILTER_UNSAFE_RAW)
×
171
                        : (empty($_COOKIE) ? [] : $_COOKIE);
1✔
172

173
                // remove invalid characters
174
                $reChars = '#^[' . self::ValidChars . ']*+$#Du';
1✔
175
                if (!$this->binary) {
1✔
176
                        $list = [&$query, &$post, &$cookies];
1✔
177
                        foreach ($list as $key => &$val) {
1✔
178
                                foreach ($val as $k => $v) {
1✔
179
                                        if (is_string($k) && (!preg_match($reChars, $k) || preg_last_error())) {
1✔
180
                                                unset($list[$key][$k]);
1✔
181

182
                                        } elseif (is_array($v)) {
1✔
183
                                                $list[$key][$k] = $v;
1✔
184
                                                $list[] = &$list[$key][$k];
1✔
185

186
                                        } elseif (is_string($v)) {
1✔
187
                                                $list[$key][$k] = (string) preg_replace('#[^' . self::ValidChars . ']+#u', '', $v);
1✔
188

189
                                        } else {
190
                                                throw new Nette\InvalidStateException(sprintf('Invalid value in $_POST/$_COOKIE in key %s, expected string, %s given.', "'$k'", get_debug_type($v)));
1✔
191
                                        }
192
                                }
193
                        }
194

195
                        unset($list, $key, $val, $k, $v);
1✔
196
                }
197

198
                $url->setQuery($query);
1✔
199
                return [$post, $cookies];
1✔
200
        }
201

202

203
        /** @return mixed[] */
204
        private function getFiles(): array
205
        {
206
                $reChars = '#^[' . self::ValidChars . ']*+$#Du';
1✔
207
                $files = [];
1✔
208
                $list = [];
1✔
209
                foreach ($_FILES ?? [] as $k => $v) {
1✔
210
                        if (
211
                                !is_array($v)
1✔
212
                                || !isset($v['name'], $v['type'], $v['size'], $v['tmp_name'], $v['error'])
1✔
213
                                || (!$this->binary && is_string($k) && (!preg_match($reChars, $k) || preg_last_error()))
1✔
214
                        ) {
215
                                continue;
1✔
216
                        }
217

218
                        $v['@'] = &$files[$k];
1✔
219
                        $list[] = $v;
1✔
220
                }
221

222
                // create FileUpload objects
223
                foreach ($list as &$v) {
1✔
224
                        if (!isset($v['name'])) {
1✔
UNCOV
225
                                continue;
×
226

227
                        } elseif (!is_array($v['name'])) {
1✔
228
                                if (!$this->binary && (!preg_match($reChars, $v['name']) || preg_last_error())) {
1✔
229
                                        $v['name'] = '';
1✔
230
                                }
231

232
                                if ($v['error'] !== UPLOAD_ERR_NO_FILE) {
1✔
233
                                        $v['@'] = new FileUpload($v);
1✔
234
                                }
235

236
                                continue;
1✔
237
                        }
238

239
                        foreach ($v['name'] as $k => $foo) {
1✔
240
                                if (!$this->binary && is_string($k) && (!preg_match($reChars, $k) || preg_last_error())) {
1✔
UNCOV
241
                                        continue;
×
242
                                }
243

244
                                $list[] = [
1✔
245
                                        'name' => $v['name'][$k],
1✔
246
                                        'type' => $v['type'][$k],
1✔
247
                                        'size' => $v['size'][$k],
1✔
248
                                        'full_path' => $v['full_path'][$k] ?? null,
1✔
249
                                        'tmp_name' => $v['tmp_name'][$k],
1✔
250
                                        'error' => $v['error'][$k],
1✔
251
                                        '@' => &$v['@'][$k],
1✔
252
                                ];
253
                        }
254
                }
255

256
                return $files;
1✔
257
        }
258

259

260
        /** @return array<string, string> */
261
        private function getHeaders(): array
262
        {
263
                if (function_exists('apache_request_headers')) {
1✔
UNCOV
264
                        $headers = apache_request_headers() ?: [];
×
265
                } else {
266
                        $headers = [];
1✔
267
                        foreach ($_SERVER as $k => $v) {
1✔
268
                                if (str_starts_with($k, 'HTTP_')) {
1✔
269
                                        $k = substr($k, 5);
1✔
270
                                } elseif (strncmp($k, 'CONTENT_', 8)) {
1✔
271
                                        continue;
1✔
272
                                }
273

274
                                $headers[strtr($k, '_', '-')] = $v;
1✔
275
                        }
276
                }
277

278
                if (!isset($headers['Authorization'])) {
1✔
279
                        if (isset($_SERVER['PHP_AUTH_USER'], $_SERVER['PHP_AUTH_PW'])) {
1✔
280
                                $headers['Authorization'] = 'Basic ' . base64_encode($_SERVER['PHP_AUTH_USER'] . ':' . $_SERVER['PHP_AUTH_PW']);
1✔
281
                        } elseif (isset($_SERVER['PHP_AUTH_DIGEST'])) {
1✔
282
                                $headers['Authorization'] = 'Digest ' . $_SERVER['PHP_AUTH_DIGEST'];
1✔
283
                        }
284
                }
285

286
                return $headers;
1✔
287
        }
288

289

290
        private function getMethod(): string
291
        {
292
                $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
1✔
293
                if (
294
                        $method === 'POST'
1✔
295
                        && preg_match('#^[A-Z]+$#D', $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? '')
1✔
296
                ) {
297
                        $method = $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'];
1✔
298
                }
299

300
                return $method;
1✔
301
        }
302

303

304
        /** @return array{?string, ?string}  [remoteAddr, remoteHost] */
305
        private function getClient(Url $url): array
1✔
306
        {
307
                $remoteAddr = !empty($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : null;
1✔
308

309
                // trust forwarding headers only when the request comes through a trusted proxy
310
                $usingTrustedProxy = $remoteAddr && Arrays::some($this->proxies, fn(string $proxy): bool => Helpers::ipMatch($remoteAddr, $proxy));
1✔
311
                if ($usingTrustedProxy) {
1✔
312
                        $remoteHost = null;
1✔
313
                        $remoteAddr = match (true) {
1✔
314
                                $this->forwarded && !empty($_SERVER['HTTP_FORWARDED']) => $this->useForwardedProxy($url),
1✔
315
                                $this->xForwarded => $this->useNonstandardProxy($url),
1✔
316
                                default => $remoteAddr,
1✔
317
                        };
318

319
                } else {
320
                        $remoteHost = !empty($_SERVER['REMOTE_HOST']) ? $_SERVER['REMOTE_HOST'] : null;
1✔
321
                }
322

323
                return [$remoteAddr, $remoteHost];
1✔
324
        }
325

326

327
        private function useForwardedProxy(Url $url): ?string
1✔
328
        {
329
                // RFC 7239: split into hops (comma), each a set of params (semicolon)
330
                $hops = $addresses = [];
1✔
331
                foreach (explode(',', $_SERVER['HTTP_FORWARDED']) as $element) {
1✔
332
                        $hop = [];
1✔
333
                        foreach (explode(';', $element) as $pair) {
1✔
334
                                [$key, $value] = explode('=', $pair, 2) + [1 => ''];
1✔
335
                                $hop[strtolower(trim($key))] = trim($value, " \t\"");
1✔
336
                        }
337

338
                        $for = $hop['for'] ?? '';
1✔
339
                        $addresses[] = str_contains($for, '[')
1✔
340
                                ? substr($for, 1, strpos($for, ']') - 1) // IPv6 "[addr]:port"
1✔
341
                                : explode(':', $for)[0]; // IPv4 "addr:port" or bare address
1✔
342
                        $hops[] = $hop;
1✔
343
                }
344

345
                $clientHop = $this->findClientHop($addresses);
1✔
346
                if ($clientHop === null) {
1✔
347
                        return null;
1✔
348
                }
349

350
                // scheme and host from the client's own hop
351
                $hop = $hops[$clientHop[0]];
1✔
352
                if (isset($hop['proto'])) {
1✔
353
                        $url->setScheme(strcasecmp($hop['proto'], 'https') === 0 ? 'https' : 'http');
1✔
354
                        $url->setPort($url->getScheme() === 'https' ? 443 : 80);
1✔
355
                }
356

357
                if (isset($hop['host']) && ($pair = $this->parseHostAndPort($hop['host']))) {
1✔
358
                        $url->setHost($pair[0]);
1✔
359
                        if (isset($pair[1])) {
1✔
360
                                $url->setPort($pair[1]);
1✔
361
                        }
362
                }
363

364
                return $clientHop[1];
1✔
365
        }
366

367

368
        private function useNonstandardProxy(Url $url): ?string
1✔
369
        {
370
                if (!empty($_SERVER['HTTP_X_FORWARDED_PROTO'])) {
1✔
371
                        $url->setScheme(strcasecmp($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') === 0 ? 'https' : 'http');
1✔
372
                        $url->setPort($url->getScheme() === 'https' ? 443 : 80);
1✔
373
                }
374

375
                if (!empty($_SERVER['HTTP_X_FORWARDED_PORT'])) {
1✔
376
                        $url->setPort((int) $_SERVER['HTTP_X_FORWARDED_PORT']);
1✔
377
                }
378

379
                if (empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1✔
380
                        return null;
1✔
381
                }
382

383
                $clientHop = $this->findClientHop(array_map(trim(...), explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'])));
1✔
384
                if ($clientHop === null) {
1✔
385
                        return null;
1✔
386
                }
387

388
                if (!empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
1✔
389
                        $hosts = explode(',', $_SERVER['HTTP_X_FORWARDED_HOST']);
1✔
390
                        if (isset($hosts[$clientHop[0]]) && ($pair = $this->parseHostAndPort(trim($hosts[$clientHop[0]])))) {
1✔
391
                                $url->setHost($pair[0]);
1✔
392
                                if (isset($pair[1])) {
1✔
393
                                        $url->setPort($pair[1]);
1✔
394
                                }
395
                        }
396
                }
397

398
                return $clientHop[1];
1✔
399
        }
400

401

402
        /**
403
         * Returns [index, address] of the rightmost hop after stripping trailing trusted proxies,
404
         * or null when that hop is not a valid IP.
405
         * @param  list<string>  $addresses
406
         * @return array{int, string}|null
407
         */
408
        private function findClientHop(array $addresses): ?array
1✔
409
        {
410
                $untrusted = array_filter(
1✔
411
                        $addresses,
1✔
412
                        fn(string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) === false
1✔
413
                                || !Arrays::some($this->proxies, fn(string $proxy): bool => Helpers::ipMatch($ip, $proxy)),
1✔
414
                );
415
                if (!$untrusted) {
1✔
UNCOV
416
                        return null;
×
417
                }
418

419
                $index = array_key_last($untrusted);
1✔
420
                return filter_var($untrusted[$index], FILTER_VALIDATE_IP) === false
1✔
421
                        ? null
1✔
422
                        : [$index, $untrusted[$index]];
1✔
423
        }
424

425

426
        /** @return array{string, ?int}|null */
427
        private function parseHostAndPort(string $s): ?array
1✔
428
        {
429
                return preg_match('#^([a-z0-9_.-]+|\[[a-f0-9:]+])(:\d+)?$#Di', $s, $matches)
1✔
430
                        ? [
431
                                rtrim(strtolower($matches[1]), '.'),
1✔
432
                                isset($matches[2]) ? (int) substr($matches[2], 1) : null,
1✔
433
                        ]
434
                        : null;
1✔
435
        }
436

437

438
        /** @deprecated */
439
        public function createHttpRequest(): Request
440
        {
UNCOV
441
                return $this->fromGlobals();
×
442
        }
443
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc