Skip to content

Commit 71a372f

Browse files
elcreatorclaude
andcommitted
test(core): Site URL host header regression test
Covers the published container port case (host localhost:8080 while the server listens on 80) plus host header validation and parser linearity. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QwWweXsnkdNWy52GqKf2Xh
1 parent 710300e commit 71a372f

1 file changed

Lines changed: 139 additions & 0 deletions

File tree

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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

Comments
 (0)