From 76e7710dc4b500ceec2bfa3fd4f442e3300f3455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Szab=C3=B3?= Date: Tue, 11 Aug 2026 17:16:40 +0300 Subject: [PATCH 1/3] fix: Ignore Content-Type header parameters when checking media type Both HTTP retrievers (`FileGetContents` and `Curl`) captured the raw `Content-Type` header value verbatim (regex `/Content-Type:(\V*)/ims`), charset included. CDNs serve schemas with e.g. `application/json; charset=utf-8`, which failed the exact media type check against `application/json` in confirmMediaType(). RFC 8259 (JSON Data Interchange Format, https://www.rfc-editor.org/info/rfc8259/) does not define a charset parameter for the application/json media type: section 11 says "No 'charset' parameter is defined for this registration. Adding one really has no effect on compliant recipients.". The parameter therefore must not affect the comparison. The retrievers now capture only up to the first `;` (`/Content-Type:([^;\v]*)/ims`), so the `Content-Type` is normalized to its base type before the check. CDN example: https://cdn.jsdelivr.net/gh/WordPress/gutenberg@trunk/schemas/json/theme.json. --- src/JsonSchema/Uri/Retrievers/Curl.php | 2 +- .../Uri/Retrievers/FileGetContents.php | 2 +- tests/Uri/Retrievers/CurlTest.php | 26 +++++++++++++++++++ tests/Uri/Retrievers/FileGetContentsTest.php | 26 +++++++++++++++++++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/JsonSchema/Uri/Retrievers/Curl.php b/src/JsonSchema/Uri/Retrievers/Curl.php index 311f1d34..096667eb 100644 --- a/src/JsonSchema/Uri/Retrievers/Curl.php +++ b/src/JsonSchema/Uri/Retrievers/Curl.php @@ -76,7 +76,7 @@ private function fetchMessageBody($response) */ protected function fetchContentType($response) { - if (0 < preg_match("/Content-Type:(\V*)/ims", $response, $match)) { + if (0 < preg_match("/Content-Type:([^;\v]*)/ims", $response, $match)) { $this->contentType = trim($match[1]); return true; diff --git a/src/JsonSchema/Uri/Retrievers/FileGetContents.php b/src/JsonSchema/Uri/Retrievers/FileGetContents.php index f6056693..fca7c0e0 100644 --- a/src/JsonSchema/Uri/Retrievers/FileGetContents.php +++ b/src/JsonSchema/Uri/Retrievers/FileGetContents.php @@ -97,7 +97,7 @@ private function fetchContentType(array $headers): bool */ protected static function getContentTypeMatchInHeader($header) { - if (0 < preg_match("/Content-Type:(\V*)/ims", $header, $match)) { + if (0 < preg_match("/Content-Type:([^;\v]*)/ims", $header, $match)) { return trim($match[1]); } diff --git a/tests/Uri/Retrievers/CurlTest.php b/tests/Uri/Retrievers/CurlTest.php index c2c88067..8dc7b75b 100644 --- a/tests/Uri/Retrievers/CurlTest.php +++ b/tests/Uri/Retrievers/CurlTest.php @@ -34,6 +34,32 @@ public function testNoContentType(): void self::assertStringEqualsFileCanonicalizing(realpath(__DIR__ . '/../../fixtures/foobar.json'), $result); } + + /** + * @dataProvider contentTypeParameterProvider + */ + public function testContentTypeIgnoresParameters(string $response, string $expected): void + { + $c = new Curl(); + + $reflector = new \ReflectionObject($c); + $fetchContentType = $reflector->getMethod('fetchContentType'); + if (PHP_VERSION_ID < 80100) { + $fetchContentType->setAccessible(true); + } + + $this->assertTrue($fetchContentType->invoke($c, $response)); + $this->assertSame($expected, $c->getContentType()); + } + + public function contentTypeParameterProvider(): array + { + return [ + 'json with charset' => ["Content-Type: application/json; charset=utf-8\r\n\r\n{}", 'application/json'], + 'schema media type with charset' => ["Content-Type: application/schema+json; charset=utf-8\r\n\r\n{}", 'application/schema+json'], + 'multiple parameters' => ["Content-Type: application/json; charset=utf-8; profile=schema\r\n\r\n{}", 'application/json'], + ]; + } } } diff --git a/tests/Uri/Retrievers/FileGetContentsTest.php b/tests/Uri/Retrievers/FileGetContentsTest.php index 70effd7c..9e37defa 100644 --- a/tests/Uri/Retrievers/FileGetContentsTest.php +++ b/tests/Uri/Retrievers/FileGetContentsTest.php @@ -39,6 +39,32 @@ public function testContentType(): void $this->assertFalse($fetchContentType->invoke($res, ['X-Some-Header: whateverValue'])); } + /** + * @dataProvider contentTypeParameterProvider + */ + public function testContentTypeIgnoresParameters(string $header, string $expected): void + { + $res = new FileGetContents(); + + $reflector = new \ReflectionObject($res); + $fetchContentType = $reflector->getMethod('fetchContentType'); + if (PHP_VERSION_ID < 80100) { + $fetchContentType->setAccessible(true); + } + + $this->assertTrue($fetchContentType->invoke($res, [$header])); + $this->assertSame($expected, $res->getContentType()); + } + + public function contentTypeParameterProvider(): array + { + return [ + 'json with charset' => ['Content-Type: application/json; charset=utf-8', 'application/json'], + 'schema media type with charset' => ['Content-Type: application/schema+json; charset=utf-8', 'application/schema+json'], + 'multiple parameters' => ['Content-Type: application/json; charset=utf-8; profile=schema', 'application/json'], + ]; + } + public function testCanHandleHttp301PermanentRedirect(): void { $res = new FileGetContents(); From c05bcf17f4282249e4d176d896f3567b49170b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Szab=C3=B3?= Date: Tue, 11 Aug 2026 22:38:36 +0300 Subject: [PATCH 2/3] fix: Correct Content-Type regex escaping and anchoring The parameter-stripping capture `[^;\v]*` was introduced inside a double-quoted string, where PHP interprets `\v` as a vertical-tab character instead of passing it through to PCRE as the vertical-whitespace escape. For a Content-Type without parameters, the match could therefore run past the CRLF and capture subsequent headers and body. Both retrievers now use a single-quoted, line-anchored pattern '/^Content-Type:([^;\v]*)/im': single-quoting lets the escape reach PCRE verbatim, and anchoring prevents headers like `X-Content-Type` from matching as a content type. The unused `s` modifier is dropped. Tests are consolidated into data-provider driven cases covering the no-parameter form, charset and multiple parameters, and the `X-Content-Type` negative, so both the escaping and anchoring regressions are guarded. --- src/JsonSchema/Uri/Retrievers/Curl.php | 2 +- .../Uri/Retrievers/FileGetContents.php | 2 +- tests/Uri/Retrievers/CurlTest.php | 12 +++++---- tests/Uri/Retrievers/FileGetContentsTest.php | 27 ++++++------------- 4 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/JsonSchema/Uri/Retrievers/Curl.php b/src/JsonSchema/Uri/Retrievers/Curl.php index 096667eb..6387e253 100644 --- a/src/JsonSchema/Uri/Retrievers/Curl.php +++ b/src/JsonSchema/Uri/Retrievers/Curl.php @@ -76,7 +76,7 @@ private function fetchMessageBody($response) */ protected function fetchContentType($response) { - if (0 < preg_match("/Content-Type:([^;\v]*)/ims", $response, $match)) { + if (0 < preg_match('/^Content-Type:([^;\v]*)/im', $response, $match)) { $this->contentType = trim($match[1]); return true; diff --git a/src/JsonSchema/Uri/Retrievers/FileGetContents.php b/src/JsonSchema/Uri/Retrievers/FileGetContents.php index fca7c0e0..84598c3e 100644 --- a/src/JsonSchema/Uri/Retrievers/FileGetContents.php +++ b/src/JsonSchema/Uri/Retrievers/FileGetContents.php @@ -97,7 +97,7 @@ private function fetchContentType(array $headers): bool */ protected static function getContentTypeMatchInHeader($header) { - if (0 < preg_match("/Content-Type:([^;\v]*)/ims", $header, $match)) { + if (0 < preg_match('/^Content-Type:([^;\v]*)/im', $header, $match)) { return trim($match[1]); } diff --git a/tests/Uri/Retrievers/CurlTest.php b/tests/Uri/Retrievers/CurlTest.php index 8dc7b75b..f432a36e 100644 --- a/tests/Uri/Retrievers/CurlTest.php +++ b/tests/Uri/Retrievers/CurlTest.php @@ -38,7 +38,7 @@ public function testNoContentType(): void /** * @dataProvider contentTypeParameterProvider */ - public function testContentTypeIgnoresParameters(string $response, string $expected): void + public function testContentTypeIgnoresParameters(string $response, ?string $expected, bool $matches): void { $c = new Curl(); @@ -48,16 +48,18 @@ public function testContentTypeIgnoresParameters(string $response, string $expec $fetchContentType->setAccessible(true); } - $this->assertTrue($fetchContentType->invoke($c, $response)); + $this->assertSame($matches, $fetchContentType->invoke($c, $response)); $this->assertSame($expected, $c->getContentType()); } public function contentTypeParameterProvider(): array { return [ - 'json with charset' => ["Content-Type: application/json; charset=utf-8\r\n\r\n{}", 'application/json'], - 'schema media type with charset' => ["Content-Type: application/schema+json; charset=utf-8\r\n\r\n{}", 'application/schema+json'], - 'multiple parameters' => ["Content-Type: application/json; charset=utf-8; profile=schema\r\n\r\n{}", 'application/json'], + 'json without parameters' => ["Content-Type: application/json\r\n\r\n{}", 'application/json', true], + 'json with charset' => ["Content-Type: application/json; charset=utf-8\r\n\r\n{}", 'application/json', true], + 'schema media type with charset' => ["Content-Type: application/schema+json; charset=utf-8\r\n\r\n{}", 'application/schema+json', true], + 'multiple parameters' => ["Content-Type: application/json; charset=utf-8; profile=schema\r\n\r\n{}", 'application/json', true], + 'X-Content-Type is not a content type' => ["HTTP/1.1 200 OK\r\nX-Content-Type: text/plain\r\n\r\n{}", null, false], ]; } } diff --git a/tests/Uri/Retrievers/FileGetContentsTest.php b/tests/Uri/Retrievers/FileGetContentsTest.php index 9e37defa..88ec859c 100644 --- a/tests/Uri/Retrievers/FileGetContentsTest.php +++ b/tests/Uri/Retrievers/FileGetContentsTest.php @@ -25,24 +25,10 @@ public function testFetchFile(): void $this->assertNotEmpty($result); } - public function testContentType(): void - { - $res = new FileGetContents(); - - $reflector = new \ReflectionObject($res); - $fetchContentType = $reflector->getMethod('fetchContentType'); - if (PHP_VERSION_ID < 80100) { - $fetchContentType->setAccessible(true); - } - - $this->assertTrue($fetchContentType->invoke($res, ['Content-Type: application/json'])); - $this->assertFalse($fetchContentType->invoke($res, ['X-Some-Header: whateverValue'])); - } - /** * @dataProvider contentTypeParameterProvider */ - public function testContentTypeIgnoresParameters(string $header, string $expected): void + public function testContentTypeIgnoresParameters(string $header, ?string $expected, bool $matches): void { $res = new FileGetContents(); @@ -52,16 +38,19 @@ public function testContentTypeIgnoresParameters(string $header, string $expecte $fetchContentType->setAccessible(true); } - $this->assertTrue($fetchContentType->invoke($res, [$header])); + $this->assertSame($matches, $fetchContentType->invoke($res, [$header])); $this->assertSame($expected, $res->getContentType()); } public function contentTypeParameterProvider(): array { return [ - 'json with charset' => ['Content-Type: application/json; charset=utf-8', 'application/json'], - 'schema media type with charset' => ['Content-Type: application/schema+json; charset=utf-8', 'application/schema+json'], - 'multiple parameters' => ['Content-Type: application/json; charset=utf-8; profile=schema', 'application/json'], + 'json without parameters' => ['Content-Type: application/json', 'application/json', true], + 'json with charset' => ['Content-Type: application/json; charset=utf-8', 'application/json', true], + 'schema media type with charset' => ['Content-Type: application/schema+json; charset=utf-8', 'application/schema+json', true], + 'multiple parameters' => ['Content-Type: application/json; charset=utf-8; profile=schema', 'application/json', true], + 'non-content-type header' => ['X-Some-Header: whateverValue', null, false], + 'X-Content-Type is not a content type' => ['X-Content-Type: text/plain', null, false], ]; } From 64c37e84966775e5c2d369de4a1efd8d253fde40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zolt=C3=A1n=20Szab=C3=B3?= Date: Tue, 11 Aug 2026 23:02:51 +0300 Subject: [PATCH 3/3] test: Rename Content-Type tests to testFetchContentType --- tests/Uri/Retrievers/CurlTest.php | 6 +++--- tests/Uri/Retrievers/FileGetContentsTest.php | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Uri/Retrievers/CurlTest.php b/tests/Uri/Retrievers/CurlTest.php index f432a36e..d47cace6 100644 --- a/tests/Uri/Retrievers/CurlTest.php +++ b/tests/Uri/Retrievers/CurlTest.php @@ -36,9 +36,9 @@ public function testNoContentType(): void } /** - * @dataProvider contentTypeParameterProvider + * @dataProvider contentTypeProvider */ - public function testContentTypeIgnoresParameters(string $response, ?string $expected, bool $matches): void + public function testFetchContentType(string $response, ?string $expected, bool $matches): void { $c = new Curl(); @@ -52,7 +52,7 @@ public function testContentTypeIgnoresParameters(string $response, ?string $expe $this->assertSame($expected, $c->getContentType()); } - public function contentTypeParameterProvider(): array + public function contentTypeProvider(): array { return [ 'json without parameters' => ["Content-Type: application/json\r\n\r\n{}", 'application/json', true], diff --git a/tests/Uri/Retrievers/FileGetContentsTest.php b/tests/Uri/Retrievers/FileGetContentsTest.php index 88ec859c..88d60aa2 100644 --- a/tests/Uri/Retrievers/FileGetContentsTest.php +++ b/tests/Uri/Retrievers/FileGetContentsTest.php @@ -26,9 +26,9 @@ public function testFetchFile(): void } /** - * @dataProvider contentTypeParameterProvider + * @dataProvider contentTypeProvider */ - public function testContentTypeIgnoresParameters(string $header, ?string $expected, bool $matches): void + public function testFetchContentType(string $header, ?string $expected, bool $matches): void { $res = new FileGetContents(); @@ -42,7 +42,7 @@ public function testContentTypeIgnoresParameters(string $header, ?string $expect $this->assertSame($expected, $res->getContentType()); } - public function contentTypeParameterProvider(): array + public function contentTypeProvider(): array { return [ 'json without parameters' => ['Content-Type: application/json', 'application/json', true],