Skip to content

Commit f630fd2

Browse files
authored
Merge pull request #2434 from elcreator/fix-docker-port
fix(core): Docker port processing
2 parents 2003c1e + 71a372f commit f630fd2

2 files changed

Lines changed: 185 additions & 20 deletions

File tree

‎core/includes/define.inc.php‎

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -120,45 +120,71 @@
120120
}
121121

122122
if (!defined('EVO_SITE_URL')) {
123-
// check for valid hostnames
123+
if (!isset($_SERVER['SERVER_PORT'])) {
124+
$_SERVER['SERVER_PORT'] = 80;
125+
}
126+
127+
// Host is what the browser actually asked for, and behind a proxy or a
128+
// published container port it is the only view of the site that can be
129+
// reached again - SERVER_PORT is the port this process listens on, which
130+
// may be a different number entirely. So the host header decides both the
131+
// hostname and the port, and SERVER_PORT is consulted only when there is
132+
// no host header at all.
124133
$site_hostname = 'localhost';
125-
if (!is_cli()) {
126-
$site_hostname = str_replace(
127-
':' . $_SERVER['SERVER_PORT'],
128-
'',
129-
get_by_key($_SERVER, 'HTTP_HOST', $site_hostname)
130-
);
134+
$site_port = null;
135+
$has_http_host = false;
136+
if (!is_cli() && !empty($_SERVER['HTTP_HOST'])) {
137+
// Anchored on purpose: str_replace(':' . SERVER_PORT, ...) turns
138+
// "localhost:8080" into "localhost80" whenever the server itself
139+
// listens on 80. The character sets are spelled out rather than
140+
// written as "everything up to the colon", because whatever lands here
141+
// is pasted into every URL the site emits: a header of
142+
// "localhost@evil.example" would otherwise become the userinfo of
143+
// http://localhost@evil.example/ and send the visitor elsewhere.
144+
// The two branches share no first character and neither repetition can
145+
// match the delimiter that follows it, so the match stays linear.
146+
$host_pattern = '/^(?:([A-Za-z0-9._-]+)|(\[[0-9A-Fa-f:.]+\]))(?::(\d{1,5}))?$/';
147+
if (preg_match($host_pattern, $_SERVER['HTTP_HOST'], $matches)) {
148+
$port = isset($matches[3]) && $matches[3] !== '' ? (int) $matches[3] : null;
149+
if ($port === null || ($port > 0 && $port <= 65535)) {
150+
$has_http_host = true;
151+
$site_hostname = $matches[1] !== '' ? $matches[1] : $matches[2];
152+
$site_port = $port;
153+
}
154+
}
155+
unset($host_pattern, $matches, $port);
131156
}
157+
158+
// check for valid hostnames
132159
$site_hostnames = explode(',', EVO_SITE_HOSTNAMES);
133160
if (!empty($site_hostnames[0]) && !in_array($site_hostname, $site_hostnames)) {
134161
$site_hostname = $site_hostnames[0];
135162
}
136163
unset($site_hostnames);
137164

138-
if (!isset($_SERVER['SERVER_PORT'])) {
139-
$_SERVER['SERVER_PORT'] = 80;
140-
}
141-
142165
// assign site_url
143166
if ((isset($_SERVER['HTTPS']) && strtolower($_SERVER['HTTPS']) === 'on') ||
144167
$_SERVER['SERVER_PORT'] == HTTPS_PORT ||
145168
(isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https')
146169
) {
147-
$site_url = 'https://' . $site_hostname;
170+
$scheme = 'https';
171+
$default_port = (int) HTTPS_PORT;
148172
} else {
149-
$site_url = 'http://' . $site_hostname;
173+
$scheme = 'http';
174+
$default_port = 80;
150175
}
151-
unset($site_hostname);
152176

153-
if ($_SERVER['SERVER_PORT'] !== 80) { // remove port from HTTP_HOST
154-
$site_url = str_replace(':' . $_SERVER['SERVER_PORT'], '', $site_url);
177+
// A host header omits the port when it is the default one for the scheme,
178+
// so "no port here" is an answer rather than a gap to fill from SERVER_PORT.
179+
if (!$has_http_host) {
180+
$site_port = (int) $_SERVER['SERVER_PORT'];
155181
}
156182

157-
if (!in_array((int)$_SERVER['SERVER_PORT'], [80, (int)HTTPS_PORT], true) &&
158-
strtolower(get_by_key($_SERVER, 'HTTPS', 'off'))
159-
) {
160-
$site_url .= ':' . $_SERVER['SERVER_PORT'];
183+
$site_url = $scheme . '://' . $site_hostname;
184+
if ($site_port !== null && $site_port !== $default_port) {
185+
$site_url .= ':' . $site_port;
161186
}
187+
unset($site_hostname, $site_port, $has_http_host, $scheme, $default_port);
162188

163189
$site_url .= EVO_BASE_URL;
164190
}
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)