Author: Joe Ferguson (svpernova09)
Committer: GitHub (web-flow)
Pusher: sy-records
Date: 2026-08-20T11:34:57+08:00

Commit: 
https://github.com/php/web-php/commit/049c59fcf247f38c8918cd379d1e1f86b46875f3
Raw diff: 
https://github.com/php/web-php/commit/049c59fcf247f38c8918cd379d1e1f86b46875f3.diff

Fix downloads.php crash on invalid os parameter (#1968)

Changed paths:
  A  src/Downloads/OptionResolver.php
  A  src/Downloads/Resolution.php
  A  tests/Unit/Downloads/OptionResolverTest.php
  M  public/downloads.php


Diff:

diff --git a/public/downloads.php b/public/downloads.php
index aa4f22c6b8..e53d8b2b5c 100644
--- a/public/downloads.php
+++ b/public/downloads.php
@@ -1,4 +1,6 @@
 <?php
+use phpweb\Downloads\OptionResolver;
+
 $_SERVER['BASE_PAGE'] = 'downloads.php';
 require_once __DIR__ . '/../include/prepend.inc';
 require_once __DIR__ . '/../include/gpg-keys.inc';
@@ -7,6 +9,58 @@
 // Try to make this page non-cached
 header_nocache();
 
+$os = [
+    'linux' => [
+        'name' => 'Linux',
+        'variants' => [
+            'linux-debian' => 'Debian',
+            'linux-fedora' => 'Fedora',
+            'linux-redhat' => 'RedHat',
+            'linux-ubuntu' => 'Ubuntu',
+            'linux-docker-cli' => 'Docker (Command Line Interface)',
+            'linux-docker-web' => 'Docker (Web Development)',
+        ],
+    ],
+    'osx' => [
+        'name' => 'macOS',
+        'variants' => [
+            'osx-homebrew' => 'Homebrew',
+            'osx-homebrew-php' => 'Homebrew-PHP',
+            'osx-docker-cli' => 'Docker (Command Line Interface)',
+            'osx-docker-web' => 'Docker (Web Development)',
+            'osx-macports' => 'MacPorts',
+        ],
+    ],
+    'windows' => [
+        'name' => 'Windows',
+        'variants' => [
+            'windows-downloads' => 'ZIP Downloads',
+            'windows-native' => 'Single Line Installer',
+            'windows-chocolatey' => 'Chocolatey',
+            'windows-scoop' => 'Scoop',
+            'windows-winget' => 'Winget',
+            'windows-docker-cli' => 'Docker (Command Line Interface)',
+            'windows-docker-web' => 'Docker (Web Development)',
+            'windows-wsl-debian' => 'WSL/Debian',
+            'windows-wsl-ubuntu' => 'WSL/Ubuntu',
+        ],
+    ],
+];
+
+// An invalid ?os= redirects to the auto-detected results before any output.
+$resolution = (new OptionResolver($os))->resolve(
+    $_GET,
+    $_SERVER['HTTP_SEC_CH_UA_PLATFORM'] ?? '',
+    $_SERVER['HTTP_USER_AGENT'] ?? '',
+);
+
+if ($resolution->redirectQuery !== null) {
+    header('Location: /downloads.php?' . $resolution->redirectQuery, true, 
302);
+    exit;
+}
+
+$options = $resolution->options;
+
 $SIDEBAR_DATA = '
 <div class="panel">
   <a href="/supported-versions.php">Supported Versions</a>
@@ -52,44 +106,6 @@ function option(string $value, string $desc, $attributes = 
[]): string
     return '<option value="' . $value . '"' . implode(' ', 
array_keys(array_filter($attributes))) . '>' . $desc . '</option>';
 }
 
-$os = [
-    'linux' => [
-        'name' => 'Linux',
-        'variants' => [
-            'linux-debian' => 'Debian',
-            'linux-fedora' => 'Fedora',
-            'linux-redhat' => 'RedHat',
-            'linux-ubuntu' => 'Ubuntu',
-            'linux-docker-cli' => 'Docker (Command Line Interface)',
-            'linux-docker-web' => 'Docker (Web Development)',
-        ],
-    ],
-    'osx' => [
-        'name' => 'macOS',
-        'variants' => [
-            'osx-homebrew' => 'Homebrew',
-            'osx-homebrew-php' => 'Homebrew-PHP',
-            'osx-docker-cli' => 'Docker (Command Line Interface)',
-            'osx-docker-web' => 'Docker (Web Development)',
-            'osx-macports' => 'MacPorts',
-        ],
-    ],
-    'windows' => [
-        'name' => 'Windows',
-        'variants' => [
-            'windows-downloads' => 'ZIP Downloads',
-            'windows-native' => 'Single Line Installer',
-            'windows-chocolatey' => 'Chocolatey',
-            'windows-scoop' => 'Scoop',
-            'windows-winget' => 'Winget',
-            'windows-docker-cli' => 'Docker (Command Line Interface)',
-            'windows-docker-web' => 'Docker (Web Development)',
-            'windows-wsl-debian' => 'WSL/Debian',
-            'windows-wsl-ubuntu' => 'WSL/Ubuntu',
-        ],
-    ],
-];
-
 $versions = [
     '8.5' => 'version 8.5',
     '8.4' => 'version 8.4',
@@ -98,44 +114,6 @@ function option(string $value, string $desc, $attributes = 
[]): string
     'default' => 'default PHP version for OS',
 ];
 
