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
6 changes: 6 additions & 0 deletions .changelog/authorization-scheme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: fixed
type: patch
---

Match HTTP authentication schemes case-insensitively without changing credential values.
6 changes: 6 additions & 0 deletions .changelog/http-negotiation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
category: fixed
type: patch
---

Honor per-representation Accept specificity and zero-quality exclusions, and distinguish absent Accept-Encoding from empty or restrictive fields.
126 changes: 91 additions & 35 deletions src/Header/Accept.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ enum Accept: string
* Determines the best matching content type from the client's Accept header.
*
* This method SHALL apply proper HTTP content negotiation rules, including:
* - Quality factors (q-values), which MUST be sorted in descending order.
* - The most specific matching range determines each representation's quality.
* - Zero-quality representations are excluded before ranking by quality.
* - Specificity preference, where more explicit types MUST be preferred over
* wildcard types when q-values are equal.
* If a match cannot be found in the list of supported server types,
Expand All @@ -55,64 +56,119 @@ public static function getBestMatch(string $acceptHeader, array $supportedTypes)
{
$clientPreferences = self::parseHeader($acceptHeader);

foreach ($clientPreferences as $preference) {
foreach ($supportedTypes as $supported) {
if (self::matches($preference['type'], $supported->value)) {
return $supported;
}
$bestMatch = null;
$bestQuality = 0.0;
$bestSpecificity = -1;

foreach ($supportedTypes as $supported) {
$effective = self::findEffectivePreference($clientPreferences, $supported->value);

if (null !== $effective && $effective['q'] > 0.0
&& ($effective['q'] > $bestQuality
|| ($effective['q'] === $bestQuality && $effective['specificity'] > $bestSpecificity))) {
$bestMatch = $supported;
$bestQuality = $effective['q'];
$bestSpecificity = $effective['specificity'];
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return null;
return $bestMatch;
}

/**
* Parses the Accept header string into a sorted list of client preferences.
* Finds the most specific applicable range before considering its quality.
*
* This method MUST:
* - Extract MIME types from the header string.
* - Parse quality factors (q-values), defaulting to 1.0 when omitted.
* - Calculate specificity, which SHALL be used as a secondary sort criterion.
* - Sort results by descending q-value and descending specificity.
* The resulting array MUST represent the client’s explicit and wildcard
* preferences in the correct HTTP negotiation order.
* @param array<int, array{type: string, q: float, specificity: int}> $preferences
*
* @param string $header the raw Accept header string
* @return array{type: string, q: float, specificity: int}|null
*/
private static function findEffectivePreference(array $preferences, string $supported): ?array
{
$effective = null;
foreach ($preferences as $preference) {
if (self::matches($preference['type'], $supported)
&& (null === $effective || $preference['specificity'] > $effective['specificity'])) {
$effective = $preference;
}
}

return $effective;
}

/**
* Parses media ranges in field order. These enum cases represent bare media
* types, so ranges requiring media parameters do not match them.
*
* @return array<int, array{type: string, q: float, specificity: int}>
*/
private static function parseHeader(string $header): array
{
$preferences = [];
$ranges = self::splitRanges($header);

foreach ($ranges as $range) {
$parts = explode(';', $range);
$type = strtolower(trim(array_shift($parts), " \t"));
$q = 1.0;
$hasMediaParameters = false;

foreach ($parts as $parameter) {
[$name, $value] = array_pad(explode('=', $parameter, 2), 2, '');
if ('q' === strtolower(trim($name, " \t"))) {
$value = trim($value, " \t");
if (!preg_match('/^(?:0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?)$/D', $value)) {
continue 2;
}
$q = (float) $value;
break;
} elseif ('' !== trim($name, " \t")) {
$hasMediaParameters = true;
}
}

// Captures a type and optional q-value while ignoring other parameters.
$pattern = '/(?<type>[^,;]+)(?:;[^,]*q=(?<q>[0-9.]+))?/';

if (preg_match_all($pattern, $header, $matches, \PREG_SET_ORDER)) {
foreach ($matches as $match) {
$type = trim($match['type'], " \t");
$q = isset($match['q']) && '' !== $match['q'] ? (float) $match['q'] : 1.0;

if (!$hasMediaParameters) {
$preferences[] = [
'type' => $type,
'q' => $q,
'type' => $type,
'q' => $q,
'specificity' => self::calculateSpecificity($type),
];
}
}

usort(
$preferences,
static function (array $a, array $b): int {
if ($a['q'] !== $b['q']) {
return $b['q'] <=> $a['q'];
}
return $preferences;
}

return $b['specificity'] <=> $a['specificity'];
/**
* Splits only list commas outside quoted strings, honoring quoted-pair escapes.
*
* @return list<string>
*/
private static function splitRanges(string $header): array
{
$ranges = [];
$start = 0;
$quoted = false;
$escaped = false;
$length = strlen($header);

for ($index = 0; $index < $length; ++$index) {
$character = $header[$index];
if ($escaped) {
$escaped = false;
continue;
}
);
if ($quoted && '\\' === $character) {
$escaped = true;
} elseif ('"' === $character) {
$quoted = !$quoted;
} elseif (',' === $character && !$quoted) {
$ranges[] = substr($header, $start, $index - $start);
$start = $index + 1;
}
}

return $preferences;
$ranges[] = substr($header, $start);
return $ranges;
}

/**
Expand Down
8 changes: 7 additions & 1 deletion src/Header/Authorization.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,13 @@ public static function parse(string $header): ?AuthorizationCredential

[$scheme, $credentials] = $parts;

$authScheme = self::tryFrom($scheme);
$authScheme = null;
foreach (self::cases() as $candidate) {
if (0 === strcasecmp($candidate->value, $scheme)) {
$authScheme = $candidate;
break;
}
}
if (null === $authScheme) {
return null;
}
Expand Down
16 changes: 10 additions & 6 deletions src/Header/ContentEncoding.php
Original file line number Diff line number Diff line change
Expand Up @@ -113,18 +113,22 @@ enum ContentEncoding: string
* - Support wildcards (“*”) as fallback.
* - Recognize “x-gzip” as an alias for the gzip encoding.
* If an encoding is not explicitly listed and no wildcard is present,
* the encoding SHOULD be considered acceptable unless the header
* exclusively lists explicit rejections.
* only identity is acceptable. Pass null for an absent header (all codings
* allowed); an empty string represents a present empty header (identity only).
*
* @param self $encoding the encoding to evaluate
* @param string $acceptEncodingHeader the raw `Accept-Encoding` header value
* @param string|null $acceptEncodingHeader the raw `Accept-Encoding` header value
*
* @return bool true if the encoding is acceptable according to negotiation rules
*/
public static function isSupported(self $encoding, string $acceptEncodingHeader): bool
public static function isSupported(self $encoding, ?string $acceptEncodingHeader): bool
{
if (null === $acceptEncodingHeader) {
return true;
}

$preferences = [];
$pattern = '/(?<name>[a-z*-]+)(?:;\s*q=(?<q>[0-9.]+))?/i';
$pattern = '/(?<name>[a-z*-]+)(?:[ \t]*;[ \t]*q=(?<q>[0-9.]+))?/i';

if (preg_match_all($pattern, $acceptEncodingHeader, $matches, \PREG_SET_ORDER)) {
foreach ($matches as $match) {
Expand All @@ -149,7 +153,7 @@ public static function isSupported(self $encoding, string $acceptEncodingHeader)
return $preferences['*'] > 0.0;
}

return true;
return self::Identity === $encoding;
}

/**
Expand Down
18 changes: 18 additions & 0 deletions tests/Header/AcceptTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,24 @@ public static function providerBestMatch(): array
$allSupported = [Accept::ApplicationJson, Accept::ApplicationXml, Accept::TextHtml];

return [
'legacy extension does not override quality' => ['text/html;q=0.5;note="a;q=0", application/json;q=0.2', $allSupported, Accept::TextHtml],
'escaped quote in other range does not suppress json' => ['application/json, text/html;profile="a\"b"', [Accept::ApplicationJson], Accept::ApplicationJson],
'escaped backslash before closing quote' => ['text/html;profile="a\\\\", application/json', [Accept::ApplicationJson], Accept::ApplicationJson],
'uppercase media type' => ['APPLICATION/JSON', $allSupported, Accept::ApplicationJson],
'equal quality favors specific representation' => ['*/*;q=0.5, text/html;q=0.5', $allSupported, Accept::TextHtml],
'quoted comma parameter is not a new range' => ['text/html;profile="a,application/json"', $allSupported, null],
'zero excluded' => ['application/json;q=0', $allSupported, null],
'specific exclusion beats wildcard' => ['application/json;q=0, */*;q=1', [Accept::ApplicationJson, Accept::TextHtml], Accept::TextHtml],
'specific quality beats wildcard quality' => ['text/*;q=1, text/plain;q=0.1, application/json;q=0.5', [Accept::TextPlain, Accept::ApplicationJson], Accept::ApplicationJson],
'case insensitive type and quality name' => ['APPLICATION/JSON;Q=0, TEXT/HTML', $allSupported, Accept::TextHtml],
'wildcard exclusion' => ['*/*;q=0', $allSupported, null],
'specific acceptance overrides wildcard exclusion' => ['*/*;q=0, application/json;q=0.5', $allSupported, Accept::ApplicationJson],
'parameterized range does not match bare representation' => ['text/html;level=1', [Accept::TextHtml], null],
'parameterized exclusion does not exclude bare representation' => ['text/html;level=1;q=0, text/html;q=1', [Accept::TextHtml], Accept::TextHtml],
'invalid quality above one skips range' => ['application/json;q=2, text/html;q=0.5', $allSupported, Accept::TextHtml],
'non numeric quality skips range' => ['application/json;q=abc', $allSupported, null],
'padded quality competes with higher quality' => ["\ttext/html ; Q=0.5, application/json;q=0.8", $allSupported, Accept::ApplicationJson],
'empty header' => ['', $allSupported, null],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'simple match: json' => ['application/json', $allSupported, Accept::ApplicationJson],
'simple match: html' => ['text/html', $allSupported, Accept::TextHtml],
'quality factor: json preferred' => [
Expand Down
21 changes: 20 additions & 1 deletion tests/Header/AuthorizationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,27 @@ public function testFromRequest(array $headers, ?array $expected): void
}

/**
* @return array
* Scheme capitalization must never alter opaque credential bytes.
*/
#[DataProvider('providerParse')]
public function testSchemeCaseDoesNotChangeCredentials(string $header, ?array $expected): void
{
if (null === $expected) {
self::assertNull(Authorization::parse($header));
return;
}

[$scheme, $credentials] = explode(' ', $header, 2);
$mixed = match ($scheme) {
'Basic' => 'bAsIc',
'Bearer' => 'bEaReR',
default => lcfirst($scheme),
};
foreach ([strtolower($scheme), strtoupper($scheme), ucfirst(strtolower($scheme)), $mixed] as $variant) {
$this->testParse($variant . ' ' . $credentials, $expected);
}
}

public static function providerParse(): array
{
return [
Expand Down
14 changes: 11 additions & 3 deletions tests/Header/ContentEncodingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public function valueWithCaseWillReturnExpectedValue(ContentEncoding $case, stri
#[Test]
public function isSupportedWithEncodingAndHeaderWillReturnExpected(
ContentEncoding $encoding,
string $header,
?string $header,
bool $expected
): void {
self::assertSame($expected, ContentEncoding::isSupported($encoding, $header));
Expand Down Expand Up @@ -83,6 +83,8 @@ public static function providerCases(): array
public static function providerIsSupported(): array
{
return [
'space before quality exclusion' => [ContentEncoding::Gzip, 'gzip ;q=0', false],
'tab before quality exclusion' => [ContentEncoding::Gzip, "gzip\t; q=0", false],
'explicitly accepted' => [ContentEncoding::Gzip, 'gzip, deflate, br', true],
'another explicitly accepted' => [ContentEncoding::Brotli, 'gzip, deflate, br', true],
'explicitly rejected' => [
Expand All @@ -98,8 +100,14 @@ public static function providerIsSupported(): array
],
'not mentioned but wildcard accepts' => [ContentEncoding::Brotli, 'gzip, *;q=0.5', true],
'not mentioned and wildcard rejects' => [ContentEncoding::Brotli, 'gzip, *;q=0', false],
'not mentioned and no wildcard (implicit accept)' => [ContentEncoding::Deflate, 'gzip, br', true],
'empty header (implicit accept)' => [ContentEncoding::Gzip, '', true],
'unlisted coding is not accepted' => [ContentEncoding::Deflate, 'gzip, br', false],
'empty header rejects compression' => [ContentEncoding::Gzip, '', false],
'missing header allows compression' => [ContentEncoding::Gzip, null, true],
'empty header accepts identity' => [ContentEncoding::Identity, '', true],
'wildcard rejects identity' => [ContentEncoding::Identity, '*;q=0', false],
'explicit identity overrides wildcard rejection' => [ContentEncoding::Identity, 'identity;q=0.5, *;q=0', true],
'explicit exclusion overrides wildcard acceptance' => [ContentEncoding::Gzip, 'gzip;q=0, *;q=1', false],
'case insensitive quality name' => [ContentEncoding::Gzip, 'GZIP;Q=0', false],
'wildcard only' => [ContentEncoding::Zstd, '*', true],
'identity is always accepted unless q=0' => [ContentEncoding::Identity, 'gzip, br', true],
'identity explicitly rejected' => [ContentEncoding::Identity, 'identity;q=0', false],
Expand Down
Loading