Skip to content

fix: do not let wildcards select values excluded with q=0 in Negotiate - #10661

Open
wleeaf wants to merge 1 commit into
codeigniter4:developfrom
wleeaf:negotiate-wildcard-excluded-values
Open

wleeaf wants to merge 1 commit into
codeigniter4:developfrom
wleeaf:negotiate-wildcard-excluded-values

Conversation

@wleeaf

@wleeaf wleeaf commented Oct 25, 2026 •

Copy link
Copy Markdown

Description
When the header holds a wildcard, Negotiate returns the first supported value, even if the same header excludes that value with q=0. In RFC 9110, a weight of 0 means "not acceptable" (section 12.4.2), and a more specific entry overrides a wildcard (sections 12.5.1 and 12.5.3). Examples:

  • Accept-Encoding: gzip;q=0, * with encoding(['gzip', 'br']) returns gzip.
  • Accept: application/json;q=0, */* with media(['application/json', 'text/html'], true) returns application/json.
  • Accept: text/*, text/html;q=0 with media(['text/html', 'text/plain'], true) returns text/html.
  • charset() behaves the same way, and so does language(), with and without $strictLocaleNegotiation. For example, Accept-Language: en;q=0, * gives en.

getBestMatch() and getBestLocaleMatch() now skip values that the header excludes. A private isExcluded() applies the most specific value in the header that matches a supported value (RFC 9110, section 12.5.1): media ranges match by type, subtype and their own parameters, so text/*;q=0 excludes text/html and text/html;q=0 excludes text/html;charset=utf-8, and language ranges use RFC 4647 basic filtering, so en;q=0 excludes en-US. A more specific range with a positive weight still wins: text/html, text/*;q=0 selects text/html, and en;q=0, en-GB;q=0.5, * selects en-GB. Wildcards, broader matches and the strict locale fallbacks all skip excluded values. When nothing matches, $strictMatch = false still falls back to the first supported value, as documented and as testBestMatchOnlyLowQuality expects.

testWildcardsSkipExcludedValues, testMediaRangesExcludeMatchingValues, testMoreSpecificRangesTakePrecedence and testLanguageRangesExcludeRegionalLocales in tests/system/HTTP/NegotiateTest.php cover media(), charset(), encoding() and language() in both locale modes, with positive controls. They fail without the change.

tests/system/HTTP/ passes. php-cs-fixer, PHPStan and Rector (dry run) report nothing for the changed files.

Checklist:

  • Securely signed commits
  • Component(s) with PHPDoc blocks, only if necessary or adds value (without duplication)
  • Unit testing, with >80% coverage
  • User guide updated
  • Conforms to style guide

@carson-codeigniter4 carson-codeigniter4 Bot added the bug Verified issues on the current code behavior or pull requests that will fix them label Oct 25, 2026

@michalsn michalsn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread system/HTTP/Negotiate.php Outdated
Comment on lines +288 to +297
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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This calls match() with its default flags, so text/*;q=0 does not exclude text/html. Also, exact parameter-set matching lets text/html;charset=utf-8 escape text/html;q=0. Both still select excluded representations through a positive wildcard. Add regression coverage and apply media-range matching with specificity preserved.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isExcluded() no longer goes through match(). It finds the most specific header value that matches the supported value and excludes the value when that one has q=0 (RFC 9110, section 12.5.1). A media range matches by type and subtype, and its own parameters must be present in the supported value, so text/*;q=0 now excludes text/html and text/html;q=0 excludes text/html;charset=utf-8, while text/html, text/*;q=0 still selects text/html and text/html;level=1;q=0.5 beats text/html;q=0. testMediaRangesExcludeMatchingValues and testMoreSpecificRangesTakePrecedence cover these, with positive controls (78e6fb1).

Comment thread system/HTTP/Negotiate.php
@wleeaf
wleeaf force-pushed the negotiate-wildcard-excluded-values branch 2 times, most recently from 04f1211 to 78e6fb1 Compare October 26, 2026 11:48
@wleeaf

wleeaf commented Oct 26, 2026

Copy link
Copy Markdown
Author

Rebased onto develop. The description is updated for the wider scope.

Comment thread system/HTTP/Negotiate.php Outdated
[$aType, $aSubType] = explode('/', $accept['value'], 2) + ['', ''];
[$sType, $sSubType] = explode('/', $supported['value'], 2) + ['', ''];

if ($aType !== $sType || ($aSubType !== '*' && $aSubType !== $sSubType)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Media types are case-insensitive. With Accept: text/*;q=0, TEXT/HTML;q=0.5, */*;q=0.1, media(['text/html'], true) now returns an empty string instead of text/html. Please use case-insensitive comparisons and add a regression test.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exclusion check now compares media types, subtypes and parameter names ignoring case, and charset values too; other parameter values stay case-sensitive (RFC 9110, section 8.3.1). Accept-Charset and Accept-Encoding values are also compared ignoring case there. With Accept: text/*;q=0, TEXT/HTML;q=0.5, */*;q=0.1, media(['text/html'], true) returns text/html again. testRangesExcludeValuesIgnoringCase covers it, along with TEXT/*;q=0, CHARSET="UTF-8";q=0, Accept-Charset: UTF-8;q=0, * and Accept-Encoding: GZIP;q=0, * (ab58f75).

Comment thread system/HTTP/Negotiate.php Outdated
}
}

return $aSubType === '*' ? 1 : 2 + count($accept['params']);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With Accept: text/*;charset=utf-8;q=0, text/* and supported values media(['text/html;charset=utf-8', 'text/plain;charset=iso-8859-1'], true), negotiation still selects the excluded UTF-8 HTML instead of the ISO-8859-1 plain text.

Both ranges receive specificity 1, so the broader positive range wins. The range with the charset parameter should be more specific. Could you include parameters in wildcard specificity and add a regression test?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Parameters now count in the specificity of every media range. A range is more specific than one of the same kind with fewer parameters, and a type/subtype range stays more specific than any type/* range, which in turn stays more specific than any */* range: the specificity is level * (count(supported params) + 1) + count(range params), and a matching range can't have more parameters than the supported value. With Accept: text/*;charset=utf-8;q=0, text/*, your example now selects text/plain;charset=iso-8859-1, and */*;charset=utf-8;q=0, */* excludes UTF-8 values the same way. testMediaRangeParametersAddSpecificity covers both, and checks that text/html;q=0 still takes precedence over text/*;charset=utf-8;level=1 (ab58f75).