-
-$platform = $_SERVER['HTTP_SEC_CH_UA_PLATFORM'] ?? '';
-$ua = $_SERVER['HTTP_USER_AGENT'] ?? '';
-$auto_os = null;
-$auto_osvariant = null;
-
-if (!empty($platform) || !empty($ua)) {
-    $platform = strtolower(trim($platform, '"'));
-    if ($platform === 'windows' || stripos($ua, 'Windows') !== false) {
-        $auto_os = 'windows';
-    } elseif ($platform === 'macos' || stripos($ua, 'Mac') !== false) {
-        $auto_os = 'osx';
-    } elseif ($platform === 'linux' || stripos($ua, 'Linux') !== false) {
-        $auto_os = 'linux';
-        if (stripos($ua, 'Ubuntu') !== false) {
-            $auto_osvariant = 'linux-ubuntu';
-        } elseif (stripos($ua, 'Debian') !== false) {
-            $auto_osvariant = 'linux-debian';
-        } elseif (stripos($ua, 'Fedora') !== false) {
-            $auto_osvariant = 'linux-fedora';
-        } elseif (stripos($ua, 'Red Hat') !== false || stripos($ua, 'RedHat') 
!== false) {
-            $auto_osvariant = 'linux-redhat';
-        }
-    }
-}
-
-$defaults = [
-    'os' => $auto_os ?? 'linux',
-    'version' => 'default',
-];
-
-$options = array_merge($defaults, $_GET);
-
-if ($auto_osvariant && (!array_key_exists('osvariant', $options) || 
!array_key_exists($options['osvariant'], $os[$options['os']]['variants']))) {
-    $options['osvariant'] = $auto_osvariant;
-} elseif (!array_key_exists('osvariant', $options) || 
!array_key_exists($options['osvariant'], $os[$options['os']]['variants'])) {
-    $options['osvariant'] = array_key_first($os[$options['os']]['variants']);
-}
 ?>
 <h1>Downloads &amp; Installation Instructions</h1>
 
