Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 128 additions & 6 deletions system/HTTP/Negotiate.php
Original file line number Diff line number Diff line change
Expand Up @@ -184,13 +184,24 @@ 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, $enforceTypes, $matchLocales)) {
return $available;
}
}

continue;
}

// If an acceptable value is supported, return it
// If an acceptable value is supported, return it, unless the header
// excludes it with a more specific value, such as "text/html;q=0"
// for "text/*"
foreach ($supported as $available) {
if ($this->match($accept, $available, $enforceTypes, $matchLocales)) {
if ($this->match($accept, $available, $enforceTypes, $matchLocales)
&& ! $this->isExcluded($acceptable, $available, $enforceTypes, $matchLocales)
) {
return $available;
}
}
Expand Down Expand Up @@ -233,8 +244,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, false, true)) {
return $locale;
}
}

continue;
}

// look for exact match
Expand All @@ -248,13 +266,17 @@ protected function getBestLocaleMatch(array $supportedLocales, ?string $header):

foreach ($fallbackLocales as $fallbackLocale) {
// look for exact match
if (in_array($fallbackLocale, $supportedLocales, true)) {
if (in_array($fallbackLocale, $supportedLocales, true)
&& ! $this->isExcluded($acceptable, $fallbackLocale, false, true)
) {
return $fallbackLocale;
}

// look for regional locale match
foreach ($supportedLocales as $locale) {
if (str_starts_with($locale, $fallbackLocale . '-')) {
if (str_starts_with($locale, $fallbackLocale . '-')
&& ! $this->isExcluded($acceptable, $locale, false, true)
) {
return $locale;
}
}
Expand All @@ -263,6 +285,106 @@ protected function getBestLocaleMatch(array $supportedLocales, ?string $header):
return $supportedLocales[0];
}

/**
* Checks whether the header excludes a supported value, that is, whether
* the most specific value in the header that matches it has q=0.
*
* @param list<array{value: string, q: float, params: array<string, string>}> $acceptable
* @param bool $enforceTypes If TRUE, the values are media types.
* @param bool $matchLocales If TRUE, the values are language tags.
*/
private function isExcluded(array $acceptable, string $supported, bool $enforceTypes, bool $matchLocales): bool
{
$supported = $this->parseHeader($supported)[0];
$specificity = -1;
$excluded = false;

// The header is sorted by quality, so of two equally specific values,
// the one with the higher quality is used.
foreach ($acceptable as $accept) {
$acceptSpecificity = $this->getMatchSpecificity($accept, $supported, $enforceTypes, $matchLocales);

if ($acceptSpecificity > $specificity) {
$specificity = $acceptSpecificity;
$excluded = $accept['q'] === 0.0;
}
}

return $excluded;
}

/**
* Returns how specifically a header value matches a supported value, or -1
* if it does not match it. A wildcard is the least specific.
*
* A media range matches the media types of its type and subtype that have
* its parameters. "text/html" is more specific than "text/*", which is more
* specific than a full wildcard, and a range is more specific than one of
* the same kind with fewer parameters, so "text/html;level=1" is more
* specific than "text/html", and "text/*;charset=utf-8" than "text/*"
* (RFC 9110, Section 12.5.1). Types, subtypes, parameter names and charset
* values are compared ignoring case (RFC 9110, Section 8.3).
* A language range matches the tags that equal it or start with it followed
* by "-", ignoring case, and is more specific with more subtags
* (RFC 4647, Section 3.3.1). Other values must be equal, ignoring case.
*
* @param array{value: string, q: float, params: array<string, string>} $accept
* @param array{value: string, q: float, params: array<string, string>} $supported
*/
private function getMatchSpecificity(array $accept, array $supported, bool $enforceTypes, bool $matchLocales): int
{
if ($enforceTypes) {
$range = strtolower($accept['value']);
[$aType, $aSubType] = explode('/', $range, 2) + ['', ''];
[$sType, $sSubType] = explode('/', strtolower($supported['value']), 2) + ['', ''];

if ($range === '*' || $range === '*/*') {
$level = 0;
} elseif ($aType === $sType && $aSubType === '*') {
$level = 1;
} elseif ($aType === $sType && $aSubType === $sSubType) {
$level = 2;
} else {
return -1;
}

$aParams = array_change_key_case($accept['params']);
$sParams = array_change_key_case($supported['params']);

if (isset($aParams['charset'], $sParams['charset'])) {
$aParams['charset'] = strtolower($aParams['charset']);
$sParams['charset'] = strtolower($sParams['charset']);
}

foreach ($aParams as $label => $value) {
if (($sParams[$label] ?? null) !== $value) {
return -1;
}
}

// A range has at most as many parameters as the value it matches,
// so it is more specific than any range of a lower level.
return $level * (count($sParams) + 1) + count($aParams);
}

if ($accept['value'] === '*' || $accept['value'] === '*/*') {
return 0;
}

if ($matchLocales) {
$aTag = strtolower($accept['value']);
$sTag = strtolower($supported['value']);

if ($aTag !== $sTag && ! str_starts_with($sTag, $aTag . '-')) {
return -1;
}

return substr_count($aTag, '-') + 1;
}

return strcasecmp($accept['value'], $supported['value']) === 0 ? 1 : -1;
}

/**
* Parses an Accept* header into it's multiple values.
*
Expand Down
119 changes: 119 additions & 0 deletions tests/system/HTTP/NegotiateTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,125 @@ 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 testMediaRangesExcludeMatchingValues(): void
{
$this->request->setHeader('Accept', 'text/*;q=0, */*');
$this->assertSame('application/json', $this->negotiate->media(['text/html', 'application/json'], true));

$this->request->setHeader('Accept', 'text/html;q=0, */*');
$this->assertSame('text/plain', $this->negotiate->media(['text/html;charset=utf-8', 'text/plain'], true));

$this->request->setHeader('Accept', 'text/html;q=0, text/*');
$this->assertSame('text/css', $this->negotiate->media(['text/html;charset=utf-8', 'text/css'], true));

// a range only excludes the values that have its parameters
$this->request->setHeader('Accept', 'text/html;level=1;q=0, */*');
$this->assertSame('text/html', $this->negotiate->media(['text/html;level=1', 'text/html'], true));
}

public function testRangesExcludeValuesIgnoringCase(): void
{
$this->request->setHeader('Accept', 'text/*;q=0, TEXT/HTML;q=0.5, */*;q=0.1');
$this->assertSame('text/html', $this->negotiate->media(['text/html'], true));

$this->request->setHeader('Accept', 'TEXT/*;q=0, */*');
$this->assertSame('application/json', $this->negotiate->media(['text/html', 'application/json'], true));

$this->request->setHeader('Accept', 'text/html;CHARSET="UTF-8";q=0, */*');
$this->assertSame('text/plain', $this->negotiate->media(['text/html;charset=utf-8', 'text/plain'], 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']));
}

public function testMediaRangeParametersAddSpecificity(): void
{
$this->request->setHeader('Accept', 'text/*;charset=utf-8;q=0, text/*');
$this->assertSame(
'text/plain;charset=iso-8859-1',
$this->negotiate->media(['text/html;charset=utf-8', 'text/plain;charset=iso-8859-1'], true),
);

$this->request->setHeader('Accept', '*/*;charset=utf-8;q=0, */*');
$this->assertSame('application/json', $this->negotiate->media(['text/html;charset=utf-8', 'application/json'], true));

// a type/subtype range is more specific than a type/* range with more parameters
$this->request->setHeader('Accept', 'text/html;q=0, text/*;charset=utf-8;level=1');
$this->assertSame(
'text/plain;charset=utf-8;level=1',
$this->negotiate->media(['text/html;charset=utf-8;level=1', 'text/plain;charset=utf-8;level=1'], true),
);
}

public function testMoreSpecificRangesTakePrecedence(): void
{
$this->request->setHeader('Accept', 'text/html, text/*;q=0');
$this->assertSame('text/html', $this->negotiate->media(['text/plain', 'text/html'], true));
$this->assertSame('', $this->negotiate->media(['text/plain'], true));

$this->request->setHeader('Accept', 'text/html;q=0, text/html;level=1;q=0.5, */*');
$this->assertSame('text/html;level=1', $this->negotiate->media(['text/html', 'text/html;level=1'], true));

$this->request->setHeader('Accept-Language', 'en;q=0, en-GB;q=0.5, *');
$this->assertSame('en-GB', $this->negotiate->language(['en-US', 'en-GB', 'fr']));

config(Feature::class)->strictLocaleNegotiation = true;

$this->assertSame('en-GB', $this->negotiate->language(['en-US', 'en-GB']));
}

public function testLanguageRangesExcludeRegionalLocales(): void
{
$this->request->setHeader('Accept-Language', 'en;q=0, *');
$this->assertSame('fr', $this->negotiate->language(['en-US', 'fr']));

// language ranges are case-insensitive and end on a subtag boundary
$this->request->setHeader('Accept-Language', 'zh;q=0, *');
$this->assertSame('zha', $this->negotiate->language(['ZH-Hant', 'zha']));

$this->request->setHeader('Accept-Language', 'en-US, en-GB;q=0');
$this->assertSame('en-AU', $this->negotiate->language(['en-GB', 'en-AU']));

config(Feature::class)->strictLocaleNegotiation = true;

$this->request->setHeader('Accept-Language', 'en;q=0, *');
$this->assertSame('fr', $this->negotiate->language(['en-US', 'fr']));

$this->request->setHeader('Accept-Language', 'zh;q=0, *');
$this->assertSame('zha', $this->negotiate->language(['ZH-Hant', 'zha']));

$this->request->setHeader('Accept-Language', 'en-US, en-GB;q=0');
$this->assertSame('en-AU', $this->negotiate->language(['en-GB', 'en-AU']));
}

public function testBestMatchOnlyLowQuality(): void
{
$this->request->setHeader('Accept', 'popcorn/cheddar;q=0');
Expand Down
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.7.6.rst
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Bugs Fixed
- **HTTP:** Fixed a bug where ``IncomingRequest`` did not read the request body when PHP's ``post_max_size`` was set to ``0`` or ``-1`` (unlimited).
- **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::media()`` threw an ``ErrorException`` when the ``Accept`` header contained a value without a subtype, such as ``json`` or an empty list element. Such values are now ignored.
- **HTTP:** Fixed a bug where ``Negotiate`` could select a value that the request header excluded with ``q=0`` through a wildcard or a broader range. A ``q=0`` range now excludes every value it matches, such as ``text/html`` and ``text/html;charset=utf-8`` for ``text/*;q=0``, or ``en-US`` for ``en;q=0``, unless a more specific range in the header accepts the value. For example, ``Accept-Encoding: gzip;q=0, *`` no longer selects ``gzip``.
- **HTTP:** Fixed stale ``$_REQUEST`` values after ``SiteURIFactory`` normalizes GET parameters, so ``Validation::withRequest()`` reads the normalized values while unrelated REQUEST entries are preserved.
- **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.

Expand Down
Loading