diff --git a/.changelog/authorization-scheme.md b/.changelog/authorization-scheme.md new file mode 100644 index 0000000..13c4682 --- /dev/null +++ b/.changelog/authorization-scheme.md @@ -0,0 +1,6 @@ +--- +category: fixed +type: patch +--- + +Match HTTP authentication schemes case-insensitively without changing credential values. diff --git a/.changelog/http-negotiation.md b/.changelog/http-negotiation.md new file mode 100644 index 0000000..cee216a --- /dev/null +++ b/.changelog/http-negotiation.md @@ -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. diff --git a/src/Header/Accept.php b/src/Header/Accept.php index 610c31b..252ba21 100644 --- a/src/Header/Accept.php +++ b/src/Header/Accept.php @@ -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, @@ -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']; } } - 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 $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 */ 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 = '/(?[^,;]+)(?:;[^,]*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 + */ + 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; } /** diff --git a/src/Header/Authorization.php b/src/Header/Authorization.php index 804dbac..ff4b4ca 100644 --- a/src/Header/Authorization.php +++ b/src/Header/Authorization.php @@ -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; } diff --git a/src/Header/ContentEncoding.php b/src/Header/ContentEncoding.php index b8c56b7..815eaee 100644 --- a/src/Header/ContentEncoding.php +++ b/src/Header/ContentEncoding.php @@ -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 = '/(?[a-z*-]+)(?:;\s*q=(?[0-9.]+))?/i'; + $pattern = '/(?[a-z*-]+)(?:[ \t]*;[ \t]*q=(?[0-9.]+))?/i'; if (preg_match_all($pattern, $acceptEncodingHeader, $matches, \PREG_SET_ORDER)) { foreach ($matches as $match) { @@ -149,7 +153,7 @@ public static function isSupported(self $encoding, string $acceptEncodingHeader) return $preferences['*'] > 0.0; } - return true; + return self::Identity === $encoding; } /** diff --git a/tests/Header/AcceptTest.php b/tests/Header/AcceptTest.php index ad3f235..46593ce 100644 --- a/tests/Header/AcceptTest.php +++ b/tests/Header/AcceptTest.php @@ -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], 'simple match: json' => ['application/json', $allSupported, Accept::ApplicationJson], 'simple match: html' => ['text/html', $allSupported, Accept::TextHtml], 'quality factor: json preferred' => [ diff --git a/tests/Header/AuthorizationTest.php b/tests/Header/AuthorizationTest.php index 25866f2..8e45b2c 100644 --- a/tests/Header/AuthorizationTest.php +++ b/tests/Header/AuthorizationTest.php @@ -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 [ diff --git a/tests/Header/ContentEncodingTest.php b/tests/Header/ContentEncodingTest.php index a1c097b..1f2f4eb 100644 --- a/tests/Header/ContentEncodingTest.php +++ b/tests/Header/ContentEncodingTest.php @@ -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)); @@ -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' => [ @@ -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],