|
| 1 | +<?php |
| 2 | + |
| 3 | +/** |
| 4 | + * EVO_SITE_URL is built once at boot and every asset URL, redirect and the |
| 5 | + * manager's referer check hang off it, so the host header has to survive the |
| 6 | + * trip intact. It used to be stripped with str_replace(':' . SERVER_PORT, ''), |
| 7 | + * which turns "localhost:8080" into "localhost80" whenever the server itself |
| 8 | + * listens on 80 - the shape every published container port has. |
| 9 | + */ |
| 10 | +function resolveSiteUrlInFreshProcess(array $server): string |
| 11 | +{ |
| 12 | + $rootDir = dirname(__DIR__, 3); |
| 13 | + |
| 14 | + $code = '<?php' . "\n" |
| 15 | + // The boot code only reads the host header outside CLI, and the suite |
| 16 | + // itself runs on CLI - so the sapi check is answered before helper.php |
| 17 | + // gets a chance to define it. |
| 18 | + . 'function is_cli() { return false; }' . "\n" |
| 19 | + . '$_SERVER = ' . var_export($server + [ |
| 20 | + 'SCRIPT_NAME' => '/index.php', |
| 21 | + 'PHP_SELF' => '/index.php', |
| 22 | + 'REQUEST_METHOD' => 'GET', |
| 23 | + ], true) . ';' . "\n" |
| 24 | + . 'define("IN_INSTALL_MODE", false);' . "\n" |
| 25 | + . 'define("IN_MANAGER_MODE", false);' . "\n" |
| 26 | + . 'define("EVO_API_MODE", true);' . "\n" |
| 27 | + . 'require ' . var_export($rootDir . '/core/vendor/autoload.php', true) . ';' . "\n" |
| 28 | + . 'require ' . var_export($rootDir . '/core/functions/helper.php', true) . ';' . "\n" |
| 29 | + . 'require ' . var_export($rootDir . '/core/functions/preload.php', true) . ';' . "\n" |
| 30 | + . 'require ' . var_export($rootDir . '/core/includes/define.inc.php', true) . ';' . "\n" |
| 31 | + . 'echo EVO_SITE_URL;'; |
| 32 | + |
| 33 | + $scriptPath = tempnam(sys_get_temp_dir(), 'evo-site-url-') . '.php'; |
| 34 | + file_put_contents($scriptPath, $code); |
| 35 | + |
| 36 | + $output = []; |
| 37 | + $status = 0; |
| 38 | + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($scriptPath) . ' 2>&1', $output, $status); |
| 39 | + @unlink($scriptPath); |
| 40 | + |
| 41 | + expect($status)->toBe(0, implode("\n", $output)); |
| 42 | + |
| 43 | + return trim(implode("\n", $output)); |
| 44 | +} |
| 45 | + |
| 46 | +test('site url keeps the port the browser asked for', function (array $server, string $expected) { |
| 47 | + expect(resolveSiteUrlInFreshProcess($server))->toBe($expected); |
| 48 | +})->with([ |
| 49 | + // The regression: nginx/apache listen on 80 inside the container, the |
| 50 | + // browser reaches it on the published 8080. |
| 51 | + 'published container port' => [ |
| 52 | + ['HTTP_HOST' => 'localhost:8080', 'SERVER_PORT' => '80'], |
| 53 | + 'http://localhost:8080/', |
| 54 | + ], |
| 55 | + 'ipv6 literal with a port' => [ |
| 56 | + ['HTTP_HOST' => '[::1]:8080', 'SERVER_PORT' => '80'], |
| 57 | + 'http://[::1]:8080/', |
| 58 | + ], |
| 59 | + 'plain http on 80' => [ |
| 60 | + ['HTTP_HOST' => 'example.test', 'SERVER_PORT' => '80'], |
| 61 | + 'http://example.test/', |
| 62 | + ], |
| 63 | + 'http on a non default port' => [ |
| 64 | + ['HTTP_HOST' => 'example.test:8080', 'SERVER_PORT' => '8080'], |
| 65 | + 'http://example.test:8080/', |
| 66 | + ], |
| 67 | + 'https on 443' => [ |
| 68 | + ['HTTP_HOST' => 'example.test', 'SERVER_PORT' => '443', 'HTTPS' => 'on'], |
| 69 | + 'https://example.test/', |
| 70 | + ], |
| 71 | + 'https on a non default port' => [ |
| 72 | + ['HTTP_HOST' => 'example.test:8443', 'SERVER_PORT' => '8443', 'HTTPS' => 'on'], |
| 73 | + 'https://example.test:8443/', |
| 74 | + ], |
| 75 | + // TLS terminated in front of php: the port php answers on says nothing |
| 76 | + // about the URL the browser used. |
| 77 | + 'proxied https, php on 80' => [ |
| 78 | + ['HTTP_HOST' => 'example.test', 'SERVER_PORT' => '80', 'HTTP_X_FORWARDED_PROTO' => 'https'], |
| 79 | + 'https://example.test/', |
| 80 | + ], |
| 81 | + 'proxied https on a non default port' => [ |
| 82 | + ['HTTP_HOST' => 'example.test:8443', 'SERVER_PORT' => '80', 'HTTP_X_FORWARDED_PROTO' => 'https'], |
| 83 | + 'https://example.test:8443/', |
| 84 | + ], |
| 85 | + // Nothing to trust but the listening port. |
| 86 | + 'no host header' => [ |
| 87 | + ['SERVER_PORT' => '8080'], |
| 88 | + 'http://localhost:8080/', |
| 89 | + ], |
| 90 | +]); |
| 91 | + |
| 92 | +/** |
| 93 | + * Whatever survives the host header ends up in every URL the site prints, so a |
| 94 | + * header that is not a plain host:port is not worth guessing at - the boot |
| 95 | + * falls back to the listening port, the same as a request with no host at all. |
| 96 | + */ |
| 97 | +test('a host header that is not a bare host:port is refused', function (string $host) { |
| 98 | + expect(resolveSiteUrlInFreshProcess(['HTTP_HOST' => $host, 'SERVER_PORT' => '80'])) |
| 99 | + ->toBe('http://localhost/'); |
| 100 | +})->with([ |
| 101 | + // Would become the userinfo of http://localhost@evil.example/ and take the |
| 102 | + // visitor to evil.example instead. |
| 103 | + 'userinfo separator' => ['localhost@evil.example'], |
| 104 | + 'path appended' => ['localhost/evil.example'], |
| 105 | + 'scheme prefix' => ['http://localhost'], |
| 106 | + 'crlf' => ["localhost |
| 107 | +X-Injected: 1"], |
| 108 | + 'trailing space' => ['localhost '], |
| 109 | + 'port out of range' => ['localhost:99999'], |
| 110 | + 'non numeric port' => ['localhost:80a'], |
| 111 | + 'empty port' => ['localhost:'], |
| 112 | + 'unclosed bracket' => ['[::1'], |
| 113 | +]); |
| 114 | + |
| 115 | +/** |
| 116 | + * The pattern is linear: its two branches differ in their first character, and |
| 117 | + * neither repetition can match the delimiter that follows it. A pathological |
| 118 | + * host header should cost roughly what a long ordinary one costs. |
| 119 | + */ |
| 120 | +test('host parsing does not blow up on a pathological header', function () { |
| 121 | + $pattern = '/^(?:([A-Za-z0-9._-]+)|(\[[0-9A-Fa-f:.]+\]))(?::(\d{1,5}))?$/'; |
| 122 | + |
| 123 | + $time = function (string $subject) use ($pattern): float { |
| 124 | + $started = hrtime(true); |
| 125 | + for ($i = 0; $i < 50; $i++) { |
| 126 | + preg_match($pattern, $subject); |
| 127 | + } |
| 128 | + |
| 129 | + return (hrtime(true) - $started) / 50; |
| 130 | + }; |
| 131 | + |
| 132 | + $short = $time(str_repeat('a', 2000) . ':'); |
| 133 | + $long = $time(str_repeat('a', 8000) . ':'); |
| 134 | + |
| 135 | + // Four times the input for well under sixteen times the work: linear, not |
| 136 | + // quadratic and nowhere near exponential. |
| 137 | + expect($long)->toBeLessThan(max($short, 1000) * 16) |
| 138 | + ->and(preg_last_error())->toBe(PREG_NO_ERROR); |
| 139 | +}); |
0 commit comments