From 149ce96941ccebc60a67b366db2b32f97a3890b5 Mon Sep 17 00:00:00 2001 From: Joe Ferguson Date: Thu, 23 Jul 2026 21:07:36 -0500 Subject: [PATCH] Do not abort cross-origin requests with an empty 200 (#1955) --- include/prepend.inc | 31 +++- tests/EndToEnd/OriginHeaderTest.php | 256 ++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 tests/EndToEnd/OriginHeaderTest.php diff --git a/include/prepend.inc b/include/prepend.inc index b2f3969f11..c1fa1f99bd 100644 --- a/include/prepend.inc +++ b/include/prepend.inc @@ -21,20 +21,39 @@ header("Permissions-Policy: interest-cohort=()"); /* Fix Silly Same Origin Policies */ (function (): void { + // The presence of CORS headers below depends on the Origin request header, + // so shared caches must not reuse a response across different origins. + header("Vary: Origin", false); + if (!isset($_SERVER["HTTP_ORIGIN"])) { return; } + // Browsers send a literal "null" origin for sandboxed iframes, documents + // loaded from data:/file: URLs and some cross origin redirects. parse_url() + // reports no host at all for those, so treat them as untrusted. $host = parse_url($_SERVER["HTTP_ORIGIN"]); - if (!preg_match('/^(.+\.)?php\.net$/', $host["host"])) { - if ($host["host"] != $_SERVER["SERVER_NAME"]) { - exit(10); + $hostname = $host["host"] ?? ""; + + $isTrustedOrigin = $hostname !== "" && ( + preg_match('/^(.+\.)?php\.net$/', $hostname) === 1 + || $hostname === ($_SERVER["SERVER_NAME"] ?? "") + ); + + if (!$isTrustedOrigin) { + // Serve the page normally, only without any CORS headers. + if (in_array($_SERVER["REQUEST_METHOD"] ?? "GET", ["GET", "HEAD", "OPTIONS"], true)) { + return; } + + // Never respond with an empty body here: that is what ended up cached. + header("Cache-Control: no-store"); + http_response_code(403); + exit("Cross-origin requests are not allowed for this endpoint.\n"); } + if (isset($host["port"])) { - $hostname = $host["host"] . ":" . $host["port"]; - } else { - $hostname = $host["host"]; + $hostname .= ":" . $host["port"]; } header("Access-Control-Allow-Origin: http://$hostname"); diff --git a/tests/EndToEnd/OriginHeaderTest.php b/tests/EndToEnd/OriginHeaderTest.php new file mode 100644 index 0000000000..e62f30acd6 --- /dev/null +++ b/tests/EndToEnd/OriginHeaderTest.php @@ -0,0 +1,256 @@ + + */ + public static function provideForeignOrigin(): \Generator + { + $origins = [ + // Any unrelated site embedding or fetching php.net. + 'https://example.com', + // Browsers send a literal "null" origin for sandboxed iframes, + // documents from data:/file: URLs, and some cross-origin redirects. + 'null', + // Must not be treated as php.net just because it contains it. + 'https://evil-php.net.attacker.com', + 'https://notphp.net', + ]; + + foreach ($origins as $origin) { + yield $origin => [$origin]; + } + } + + /** + * @return \Generator + */ + public static function provideAllowedOrigin(): \Generator + { + $origins = [ + 'no Origin header' => null, + 'https://www.php.net' => 'https://www.php.net', + 'https://php.net' => 'https://php.net', + 'https://qa.php.net' => 'https://qa.php.net', + ]; + + foreach ($origins as $name => $origin) { + yield $name => [$origin]; + } + } + + /** + * @return \Generator + */ + public static function providePath(): \Generator + { + $paths = [ + '/', + '/downloads.php', + '/contact.php', + '/manual/en/function.str-replace.php', + '/releases/', + ]; + + foreach ($paths as $path) { + yield $path => [$path]; + } + } + + /** + * @return array{status: int, body: string, vary: string} + */ + private static function post(string $path, string $origin): array + { + return self::get($path, $origin, ['vote' => 'up']); + } + + /** + * @param ?array $postFields + * + * @return array{status: int, body: string, vary: string} + */ + private static function get( + string $path, + ?string $origin = null, + ?array $postFields = null, + ): array + { + $httpHost = getenv('HTTP_HOST'); + + if (!is_string($httpHost)) { + throw new \RuntimeException('Environment variable "HTTP_HOST" is not set.'); + } + + $headers = []; + + if (is_string($origin)) { + $headers[] = sprintf('Origin: %s', $origin); + } + + $vary = ''; + + $handle = curl_init(); + + if (is_array($postFields)) { + curl_setopt($handle, CURLOPT_POST, true); + curl_setopt($handle, CURLOPT_POSTFIELDS, http_build_query($postFields)); + } + + curl_setopt_array($handle, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_URL => sprintf('http://%s%s', $httpHost, $path), + CURLOPT_HEADERFUNCTION => static function ($handle, string $header) use (&$vary): int { + if (stripos($header, 'vary:') === 0) { + $vary = trim(substr($header, strlen('vary:'))); + } + + return strlen($header); + }, + ]); + + $body = curl_exec($handle); + $status = curl_getinfo($handle, CURLINFO_HTTP_CODE); + + curl_close($handle); + + if (!is_string($body)) { + throw new \RuntimeException(sprintf('Failed to request "%s".', $path)); + } + + return [ + 'status' => $status, + 'body' => $body, + 'vary' => $vary, + ]; + } +}