From c21c3409d762958a412b46215ba3d019972c3110 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 00:43:14 +0000 Subject: [PATCH] feat(api): harden version header parsing Handle Accept-Version parameters and comma-separated Accept values when extracting API versions. Co-Authored-By: Virgil --- .../src/Front/Api/Middleware/ApiVersion.php | 15 +++++-- .../tests/Feature/ApiVersionParsingTest.php | 45 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) create mode 100644 src/php/tests/Feature/ApiVersionParsingTest.php diff --git a/src/php/src/Front/Api/Middleware/ApiVersion.php b/src/php/src/Front/Api/Middleware/ApiVersion.php index b334afd..5bd073a 100644 --- a/src/php/src/Front/Api/Middleware/ApiVersion.php +++ b/src/php/src/Front/Api/Middleware/ApiVersion.php @@ -188,7 +188,9 @@ class ApiVersion return null; } - // Strip 'v' prefix if present + // Strip 'v' prefix and any optional parameters if present. + $header = trim($header); + $header = explode(';', $header, 2)[0]; $version = ltrim($header, 'vV'); if (is_numeric($version)) { @@ -207,9 +209,14 @@ class ApiVersion { $accept = $request->header('Accept', ''); - // Match vendor media type: application/vnd.{name}.v{n}+json - if (preg_match('#application/vnd\.[^.]+\.v(\d+)\+#', $accept, $matches)) { - return (int) $matches[1]; + foreach (preg_split('/\s*,\s*/', $accept, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $mediaType) { + // Strip media-type parameters before matching the vendor suffix. + $mediaType = explode(';', trim($mediaType), 2)[0]; + + // Match vendor media type: application/vnd.{name}.v{n}+json + if (preg_match('#^application/vnd\.[^.]+\.v(\d+)\+#i', $mediaType, $matches)) { + return (int) $matches[1]; + } } return null; diff --git a/src/php/tests/Feature/ApiVersionParsingTest.php b/src/php/tests/Feature/ApiVersionParsingTest.php new file mode 100644 index 0000000..59b5656 --- /dev/null +++ b/src/php/tests/Feature/ApiVersionParsingTest.php @@ -0,0 +1,45 @@ +headers->set('Accept-Version', 'v2; q=1.0'); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('X-API-Version'))->toBe('2'); + expect($request->attributes->get('api_version'))->toBe(2); + expect($request->attributes->get('api_version_string'))->toBe('v2'); +}); + +it('resolves the api version from a vendor accept header inside a list', function () { + $middleware = new ApiVersion(); + $request = Request::create('/api/users', 'GET'); + $request->headers->set( + 'Accept', + 'text/html;q=0.8, application/json, application/vnd.hosthub.v2+json; charset=utf-8' + ); + + $response = $middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('X-API-Version'))->toBe('2'); + expect($request->attributes->get('api_version'))->toBe(2); + expect($request->attributes->get('api_version_string'))->toBe('v2'); +});