@wleeaf
wleeaf force-pushed the negotiate-wildcard-excluded-values branch from 78e6fb1 to d2dba59 Compare October 26, 2026 23:34
A wildcard or a broader range could select a supported value even when
the header excluded that value with q=0. For example,
`Accept-Encoding: gzip;q=0, *` selected gzip, `Accept: text/*;q=0, */*`
selected text/html, `Accept: text/html;q=0, */*` selected
text/html;charset=utf-8, and `Accept-Language: en;q=0, *` selected en-US,
with and without strict locale negotiation.

A supported value is now excluded when the most specific value in the
header that matches it has q=0 (RFC 9110, Section 12.5.1), so
`text/html, text/*;q=0` still selects text/html. A media range matches the
media types of its type and subtype that have its parameters, and a
language range matches the tags that equal it or start with it followed
by "-" (RFC 4647, Section 3.3.1). A type/subtype range is more specific
than a type/* range, which is more specific than */*, and a range with
more parameters is more specific than one of the same kind with fewer, so
`text/*;charset=utf-8;q=0, text/*` excludes text/html;charset=utf-8.
These comparisons ignore case, except for parameter values other than
charset (RFC 9110, Section 8.3). Wildcards, broader matches and the strict
locale fallbacks skip excluded values. Without a match, the fallback to
the first supported value when `$strictMatch` is false is unchanged.
@wleeaf
wleeaf force-pushed the negotiate-wildcard-excluded-values branch from d2dba59 to ab58f75 Compare October 26, 2026 23:35
@wleeaf

wleeaf commented Oct 26, 2026

Copy link
Copy Markdown
Author

Rebased onto develop again.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Verified issues on the current code behavior or pull requests that will fix them

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants