From edbc543e32e60df85a921cf1379621c69a4b7250 Mon Sep 17 00:00:00 2001 From: Wleeaf Date: Sun, 25 Oct 2026 18:31:40 +0300 Subject: [PATCH] fix: do not let wildcards select values excluded with q=0 in `Negotiate` A wildcard returned the first supported value even when the header excluded that value with q=0. For example, `Accept-Encoding: gzip;q=0, *` selected gzip, `Accept: application/json;q=0, */*` selected application/json, and `Accept: text/*, text/html;q=0` selected text/html. The same applied to `charset()`, and to `language()` with and without strict locale negotiation. A wildcard now skips the supported values that the header excludes. Without a match, the fallback to the first supported value when `$strictMatch` is false is unchanged. --- system/HTTP/Negotiate.php | 41 +++++++++++++++++++-- tests/system/HTTP/NegotiateTest.php | 25 +++++++++++++ user_guide_src/source/changelogs/v4.7.6.rst | 1 + 3 files changed, 63 insertions(+), 4 deletions(-) diff --git a/system/HTTP/Negotiate.php b/system/HTTP/Negotiate.php index 82123558a273..61e3511ad687 100644 --- a/system/HTTP/Negotiate.php +++ b/system/HTTP/Negotiate.php @@ -184,13 +184,23 @@ protected function getBestMatch( } // if acceptable value is "anything", return the first available + // that the header does not exclude if ($accept['value'] === '*' || $accept['value'] === '*/*') { - return $supported[0]; + foreach ($supported as $available) { + if (! $this->isExcluded($acceptable, $available)) { + return $available; + } + } + + continue; } - // If an acceptable value is supported, return it + // If an acceptable value is supported, return it, unless it was + // matched by a wildcard such as "text/*" and the header excludes it foreach ($supported as $available) { - if ($this->match($accept, $available, $enforceTypes, $matchLocales)) { + if ($this->match($accept, $available, $enforceTypes, $matchLocales) + && (! str_contains($accept['value'], '*') || ! $this->isExcluded($acceptable, $available)) + ) { return $available; } } @@ -233,8 +243,15 @@ protected function getBestLocaleMatch(array $supportedLocales, ?string $header): } // if acceptable value is "anything", return the first available + // that the header does not exclude if ($accept['value'] === '*') { - return $supportedLocales[0]; + foreach ($supportedLocales as $locale) { + if (! $this->isExcluded($acceptable, $locale)) { + return $locale; + } + } + + continue; } // look for exact match @@ -263,6 +280,22 @@ protected function getBestLocaleMatch(array $supportedLocales, ?string $header): return $supportedLocales[0]; } + /** + * Checks whether the header excludes a supported value with q=0. + * + * @param list}> $acceptable + */ + private function isExcluded(array $acceptable, string $supported): bool + { + foreach ($acceptable as $accept) { + if ($accept['q'] === 0.0 && $this->match($accept, $supported)) { + return true; + } + } + + return false; + } + /** * Parses an Accept* header into it's multiple values. * diff --git a/tests/system/HTTP/NegotiateTest.php b/tests/system/HTTP/NegotiateTest.php index 083846c8a1a8..632e86b4f5a6 100644 --- a/tests/system/HTTP/NegotiateTest.php +++ b/tests/system/HTTP/NegotiateTest.php @@ -189,6 +189,31 @@ public function testBestMatchLowQuality(): void $this->assertSame('apple/mac', $this->negotiate->media(['popcorn/cheddar', 'apple/mac'], false)); } + public function testWildcardsSkipExcludedValues(): void + { + $this->request->setHeader('Accept', 'application/json;q=0, */*'); + $this->assertSame('text/html', $this->negotiate->media(['application/json', 'text/html'], true)); + + $this->request->setHeader('Accept', 'text/*, text/html;q=0'); + $this->assertSame('text/plain', $this->negotiate->media(['text/html', 'text/plain'], true)); + + $this->request->setHeader('Accept', '*/*, text/html;q=0'); + $this->assertSame('', $this->negotiate->media(['text/html'], true)); + + $this->request->setHeader('Accept-Charset', 'utf-8;q=0, *'); + $this->assertSame('iso-8859-1', $this->negotiate->charset(['utf-8', 'iso-8859-1'])); + + $this->request->setHeader('Accept-Encoding', 'gzip;q=0, *'); + $this->assertSame('br', $this->negotiate->encoding(['gzip', 'br'])); + + $this->request->setHeader('Accept-Language', 'en;q=0, *'); + $this->assertSame('fr', $this->negotiate->language(['en', 'fr'])); + + config(Feature::class)->strictLocaleNegotiation = true; + + $this->assertSame('de', $this->negotiate->language(['en', 'de'])); + } + public function testBestMatchOnlyLowQuality(): void { $this->request->setHeader('Accept', 'popcorn/cheddar;q=0'); diff --git a/user_guide_src/source/changelogs/v4.7.6.rst b/user_guide_src/source/changelogs/v4.7.6.rst index 75baa337d7b0..37b492dc9753 100644 --- a/user_guide_src/source/changelogs/v4.7.6.rst +++ b/user_guide_src/source/changelogs/v4.7.6.rst @@ -41,6 +41,7 @@ Bugs Fixed - **HTTP:** Fixed a bug in ``ContentSecurityPolicy::setDefaultSrc()`` where passing an array of URIs resulted in an invalid ``'Array'`` directive. - **HTTP:** Fixed stale ``$_REQUEST`` values after ``SiteURIFactory`` normalizes GET parameters, so ``Validation::withRequest()`` reads the normalized values while unrelated REQUEST entries are preserved. - **HTTP:** Fixed a bug where ``IncomingRequest::isSecure()`` did not recognize ``https`` in the ``X-Forwarded-Proto`` header from a trusted proxy when the value contained uppercase letters or surrounding whitespace, or was represented as a single-element array. +- **HTTP:** Fixed a bug where ``Negotiate`` returned a value that the request header excluded with ``q=0`` when the header also contained a wildcard such as ``*``, ``*/*`` or ``text/*``. For example, ``Accept-Encoding: gzip;q=0, *`` no longer selects ``gzip``. - **Security:** Fixed a bug where a valid JSON request body that is not an object (for example a JSON array) was corrupted after CSRF verification when the token was sent via the ``X-CSRF-TOKEN`` header. See the repo's