diff --git a/src/Downloads/OptionResolver.php b/src/Downloads/OptionResolver.php
new file mode 100644
index 0000000000..8c946b85e4
--- /dev/null
+++ b/src/Downloads/OptionResolver.php
@@ -0,0 +1,109 @@
+<?php
+
+declare(strict_types=1);
+
+namespace phpweb\Downloads;
+
+class OptionResolver
+{
+    /**
+     * @param array<string, array{name: string, variants: array<string, 
string>}> $osList
+     */
+    public function __construct(private readonly array $osList)
+    {
+    }
+
+    /**
+     * Resolve the selected download options from the request, auto-detecting 
the
+     * operating system from client hints / user agent when not explicitly 
chosen.
+     *
+     * When the client supplies an invalid os parameter (e.g. bots requesting
+     * ?os=<garbage> or ?os[]=x, which previously crashed while indexing the os
+     * list), the returned Resolution carries a redirect query pointing at the
+     * auto-detected results instead of silently rendering a default.
+     *
+     * @param array<string, mixed> $get GET parameters (os, osvariant, 
version, ...)
+     */
+    public function resolve(array $get, string $platformHeader, string 
$uaHeader): Resolution
+    {
+        [$autoOs, $autoOsVariant] = $this->autoDetect($platformHeader, 
$uaHeader);
+
+        $defaults = [
+            'os' => $autoOs ?? 'linux',
+            'version' => 'default',
+        ];
+
+        $options = array_merge($defaults, $get);
+
+        $invalidOs = array_key_exists('os', $get)
+            && (!is_string($get['os']) || !array_key_exists($get['os'], 
$this->osList));
+
+        if ($invalidOs) {
+            $options['os'] = $defaults['os'];
+        }
+
+        if ($autoOsVariant && (!array_key_exists('osvariant', $options) || 
!array_key_exists($options['osvariant'], 
$this->osList[$options['os']]['variants']))) {
+            $options['osvariant'] = $autoOsVariant;
+        } elseif (!array_key_exists('osvariant', $options) || 
!array_key_exists($options['osvariant'], 
$this->osList[$options['os']]['variants'])) {
+            $options['osvariant'] = 
array_key_first($this->osList[$options['os']]['variants']);
+        }
+
+        return new Resolution(
+            $options,
+            $invalidOs ? $this->redirectQuery($options) : null,
+        );
+    }
+
+    /**
+     * Build the canonical query string for the auto-detected results, 
preserving
+     * the resolved os/variant/version so the redirect lands on a valid page.
+     *
+     * @param array<string, mixed> $options
+     */
+    private function redirectQuery(array $options): string
+    {
+        $query = [];
+
+        foreach (['os', 'osvariant', 'version'] as $key) {
+            if (array_key_exists($key, $options) && is_string($options[$key])) 
{
+                $query[$key] = $options[$key];
+            }
+        }
+
+        return http_build_query($query);
+    }
+
+    /**
+     * @return array{0: ?string, 1: ?string} [auto os, auto os variant]
+     */
+    private function autoDetect(string $platformHeader, string $uaHeader): 
array
+    {
+        $autoOs = null;
+        $autoOsVariant = null;
+
+        if ($platformHeader === '' && $uaHeader === '') {
+            return [$autoOs, $autoOsVariant];
+        }
+
+        $platform = strtolower(trim($platformHeader, '"'));
+
+        if ($platform === 'windows' || stripos($uaHeader, 'Windows') !== 
false) {
+            $autoOs = 'windows';
+        } elseif ($platform === 'macos' || stripos($uaHeader, 'Mac') !== 
false) {
+            $autoOs = 'osx';
+        } elseif ($platform === 'linux' || stripos($uaHeader, 'Linux') !== 
false) {
+            $autoOs = 'linux';
+            if (stripos($uaHeader, 'Ubuntu') !== false) {
+                $autoOsVariant = 'linux-ubuntu';
+            } elseif (stripos($uaHeader, 'Debian') !== false) {
+                $autoOsVariant = 'linux-debian';
+            } elseif (stripos($uaHeader, 'Fedora') !== false) {
+                $autoOsVariant = 'linux-fedora';
+            } elseif (stripos($uaHeader, 'Red Hat') !== false || 
stripos($uaHeader, 'RedHat') !== false) {
+                $autoOsVariant = 'linux-redhat';
+            }
+        }
+
+        return [$autoOs, $autoOsVariant];
+    }
+}
diff --git a/src/Downloads/Resolution.php b/src/Downloads/Resolution.php
new file mode 100644
index 0000000000..0638ecbb0f
--- /dev/null
+++ b/src/Downloads/Resolution.php
@@ -0,0 +1,19 @@
+<?php
+
+declare(strict_types=1);
+
+namespace phpweb\Downloads;
+
+final class Resolution
+{
+    /**
+     * @param array<string, mixed> $options resolved download options for 
rendering
+     * @param ?string $redirectQuery query string to redirect to (without 
leading
+     *                               '?'), or null when the request should 
render
+     */
+    public function __construct(
+        public readonly array $options,
+        public readonly ?string $redirectQuery = null,
+    ) {
+    }
+}
diff --git a/tests/Unit/Downloads/OptionResolverTest.php 
b/tests/Unit/Downloads/OptionResolverTest.php
new file mode 100644
index 0000000000..7d379e4cc3
--- /dev/null
+++ b/tests/Unit/Downloads/OptionResolverTest.php
@@ -0,0 +1,177 @@
+<?php
+
+declare(strict_types=1);
+
+namespace phpweb\Test\Unit\Downloads;
+
+use phpweb\Downloads\OptionResolver;
+use phpweb\Downloads\Resolution;
+use PHPUnit\Framework;
+
+#[Framework\Attributes\CoversClass(OptionResolver::class)]
+#[Framework\Attributes\CoversClass(Resolution::class)]
+class OptionResolverTest extends Framework\TestCase
+{
+    /**
+     * @var array<string, array{name: string, variants: array<string, string>}>
+     */
+    private const OS_LIST = [
+        'linux' => [
+            'name' => 'Linux',
+            'variants' => [
+                'linux-debian' => 'Debian',
+                'linux-ubuntu' => 'Ubuntu',
+            ],
+        ],
+        'osx' => [
+            'name' => 'macOS',
+            'variants' => [
+                'osx-homebrew' => 'Homebrew',
+                'osx-macports' => 'MacPorts',
+            ],
+        ],
+        'windows' => [
+            'name' => 'Windows',
+            'variants' => [
+                'windows-downloads' => 'ZIP Downloads',
+                'windows-native' => 'Single Line Installer',
+            ],
+        ],
+    ];
+
+    /**
+     * Reproduces the production crash: a bot requesting 
downloads.php?os=<garbage>
+     * previously made $os[$options['os']] null and threw
+     * "array_key_exists(): Argument #2 ($array) must be of type array, null 
given".
+     * It now redirects to the auto-detected results instead.
+     */
+    public function testInvalidOsParameterRedirectsToDefault(): void
+    {
+        $resolution = $this->resolver()->resolve(['os' => 'not-a-real-os'], 
'', '');
+
+        self::assertSame('linux', $resolution->options['os']);
+        self::assertSame('linux-debian', $resolution->options['osvariant']);
+        self::assertSame('os=linux&osvariant=linux-debian&version=default', 
$resolution->redirectQuery);
+    }
+
+    public function testArrayOsParameterRedirectsToDefault(): void
+    {
+        $resolution = $this->resolver()->resolve(['os' => ['linux']], '', '');
+
+        self::assertSame('linux', $resolution->options['os']);
+        self::assertSame('os=linux&osvariant=linux-debian&version=default', 
$resolution->redirectQuery);
+    }
+
+    public function testInvalidOsRedirectsToAutoDetectedResults(): void
+    {
+        $resolution = $this->resolver()->resolve(
+            ['os' => 'error'],
+            '',
+            'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
+        );
+
+        self::assertSame('windows', $resolution->options['os']);
+        
self::assertSame('os=windows&osvariant=windows-downloads&version=default', 
$resolution->redirectQuery);
+    }
+
+    public function testInvalidOsRedirectPreservesRequestedVersion(): void
+    {
+        $resolution = $this->resolver()->resolve(['os' => 'error', 'version' 
=> '8.4'], '', '');
+
+        self::assertSame('os=linux&osvariant=linux-debian&version=8.4', 
$resolution->redirectQuery);
+    }
+
+    public function testValidOsParameterDoesNotRedirect(): void
+    {
+        $resolution = $this->resolver()->resolve(['os' => 'windows'], '', '');
+
+        self::assertNull($resolution->redirectQuery);
+        self::assertSame('windows', $resolution->options['os']);
+        self::assertSame('windows-downloads', 
$resolution->options['osvariant']);
+    }
+
+    public function testAbsentOsParameterDoesNotRedirect(): void
+    {
+        $resolution = $this->resolver()->resolve([], '', '');
+
+        self::assertNull($resolution->redirectQuery);
+        self::assertSame('linux', $resolution->options['os']);
+    }
+
+    public function testValidOsAndVariantAreHonored(): void
+    {
+        $resolution = $this->resolver()->resolve(
+            ['os' => 'osx', 'osvariant' => 'osx-macports'],
+            '',
+            '',
+        );
+
+        self::assertNull($resolution->redirectQuery);
+        self::assertSame('osx', $resolution->options['os']);
+        self::assertSame('osx-macports', $resolution->options['osvariant']);
+    }
+
+    public function testInvalidVariantFallsBackToFirstForThatOs(): void
+    {
+        $resolution = $this->resolver()->resolve(
+            ['os' => 'windows', 'osvariant' => 'linux-debian'],
+            '',
+            '',
+        );
+
+        self::assertNull($resolution->redirectQuery);
+        self::assertSame('windows', $resolution->options['os']);
+        self::assertSame('windows-downloads', 
$resolution->options['osvariant']);
+    }
+
+    public function testVersionDefaultsWhenNotSupplied(): void
+    {
+        $resolution = $this->resolver()->resolve([], '', '');
+
+        self::assertSame('default', $resolution->options['version']);
+    }
+
+    public function testGetParametersArePreserved(): void
+    {
+        $resolution = $this->resolver()->resolve(
+            ['os' => 'linux', 'version' => '8.4', 'source' => 'Y'],
+            '',
+            '',
+        );
+
+        self::assertSame('8.4', $resolution->options['version']);
+        self::assertSame('Y', $resolution->options['source']);
+    }
+
+    public function testDetectsWindowsFromUserAgent(): void
+    {
+        $resolution = $this->resolver()->resolve([], '', 'Mozilla/5.0 (Windows 
NT 10.0; Win64; x64)');
+
+        self::assertSame('windows', $resolution->options['os']);
+    }
+
+    public function testDetectsUbuntuVariantFromUserAgent(): void
+    {
+        $resolution = $this->resolver()->resolve([], '', 'Mozilla/5.0 (X11; 
Ubuntu; Linux x86_64)');
+
+        self::assertSame('linux', $resolution->options['os']);
+        self::assertSame('linux-ubuntu', $resolution->options['osvariant']);
+    }
+
+    public function testExplicitOsParameterOverridesAutoDetection(): void
+    {
+        $resolution = $this->resolver()->resolve(
+            ['os' => 'osx'],
+            '',
+            'Mozilla/5.0 (Windows NT 10.0; Win64; x64)',
+        );
+
+        self::assertNull($resolution->redirectQuery);
+        self::assertSame('osx', $resolution->options['os']);
+    }
+
+    private function resolver(): OptionResolver
+    {
+        return new OptionResolver(self::OS_LIST);
+    }
+}

Reply via email to