[PHP-WEBMASTER] [web-php] master: Fix downloads.php crash on invalid os parameter (#1968)

[email protected] (Joe Ferguson via GitHub)
Newsgroups php.webmaster
Message-ID <[email protected]>
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);
+    }
+}
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.