From d151a4829312f4cea27d98789a539706e796fec1 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 26 Oct 2026 15:12:43 +0600 Subject: [PATCH 01/12] test(request): cover header access from server globals and REST Pins header()/headers()/hasHeader(): any name style resolves, CONTENT_* headers are exposed without the HTTP_ prefix, values stay unsanitized, and a hydrated WP_REST_Request takes precedence over $_SERVER. Assisted-By: AI --- tests/Http/RequestTest.php | 62 ++++++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 18 ++++++++--- 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index ed48cd3..18340a2 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -147,4 +147,66 @@ public function testRequestSuccessfulValidationReturnsValidatedValues(): void assertSameValue(['name' => 'Ada'], $validated, 'successful validation result changed'); assertSameValue(null, WpKitTestState::$sentJson, 'successful validation emitted an error response'); } + + public function testRequestHeaderIsReadFromServerByAnyNameStyle(): void + { + $_SERVER['HTTP_X_SETUP_TOKEN'] = 'abc'; + $request = new Request(); + + assertSameValue('abc', $request->header('X-Setup-Token'), 'dashed header name not resolved'); + assertSameValue('abc', $request->header('x_setup_token'), 'canonical header name not resolved'); + assertTest($request->hasHeader('x-setup-token'), 'hasHeader missed a present header'); + } + + public function testRequestContentHeadersAreExposedWithoutTheHttpPrefix(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/json'; + $_SERVER['CONTENT_LENGTH'] = '42'; + $request = new Request(); + + assertSameValue('application/json', $request->header('Content-Type'), 'content type header missing'); + assertSameValue('42', $request->header('Content-Length'), 'content length header missing'); + } + + public function testRequestMissingHeaderFallsBackToTheDefault(): void + { + $request = new Request(); + + assertSameValue(null, $request->header('X-Missing'), 'missing header was not null'); + assertSameValue('fallback', $request->header('X-Missing', 'fallback'), 'missing header ignored the default'); + assertSameValue(false, $request->hasHeader('X-Missing'), 'hasHeader reported a missing header'); + } + + public function testRequestHeadersAreKeyedByCanonicalName(): void + { + $_SERVER['HTTP_ACCEPT'] = 'application/json'; + $_SERVER['HTTP_X_TRACE'] = 't-1'; + $_SERVER['HTTP_X_ARRAY'] = ['not', 'a', 'string']; + $_SERVER['REQUEST_METHOD'] = 'GET'; + $request = new Request(); + + assertSameValue( + ['accept' => 'application/json', 'x_trace' => 't-1'], + $request->headers(), + 'header map changed', + ); + } + + public function testRequestHeaderValuesAreReturnedUnsanitized(): void + { + $_SERVER['HTTP_X_SIGNATURE'] = "t=1,v1=ab+/= "; + $request = new Request(); + + assertSameValue("t=1,v1=ab+/= ", $request->header('X-Signature'), 'header value was altered'); + } + + public function testRequestRESTRequestHeadersTakePrecedenceOverServer(): void + { + $_SERVER['HTTP_X_SIGNATURE'] = 'from-server'; + $request = new Request(); + $request->setApiRequest(new WP_REST_Request([], [], [], [], ['x_signature' => ['from-rest'], 'accept' => ['a', 'b']])); + + assertSameValue('from-rest', $request->header('X-Signature'), 'REST header did not win'); + assertSameValue('a, b', $request->header('Accept'), 'multi-value REST header not joined'); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 0322ad7..d9f5a8a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -101,12 +101,20 @@ class WP_REST_Request private $route; - public function __construct($body = [], $query = [], $route = [], $json = []) + private $headers; + + public function __construct($body = [], $query = [], $route = [], $json = [], $headers = []) { - $this->body = $body; - $this->query = $query; - $this->route = $route; - $this->json = $json; + $this->body = $body; + $this->query = $query; + $this->route = $route; + $this->json = $json; + $this->headers = $headers; + } + + public function get_headers() + { + return $this->headers; } public function get_body_params() From 74f2f37f40eb73ea4c5034905b36538db264b2e9 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 26 Oct 2026 15:12:46 +0600 Subject: [PATCH 02/12] feat(request): read request headers via InteractsWithHeaders Request had no header access outside REST (where __call proxies to WP_REST_Request::get_header), so static and AJAX routes had to read $_SERVER directly. The InteractsWithHeaders trait adds header(), headers() and hasHeader(): names are canonicalized like WP_REST_Request (lowercase, underscores), values come from the hydrated REST request when present, else from $_SERVER (unslashed, not sanitized, so tokens and signatures survive intact). Public methods stay untyped so consumer subclasses can override them. Assisted-By: AI --- src/Http/Request/InteractsWithHeaders.php | 82 +++++++++++++++++++++++ src/Http/Request/Request.php | 1 + 2 files changed, 83 insertions(+) create mode 100644 src/Http/Request/InteractsWithHeaders.php diff --git a/src/Http/Request/InteractsWithHeaders.php b/src/Http/Request/InteractsWithHeaders.php new file mode 100644 index 0000000..943f83b --- /dev/null +++ b/src/Http/Request/InteractsWithHeaders.php @@ -0,0 +1,82 @@ + + */ + public function headers() + { + if (isset($this->rest) && $this->rest instanceof WP_REST_Request) { + return array_map(static fn ($values) => implode(', ', (array) $values), (array) $this->rest->get_headers()); + } + + return $this->serverHeaders(); + } + + /** + * Returns one header by any name style ("X-Token" or "x_token"), or the default when absent. + * + * @return null|string + */ + public function header(string $name, ?string $default = null) + { + return $this->headers()[self::canonicalHeaderName($name)] ?? $default; + } + + /** + * Reports whether the request carries the header. + * + * @return bool + */ + public function hasHeader(string $name) + { + return \array_key_exists(self::canonicalHeaderName($name), $this->headers()); + } + + /** + * Collects headers from $_SERVER, which WordPress slashes. + * + * @return array + */ + private function serverHeaders(): array + { + $headers = []; + foreach ($_SERVER as $key => $value) { + if (!\is_string($value)) { + continue; + } + + if (str_starts_with($key, 'HTTP_')) { + $headers[self::canonicalHeaderName(substr($key, 5))] = wp_unslash($value); + } elseif ($value !== '' && \in_array($key, self::$_unprefixedHeaders, true)) { + // FastCGI passes CONTENT_* as '' when the request has no body. + $headers[self::canonicalHeaderName($key)] = wp_unslash($value); + } + } + + return $headers; + } + + /** + * Normalizes a header name the way WP_REST_Request does. + */ + private static function canonicalHeaderName(string $name): string + { + return str_replace('-', '_', strtolower($name)); + } +} diff --git a/src/Http/Request/Request.php b/src/Http/Request/Request.php index 7fb16bc..29e7d4b 100644 --- a/src/Http/Request/Request.php +++ b/src/Http/Request/Request.php @@ -16,6 +16,7 @@ class Request extends Validator implements ArrayAccess, JsonSerializable { + use InteractsWithHeaders; use IpTool; protected $rest; From 2898289d1552bfda7e8ccb75ba09f7d0280eaefd Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 26 Oct 2026 15:15:44 +0600 Subject: [PATCH 03/12] test(request): cover raw body access A FakePhpInputStream fixture stands in for the php:// wrapper so the tests drive the real file_get_contents('php://input') path: the body is returned byte for byte (JSON and form payloads), is empty without a body, is read from the stream once, and a hydrated WP_REST_Request's body takes precedence. Assisted-By: AI --- tests/Fixtures/FakePhpInputStream.php | 88 +++++++++++++++++++++++++++ tests/Http/RequestTest.php | 56 +++++++++++++++++ tests/bootstrap.php | 10 ++- 3 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/FakePhpInputStream.php diff --git a/tests/Fixtures/FakePhpInputStream.php b/tests/Fixtures/FakePhpInputStream.php new file mode 100644 index 0000000..9998b0c --- /dev/null +++ b/tests/Fixtures/FakePhpInputStream.php @@ -0,0 +1,88 @@ +_position = 0; + + return true; + } + + /** + * Reads the next chunk of the fake body. + */ + public function stream_read(int $count): string + { + $chunk = substr(self::$_content, $this->_position, $count); + $this->_position += \strlen($chunk); + + return $chunk; + } + + /** + * Reports whether the fake body is exhausted. + */ + public function stream_eof(): bool + { + return $this->_position >= \strlen(self::$_content); + } + + /** + * Returns empty stat data; file_get_contents() only needs the call to succeed. + */ + public function stream_stat(): array + { + return []; + } +} diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index 18340a2..9b4aed9 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -3,6 +3,7 @@ namespace BitApps\WPKit\Tests\Http; use BitApps\WPKit\Http\Request\Request; +use BitApps\WPKit\Tests\Fixtures\FakePhpInputStream; use BitApps\WPKit\Tests\TestCase; use WP_REST_Request; use WpKitTestState; @@ -49,6 +50,14 @@ public function getRoute() */ final class RequestTest extends TestCase { + /** Puts the built-in php:// wrapper back after a test faked php://input. */ + protected function tearDown(): void + { + FakePhpInputStream::uninstall(); + + parent::tearDown(); + } + public function testConsumerSubclassMayOverrideAccessorsWithUntypedSignatures(): void { $request = new ContractOverridingRequest(); @@ -209,4 +218,51 @@ public function testRequestRESTRequestHeadersTakePrecedenceOverServer(): void assertSameValue('from-rest', $request->header('X-Signature'), 'REST header did not win'); assertSameValue('a, b', $request->header('Accept'), 'multi-value REST header not joined'); } + + public function testRequestContentReturnsTheRawBodyByteForByte(): void + { + $payload = "{\"b\": 2, \"a\":\"\\u00e9\"}\n"; + $_SERVER['CONTENT_TYPE'] = 'application/json'; + FakePhpInputStream::install($payload); + $request = new Request(); + + assertSameValue($payload, $request->content(), 'raw body was altered'); + assertSameValue(['b' => 2, 'a' => "\u{e9}"], $request->body(), 'decoded body changed'); + } + + public function testRequestContentReturnsNonJsonPayloadsVerbatim(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; + FakePhpInputStream::install('a=1&b=%20+x'); + + assertSameValue('a=1&b=%20+x', (new Request())->content(), 'form payload was altered'); + } + + public function testRequestContentIsEmptyWithoutABody(): void + { + FakePhpInputStream::install(''); + + assertSameValue('', (new Request())->content(), 'empty body was not an empty string'); + } + + public function testRequestReadsTheInputStreamOnce(): void + { + $_SERVER['CONTENT_TYPE'] = 'application/json'; + FakePhpInputStream::install('{"a":1}'); + $request = new Request(); + + $request->content(); + $request->content(); + + assertSameValue(1, FakePhpInputStream::$opens, 'php://input was read more than once'); + } + + public function testRequestContentPrefersTheHydratedRESTBody(): void + { + FakePhpInputStream::install('from-input'); + $request = new Request(); + $request->setApiRequest(new WP_REST_Request([], [], [], [], [], 'from-rest')); + + assertSameValue('from-rest', $request->content(), 'REST raw body did not win'); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index d9f5a8a..f5e9801 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -103,13 +103,21 @@ class WP_REST_Request private $headers; - public function __construct($body = [], $query = [], $route = [], $json = [], $headers = []) + private $rawBody; + + public function __construct($body = [], $query = [], $route = [], $json = [], $headers = [], $rawBody = '') { $this->body = $body; $this->query = $query; $this->route = $route; $this->json = $json; $this->headers = $headers; + $this->rawBody = $rawBody; + } + + public function get_body() + { + return $this->rawBody; } public function get_headers() From 17a5fe3177fc8bbcec411b7707dc03dd7b69f593 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 26 Oct 2026 15:15:47 +0600 Subject: [PATCH 04/12] feat(request): expose the raw body via content() body() only offers the decoded payload, which cannot be used to verify an HMAC computed over the exact bytes sent. content() returns the raw body: the hydrated REST request's when present, else php://input read once and cached. setBody() now decodes from content(), so the stream is not read twice. Assisted-By: AI --- src/Http/Request/Request.php | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/Http/Request/Request.php b/src/Http/Request/Request.php index 29e7d4b..a5a580e 100644 --- a/src/Http/Request/Request.php +++ b/src/Http/Request/Request.php @@ -29,6 +29,11 @@ class Request extends Validator implements ArrayAccess, JsonSerializable protected $body = []; + /** + * Raw php://input, read at most once. + */ + private ?string $_content = null; + /** * Undocumented function. */ @@ -99,6 +104,20 @@ public function body() return $this->body; } + /** + * Returns the raw request body exactly as sent, e.g. for signature checks. + * + * @return string + */ + public function content() + { + if (isset($this->rest)) { + return (string) $this->rest->get_body(); + } + + return $this->_content ??= (string) file_get_contents('php://input'); + } + public function method() { return sanitize_text_field($_SERVER['REQUEST_METHOD']); @@ -247,7 +266,7 @@ protected function setBody($body = []) !str_contains($this->contentType(), 'form-data') && !str_contains($this->contentType(), 'x-www-form-urlencoded') ) { - $this->body = JSON::maybeDecode(file_get_contents('php://input'), JsonConfig::decodeAsArray()); + $this->body = JSON::maybeDecode($this->content(), JsonConfig::decodeAsArray()); } if (!empty($_POST)) { From a18d5709f1f8e8c4c9a0c599e61c7a14177fda1e Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 26 Oct 2026 15:27:18 +0600 Subject: [PATCH 05/12] test(request): require sanitized headers, method and content type header()/headers() must return sanitized values with rawHeader() for signature checks; method() must sanitize, upper-case and tolerate a missing REQUEST_METHOD; contentType() must strip only a leading "application/" (ltrim's character mask turned application/pdf into "df" and text/plain into "ext/plain"). The sanitize_text_field stub now mirrors core so these assertions are meaningful. Assisted-By: AI --- tests/Http/RequestTest.php | 57 ++++++++++++++++++++++++++++++++++++-- tests/bootstrap.php | 17 +++++++++++- 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/tests/Http/RequestTest.php b/tests/Http/RequestTest.php index 9b4aed9..8e3afcc 100644 --- a/tests/Http/RequestTest.php +++ b/tests/Http/RequestTest.php @@ -201,12 +201,23 @@ public function testRequestHeadersAreKeyedByCanonicalName(): void ); } - public function testRequestHeaderValuesAreReturnedUnsanitized(): void + public function testRequestHeaderValuesAreSanitizedByDefault(): void { - $_SERVER['HTTP_X_SIGNATURE'] = "t=1,v1=ab+/= "; + $_SERVER['HTTP_X_NAME'] = " Ada\n\tLovelace %41 "; + $_SERVER['HTTP_X_TRACE'] = 't-1'; + $request = new Request(); + + assertSameValue('Ada Lovelace', $request->header('X-Name'), 'header() returned an unsanitized value'); + assertSameValue(['x_name' => 'Ada Lovelace', 'x_trace' => 't-1'], $request->headers(), 'headers() returned unsanitized values'); + } + + public function testRequestRawHeaderReturnsTheExactValueForVerification(): void + { + $_SERVER['HTTP_X_SIGNATURE'] = "t=1,v1=ab+/= %41"; $request = new Request(); - assertSameValue("t=1,v1=ab+/= ", $request->header('X-Signature'), 'header value was altered'); + assertSameValue("t=1,v1=ab+/= %41", $request->rawHeader('X-Signature'), 'raw header value was altered'); + assertSameValue('fallback', $request->rawHeader('X-Missing', 'fallback'), 'raw header ignored the default'); } public function testRequestRESTRequestHeadersTakePrecedenceOverServer(): void @@ -216,6 +227,7 @@ public function testRequestRESTRequestHeadersTakePrecedenceOverServer(): void $request->setApiRequest(new WP_REST_Request([], [], [], [], ['x_signature' => ['from-rest'], 'accept' => ['a', 'b']])); assertSameValue('from-rest', $request->header('X-Signature'), 'REST header did not win'); + assertSameValue('from-rest', $request->rawHeader('X-Signature'), 'REST raw header did not win'); assertSameValue('a, b', $request->header('Accept'), 'multi-value REST header not joined'); } @@ -265,4 +277,43 @@ public function testRequestContentPrefersTheHydratedRESTBody(): void assertSameValue('from-rest', $request->content(), 'REST raw body did not win'); } + + public function testRequestMethodIsSanitizedAndUpperCased(): void + { + $_SERVER['REQUEST_METHOD'] = ' patch '; + + assertSameValue('PATCH', (new Request())->method(), 'request method was not sanitized'); + } + + public function testRequestMethodIsEmptyWhenTheServerOmitsIt(): void + { + unset($_SERVER['REQUEST_METHOD']); + + assertSameValue('', (new Request())->method(), 'missing request method was not empty'); + } + + public function testRequestContentTypeStripsOnlyTheApplicationPrefix(): void + { + $cases = [ + 'application/json; charset=utf-8' => 'json; charset=utf-8', + 'application/pdf' => 'pdf', + 'Application/XML' => 'XML', + 'text/plain' => 'text/plain', + 'multipart/form-data; boundary=x' => 'multipart/form-data; boundary=x', + ' text/csv ' => 'text/csv', + ]; + + foreach ($cases as $header => $expected) { + $_SERVER['CONTENT_TYPE'] = $header; + + assertSameValue($expected, (new Request())->contentType(), "content type mangled: {$header}"); + } + } + + public function testRequestContentTypeIsEmptyWhenTheServerOmitsIt(): void + { + unset($_SERVER['CONTENT_TYPE']); + + assertSameValue('', (new Request())->contentType(), 'missing content type was not empty'); + } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index f5e9801..8e76f4a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -313,9 +313,24 @@ function wp_ob_end_flush_all() } } +/** + * Mirrors core _sanitize_text_fields(): strips tags, collapses whitespace, drops %XX octets. + */ function sanitize_text_field($value) { - return $value; + if (\is_array($value) || \is_object($value)) { + return ''; + } + + $filtered = trim(preg_replace('/[\r\n\t ]+/', ' ', strip_tags((string) $value))); + + $found = false; + while (preg_match('/%[a-f0-9]{2}/i', $filtered, $match)) { + $filtered = str_replace($match[0], '', $filtered); + $found = true; + } + + return $found ? trim(preg_replace('/ +/', ' ', $filtered)) : $filtered; } function sanitize_url($value) From f2bc9d24a17bcac432efde9dd5f27681a66220e0 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 26 Oct 2026 15:27:20 +0600 Subject: [PATCH 06/12] fix(request): sanitize headers, method and content type by default header() and headers() now return sanitize_text_field() output; rawHeader() keeps the exact bytes for HMAC/token verification and says so. method() unslashes, sanitizes and upper-cases REQUEST_METHOD and returns '' when it is absent. contentType() reads the sanitized Content-Type header and removes a case-insensitive "application/" prefix instead of ltrim()'s character mask. Assisted-By: AI --- src/Http/Request/InteractsWithHeaders.php | 40 ++++++++++++++++++----- src/Http/Request/Request.php | 24 ++++++++++++-- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/Http/Request/InteractsWithHeaders.php b/src/Http/Request/InteractsWithHeaders.php index 943f83b..f0f43e7 100644 --- a/src/Http/Request/InteractsWithHeaders.php +++ b/src/Http/Request/InteractsWithHeaders.php @@ -15,27 +15,35 @@ trait InteractsWithHeaders private static array $_unprefixedHeaders = ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5']; /** - * Returns every request header keyed by canonical name (lowercase, underscores); values are raw, so validate them. + * Returns every request header, sanitized, keyed by canonical name (lowercase, underscores). * * @return array */ public function headers() { - if (isset($this->rest) && $this->rest instanceof WP_REST_Request) { - return array_map(static fn ($values) => implode(', ', (array) $values), (array) $this->rest->get_headers()); - } - - return $this->serverHeaders(); + return array_map('sanitize_text_field', $this->rawHeaders()); } /** - * Returns one header by any name style ("X-Token" or "x_token"), or the default when absent. + * Returns one sanitized header by any name style ("X-Token" or "x_token"), or the default when absent. * * @return null|string */ public function header(string $name, ?string $default = null) { - return $this->headers()[self::canonicalHeaderName($name)] ?? $default; + $value = $this->rawHeader($name); + + return $value === null ? $default : sanitize_text_field($value); + } + + /** + * Returns one header exactly as sent, for signature or token checks only; never output or store it unsanitized. + * + * @return null|string + */ + public function rawHeader(string $name, ?string $default = null) + { + return $this->rawHeaders()[self::canonicalHeaderName($name)] ?? $default; } /** @@ -45,7 +53,21 @@ public function header(string $name, ?string $default = null) */ public function hasHeader(string $name) { - return \array_key_exists(self::canonicalHeaderName($name), $this->headers()); + return \array_key_exists(self::canonicalHeaderName($name), $this->rawHeaders()); + } + + /** + * Collects unsanitized headers from the REST request when hydrated, else from the server globals. + * + * @return array + */ + private function rawHeaders(): array + { + if (isset($this->rest) && $this->rest instanceof WP_REST_Request) { + return array_map(static fn ($values) => implode(', ', (array) $values), (array) $this->rest->get_headers()); + } + + return $this->serverHeaders(); } /** diff --git a/src/Http/Request/Request.php b/src/Http/Request/Request.php index a5a580e..9551e53 100644 --- a/src/Http/Request/Request.php +++ b/src/Http/Request/Request.php @@ -19,6 +19,8 @@ class Request extends Validator implements ArrayAccess, JsonSerializable use InteractsWithHeaders; use IpTool; + private const APPLICATION_MEDIA_PREFIX = 'application/'; + protected $rest; protected array $attributes = []; @@ -118,14 +120,32 @@ public function content() return $this->_content ??= (string) file_get_contents('php://input'); } + /** + * Returns the sanitized, upper-cased HTTP method, or '' when the server omits it. + * + * @return string + */ public function method() { - return sanitize_text_field($_SERVER['REQUEST_METHOD']); + if (!isset($_SERVER['REQUEST_METHOD'])) { + return ''; + } + + return strtoupper(sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD']))); } + /** + * Returns the sanitized Content-Type with a leading "application/" removed (e.g. "json"). + * + * @return string + */ public function contentType() { - return isset($_SERVER['CONTENT_TYPE']) ? ltrim($_SERVER['CONTENT_TYPE'], 'application/') : ''; + $type = (string) $this->header('Content-Type', ''); + + return stripos($type, self::APPLICATION_MEDIA_PREFIX) === 0 + ? substr($type, \strlen(self::APPLICATION_MEDIA_PREFIX)) + : $type; } public function setApiRequest(WP_REST_Request $request) From 8ea28abcfb37ab0985644595a3a98ab75ae6ca6f Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 26 Oct 2026 15:48:09 +0600 Subject: [PATCH 07/12] test(router): cover static routing on real-world installs Pins StaticRouter behaviour the PaymentForm endpoints need: - match WordPress's parsed path, so subdirectory installs and /index.php/ permalinks route; strip the home path from the request URI when WP has not parsed one (plain permalinks) - dispatch on template_redirect by default, or on parse_request when opted in via dispatchOnParseRequest() - skip the init flush under plain permalinks and retry missing rules at most once per window instead of on every request - register the page root rule only for an empty-path route Adds WP and home_url() stubs and defaults the test site to pretty permalinks at the domain root. Assisted-By: AI --- tests/Router/RewriteRuleSetTest.php | 9 ++- tests/Router/StaticRoutingTest.php | 106 +++++++++++++++++++++++++++- tests/bootstrap.php | 18 ++++- 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/tests/Router/RewriteRuleSetTest.php b/tests/Router/RewriteRuleSetTest.php index 911a10e..da78411 100644 --- a/tests/Router/RewriteRuleSetTest.php +++ b/tests/Router/RewriteRuleSetTest.php @@ -43,7 +43,6 @@ public function testAddPathBuildsOneCompleteRuleAndQueryVars(): void assertSameValue( [ - '^landing/?$' => 'index.php?pagename=landing', '^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]', ], $set->rules(), @@ -63,6 +62,14 @@ public function testRulesAccumulateAcrossPaths(): void assertSameValue(['a', 'b'], $set->queryVars(), 'query vars did not accumulate'); } + public function testEmptyPathRegistersOnlyThePageRootRule(): void + { + $set = new RewriteRuleSet('landing'); + $set->addPath('/'); + + assertSameValue(['^landing/?$' => 'index.php?pagename=landing'], $set->rules(), 'empty path did not map to the page root'); + } + public function testEmptySetHasNoRulesOrQueryVars(): void { $set = new RewriteRuleSet('landing'); diff --git a/tests/Router/StaticRoutingTest.php b/tests/Router/StaticRoutingTest.php index e277081..45c666f 100644 --- a/tests/Router/StaticRoutingTest.php +++ b/tests/Router/StaticRoutingTest.php @@ -92,7 +92,7 @@ public function testRewriteRulesMapPageAndParameterSegmentsToQueryVars(): void do_action('init'); $rules = WpKitTestState::$rewriteRules; - assertSameValue('index.php?pagename=landing', $rules['^landing/?$']['query'] ?? null, 'page rewrite rule changed'); + assertTest(!isset($rules['^landing/?$']), 'page root rule was registered without an empty-path route'); assertSameValue('index.php?pagename=landing&id=$matches[1]', $rules['^landing/entries/([^/]+)/?$']['query'] ?? null, 'parameter rewrite rule changed'); assertTest(!isset($rules['^landing/entries/?$']), 'undeclared intermediate route was registered'); } @@ -307,6 +307,110 @@ public function testPersistedRewriteCheckRequiresEveryRule(): void ); } + public function testEmptyPathRouteRegistersThePageRootRule(): void + { + $this->makeTransport(['/' => static fn () => 'home']); + + do_action('init'); + + assertSameValue('index.php?pagename=landing', WpKitTestState::$rewriteRules['^landing/?$']['query'] ?? null, 'empty-path route lost the page root rule'); + } + + public function testSubdirectoryInstallMatchesTheParsedWordPressPath(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + WpKitTestState::$options['home'] = 'https://example.test/blog'; + $_SERVER['REQUEST_URI'] = '/blog/landing/entries/42'; + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = 'landing/entries/42'; + + do_action('template_redirect'); + + assertSameValue('page-entry 42', apply_filters('the_content', 'page-'), 'subdirectory request did not match'); + } + + public function testPathInfoPermalinkMatchesTheParsedWordPressPath(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + $_SERVER['REQUEST_URI'] = '/index.php/landing/entries/7'; + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = 'landing/entries/7'; + + do_action('template_redirect'); + + assertSameValue('page-entry 7', apply_filters('the_content', 'page-'), 'index.php permalink request did not match'); + } + + public function testWithoutAParsedPathTheHomePathIsStrippedFromTheRequestUri(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + WpKitTestState::$options['home'] = 'https://example.test/blog'; + $_SERVER['REQUEST_URI'] = '/blog/landing/entries/42?preview=1'; + $GLOBALS['wp'] = new \WP(); + + do_action('template_redirect'); + + assertSameValue('page-entry 42', apply_filters('the_content', 'page-'), 'home path was not stripped from the request URI'); + } + + public function testRequestsAreDispatchedOnTemplateRedirectByDefault(): void + { + $calls = 0; + $this->makeTransport(['ping' => static function () use (&$calls) { + ++$calls; + + return 'pong'; + }]); + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = 'landing/ping'; + + do_action('parse_request', $GLOBALS['wp']); + assertSameValue(0, $calls, 'default router dispatched on parse_request'); + + do_action('template_redirect'); + assertSameValue(1, $calls, 'default router did not dispatch on template_redirect'); + } + + public function testEarlyDispatchHandlesRequestsOnParseRequestOnly(): void + { + $calls = 0; + $this->makeTransport(['ping' => static function () use (&$calls) { + ++$calls; + + return 'pong'; + }])->dispatchOnParseRequest(); + $wp = new \WP(); + $wp->request = 'landing/ping'; + + do_action('parse_request', $wp); + do_action('template_redirect'); + + assertSameValue(1, $calls, 'early dispatch did not run exactly once on parse_request'); + } + + public function testInitSkipsFlushWithPlainPermalinks(): void + { + $this->makeTransport(['entries/{id}' => static fn () => 'x']); + WpKitTestState::$options['permalink_structure'] = ''; + + do_action('init'); + + assertSameValue(0, WpKitTestState::$rewriteFlushes, 'rules were flushed although plain permalinks never use them'); + } + + public function testInitRetriesMissingRulesAtMostOncePerRetryWindow(): void + { + $this->makeTransport(['entries/{id}' => static fn () => 'x']); + + do_action('init'); + do_action('init'); + assertSameValue(1, WpKitTestState::$rewriteFlushes, 'rules that never persist were flushed on every request'); + + WpKitTestState::$currentTime = '2024-01-02 03:10:05'; + do_action('init'); + assertSameValue(2, WpKitTestState::$rewriteFlushes, 'missing rules were not retried after the window'); + } + private function makeTransport(array $routes = []): StaticRouter { new Router('static', 'landing', null); diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 8e76f4a..f9482b2 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -90,6 +90,14 @@ final class WpDieException extends RuntimeException { } +if (!class_exists('WP')) { + /** Minimal WP: only the parsed request path the static router reads. */ + class WP + { + public $request = ''; + } +} + if (!class_exists('WP_REST_Request')) { class WP_REST_Request { @@ -230,8 +238,11 @@ function resetWpKitTestState() 'time_format' => 'H:i:s', 'timezone_string' => 'UTC', 'gmt_offset' => 0, - 'rewrite_rules' => [], + 'rewrite_rules' => [], + 'permalink_structure' => '/%postname%/', + 'home' => 'https://example.test', ]; + unset($GLOBALS['wp']); WpKitTestState::$currentTime = '2024-01-02 03:04:05'; WpKitTestState::$capabilities = []; WpKitTestState::$sites = []; @@ -525,6 +536,11 @@ function add_rewrite_rule($regex, $query, $position) WpKitTestState::$rewriteRules[$regex] = compact('query', 'position'); } +function home_url($path = '') +{ + return rtrim((string) get_option('home'), '/') . '/' . ltrim($path, '/'); +} + function flush_rewrite_rules() { ++WpKitTestState::$rewriteFlushes; From 851a4974c916e916cfa7d09e545351940cb7a645 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 26 Oct 2026 15:48:11 +0600 Subject: [PATCH 08/12] fix(router): make StaticRouter work on subdirectory installs - Match routes against WP::$request (home path and index.php already stripped) and fall back to the request URI minus the home path, instead of the raw REQUEST_URI, which never matched on subdirectory installs. - dispatchOnParseRequest() opts a router into dispatching before the main query runs, so self-responding routes (callbacks, webhooks) are never flagged 404 or caught by redirect_canonical. Page-content routes keep the template_redirect default. - maybeFlushRewriteRules() no longer flushes under plain permalinks and retries missing rules at most every 5 minutes via a transient, instead of flushing (and rewriting .htaccess) on every request. - RewriteRuleSet adds the ^page/?$ rule only for an empty-path route, so routes no longer claim the bare page URL by accident. Assisted-By: AI --- src/Http/Router/RewriteRuleSet.php | 9 +++-- src/Http/Router/StaticRouter.php | 63 ++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 8 deletions(-) diff --git a/src/Http/Router/RewriteRuleSet.php b/src/Http/Router/RewriteRuleSet.php index ab8eff8..b8f8c05 100644 --- a/src/Http/Router/RewriteRuleSet.php +++ b/src/Http/Router/RewriteRuleSet.php @@ -18,14 +18,15 @@ public function __construct(string $pageName) $this->_pageName = trim($pageName, '/'); } + /** + * Adds the rule for one route path; only an empty path maps the page root itself. + */ public function addPath(string $path): void { - if (empty($this->_rules)) { - $this->_rules["^{$this->_pageName}/?$"] = "index.php?pagename={$this->_pageName}"; - } - $path = trim($path, '/'); if ($path === '') { + $this->_rules["^{$this->_pageName}/?$"] = "index.php?pagename={$this->_pageName}"; + return; } diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index 8c938fe..d6fa8c9 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -4,6 +4,7 @@ use BitApps\WPKit\Http\RequestType; use BitApps\WPKit\Http\Response; +use WP; if (!\defined('ABSPATH')) { exit; @@ -11,6 +12,13 @@ class StaticRouter { + /** + * Seconds to wait before re-flushing rules that did not persist (e.g. filtered out by another plugin). + */ + private const FLUSH_RETRY_SECONDS = 300; + + private const FLUSH_RETRY_TRANSIENT_PREFIX = 'wpkit_static_flush_'; + private Router $router; private string $pageName; @@ -59,10 +67,27 @@ public function addQueryVars($vars): array return array_merge($vars, $this->queryVars); } - public function handleRequest(): void + /** + * Dispatches on parse_request instead of template_redirect, before the main query runs or a 404 is + * flagged; for routes that send their own response and exit (callbacks, webhooks), not page content. + */ + public function dispatchOnParseRequest(): self + { + remove_action('template_redirect', [$this, 'handleRequest']); + add_action('parse_request', [$this, 'handleRequest']); + + return $this; + } + + /** + * Runs the first route matching the request; parse_request passes the WP object, template_redirect nothing. + * + * @param mixed $wp + */ + public function handleRequest($wp = null): void { - $requestPath = sanitize_url((string) parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH)); - $method = strtoupper(sanitize_text_field($_SERVER['REQUEST_METHOD'] ?? 'GET')); + $requestPath = $this->requestPath($wp instanceof WP ? $wp : ($GLOBALS['wp'] ?? null)); + $method = strtoupper(sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD'] ?? 'GET'))); foreach ($this->router->getRoutes() as $route) { if (!\in_array($method, $route->getMethods(), true)) { continue; @@ -122,12 +147,26 @@ public static function isRewriteExists(?string $path = '', ?array $rewriteRules return true; } + /** + * Flushes when our rules are missing from the persisted set, at most once per retry window, and never + * under plain permalinks where rewrite rules are unused. + */ public function maybeFlushRewriteRules(): void { - if (empty($this->rewriteRules) || self::isRewriteExists('', $this->rewriteRules)) { + if ( + empty($this->rewriteRules) + || !get_option('permalink_structure') + || self::isRewriteExists('', $this->rewriteRules) + ) { return; } + $retryKey = self::FLUSH_RETRY_TRANSIENT_PREFIX . $this->pageName; + if (get_transient($retryKey) !== false) { + return; + } + + set_transient($retryKey, 1, self::FLUSH_RETRY_SECONDS); flush_rewrite_rules(); } @@ -140,6 +179,22 @@ private function registerHooks(string $activationHook, string $deactivationHook) add_action('template_redirect', [$this, 'handleRequest']); } + /** + * Request path relative to the site root: WP's parsed path when set (strips home and index.php), + * else the request URI minus the home path (plain permalinks). + */ + private function requestPath(?WP $wp): string + { + if ($wp !== null && \is_string($wp->request) && $wp->request !== '') { + return sanitize_url('/' . trim($wp->request, '/')); + } + + $path = (string) wp_parse_url(sanitize_url(wp_unslash($_SERVER['REQUEST_URI'] ?? '')), PHP_URL_PATH); + $homePath = rtrim((string) wp_parse_url(home_url(), PHP_URL_PATH), '/'); + + return $homePath !== '' && str_starts_with($path, $homePath . '/') ? substr($path, \strlen($homePath)) : $path; + } + private function processRoutes(): void { $ruleSet = new RewriteRuleSet($this->pageName); From 2fe0f847ec5c9edd0116c9ee873f86a113d9b206 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 26 Oct 2026 15:49:49 +0600 Subject: [PATCH 09/12] test(router): cover StaticRouter edge cases Adds cases for template_redirect's '' argument falling back to the global WP, slashes around WP::$request, the request-URI fallback with no WP object, home-path stripping only on a segment boundary, early dispatch ignoring unmatched paths and enforcing the route method, per-page retry windows, activation flushing inside a retry window, persisted rules skipping later flushes, and the root rule alongside other routes. Each new case fails against a matching mutation of StaticRouter. Assisted-By: AI --- tests/Router/StaticRoutingTest.php | 130 +++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/tests/Router/StaticRoutingTest.php b/tests/Router/StaticRoutingTest.php index 45c666f..ff23ab1 100644 --- a/tests/Router/StaticRoutingTest.php +++ b/tests/Router/StaticRoutingTest.php @@ -411,6 +411,136 @@ public function testInitRetriesMissingRulesAtMostOncePerRetryWindow(): void assertSameValue(2, WpKitTestState::$rewriteFlushes, 'missing rules were not retried after the window'); } + public function testTemplateRedirectEmptyArgumentFallsBackToTheGlobalWp(): void + { + $transport = $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + WpKitTestState::$options['home'] = 'https://example.test/blog'; + $_SERVER['REQUEST_URI'] = '/blog/landing/entries/9'; + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = 'landing/entries/9'; + + $transport->handleRequest(''); + + assertSameValue('page-entry 9', apply_filters('the_content', 'page-'), "template_redirect's '' argument hid the global WP"); + } + + public function testParsedPathWithSurroundingSlashesStillMatches(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + $GLOBALS['wp'] = new \WP(); + $GLOBALS['wp']->request = '/landing/entries/5/'; + + do_action('template_redirect'); + + assertSameValue('page-entry 5', apply_filters('the_content', 'page-'), 'slashes around the parsed path broke matching'); + } + + public function testRequestUriFallbackWorksWithoutAWpObject(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + WpKitTestState::$options['home'] = 'https://example.test/blog/'; + $_SERVER['REQUEST_URI'] = '/blog/landing/entries/3'; + + do_action('template_redirect'); + + assertSameValue('page-entry 3', apply_filters('the_content', 'page-'), 'fallback needed a WP object'); + } + + public function testHomePathIsStrippedOnlyOnASegmentBoundary(): void + { + $this->makeTransport(['entries/{id}' => static fn ($id) => 'entry ' . $id]); + WpKitTestState::$options['home'] = 'https://example.test/la'; + $_SERVER['REQUEST_URI'] = '/landing/entries/3'; + + do_action('template_redirect'); + + assertSameValue('page-entry 3', apply_filters('the_content', 'page-'), 'home path "/la" was stripped from the "/landing" segment'); + } + + public function testEarlyDispatchIgnoresUnmatchedRequests(): void + { + $calls = 0; + $this->makeTransport(['ping' => static function () use (&$calls) { + ++$calls; + + return 'pong'; + }])->dispatchOnParseRequest(); + $wp = new \WP(); + $wp->request = 'landing/other'; + + do_action('parse_request', $wp); + + assertSameValue(0, $calls, 'early dispatch ran a route for an unmatched path'); + assertTest(!isset(WpKitTestState::$filters['the_content']), 'early dispatch touched page content for an unmatched path'); + } + + public function testEarlyDispatchStillEnforcesTheRouteMethod(): void + { + $calls = 0; + new Router('static', 'landing', null); + (new RouteBase())->post('hook', static function () use (&$calls) { + ++$calls; + + return 'ok'; + }); + (new StaticRouter('landing', 'plugin_activate', 'plugin_deactivate'))->dispatchOnParseRequest(); + $_SERVER['REQUEST_METHOD'] = 'GET'; + $wp = new \WP(); + $wp->request = 'landing/hook'; + + do_action('parse_request', $wp); + + assertSameValue(0, $calls, 'early dispatch ran a POST route for GET'); + } + + public function testRetryWindowIsTrackedPerPage(): void + { + $this->makeTransport(['entries/{id}' => static fn () => 'x']); + new Router('static', 'portal', null); + (new RouteBase())->get('profile', static fn () => 'x'); + new StaticRouter('portal', 'plugin_activate', 'plugin_deactivate'); + + do_action('init'); + do_action('init'); + + assertSameValue(2, WpKitTestState::$rewriteFlushes, 'one page\'s retry window suppressed another page\'s flush'); + } + + public function testActivationFlushesEvenInsideTheRetryWindow(): void + { + $this->makeTransport(['entries/{id}' => static fn () => 'x']); + + do_action('init'); + do_action('plugin_activate'); + + assertSameValue(2, WpKitTestState::$rewriteFlushes, 'activation flush was throttled'); + } + + public function testPersistedRulesSkipFlushAfterAnEarlierRetry(): void + { + $this->makeTransport(['entries/{id}' => static fn () => 'x']); + + do_action('init'); + WpKitTestState::$options['rewrite_rules'] = ['^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]']; + WpKitTestState::$currentTime = '2024-01-02 04:00:00'; + do_action('init'); + + assertSameValue(1, WpKitTestState::$rewriteFlushes, 'persisted rules were flushed again after the window'); + } + + public function testRootRuleCoexistsWithOtherRoutes(): void + { + $this->makeTransport([ + '/' => static fn () => 'home', + 'entries/{id}' => static fn () => 'x', + ]); + + do_action('init'); + + assertTest(isset(WpKitTestState::$rewriteRules['^landing/?$']), 'root rule missing next to other routes'); + assertTest(isset(WpKitTestState::$rewriteRules['^landing/entries/([^/]+)/?$']), 'route rule missing next to the root rule'); + } + private function makeTransport(array $routes = []): StaticRouter { new Router('static', 'landing', null); From 0d709ecfb79b0cbbf4df1b9f3e2436dc5d3c5b7f Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 26 Oct 2026 16:06:44 +0600 Subject: [PATCH 10/12] test(router): require deactivation to drop static rewrite rules Deactivation flushed while the router's rules were still registered on that request, so the flush re-saved them and they outlived the plugin. The tests now require no flush and an emptied rewrite_rules option, including after init has registered the rules. Assisted-By: AI --- tests/Router/StaticRoutingTest.php | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/tests/Router/StaticRoutingTest.php b/tests/Router/StaticRoutingTest.php index ff23ab1..fae7889 100644 --- a/tests/Router/StaticRoutingTest.php +++ b/tests/Router/StaticRoutingTest.php @@ -245,16 +245,33 @@ public function testActivationRegistersRewriteRulesAndFlushes(): void assertTest(WpKitTestState::$rewriteFlushes > 0, 'activation did not flush rewrite rules'); } - public function testDeactivationOnlyFlushesRewriteRules(): void + public function testDeactivationClearsPersistedRulesInsteadOfFlushing(): void { $this->makeTransport(['entries/{id}' => static function () { return 'x'; }]); + WpKitTestState::$options['rewrite_rules'] = ['^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]']; do_action('plugin_deactivate'); assertSameValue([], WpKitTestState::$rewriteRules, 'deactivation registered rewrite rules'); - assertSameValue(1, WpKitTestState::$rewriteFlushes, 'deactivation must flush exactly once'); + assertSameValue(0, WpKitTestState::$rewriteFlushes, 'deactivation flushed, which re-saves rules still registered on this request'); + assertSameValue('', WpKitTestState::$options['rewrite_rules'], 'persisted rules were not cleared for regeneration'); + } + + public function testDeactivationAfterInitDoesNotPersistTheStillRegisteredRules(): void + { + $this->makeTransport(['entries/{id}' => static function () { + return 'x'; + }]); + WpKitTestState::$options['rewrite_rules'] = ['^landing/entries/([^/]+)/?$' => 'index.php?pagename=landing&id=$matches[1]']; + + do_action('init'); + do_action('plugin_deactivate'); + + assertTest(isset(WpKitTestState::$rewriteRules['^landing/entries/([^/]+)/?$']), 'init did not register the route rule'); + assertSameValue(0, WpKitTestState::$rewriteFlushes, 'deactivation re-persisted the registered rules'); + assertSameValue('', WpKitTestState::$options['rewrite_rules'], 'route rule outlived deactivation'); } public function testInitFlushesOnceWhenStaticRulesAreMissingFromPersistedRules(): void From de9fc49429f0dacd083a41fc3f66abbfa74bff16 Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Mon, 26 Oct 2026 16:06:45 +0600 Subject: [PATCH 11/12] fix(router): clear persisted rules on deactivation instead of flushing flushOnDeactivate() now empties the rewrite_rules option (row kept) so WordPress rebuilds it on the next request, when the deactivated plugin's rules are no longer registered. Consumers no longer need their own post-deactivation cleanup to stop stale /page/* rewrites. Assisted-By: AI --- src/Http/Router/StaticRouter.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Http/Router/StaticRouter.php b/src/Http/Router/StaticRouter.php index d6fa8c9..3a3963e 100644 --- a/src/Http/Router/StaticRouter.php +++ b/src/Http/Router/StaticRouter.php @@ -42,9 +42,13 @@ public function flushOnActivate(): void flush_rewrite_rules(); } + /** + * Empties the persisted rules so the next request rebuilds them without ours; a flush here would + * re-save them, as they are still registered on the deactivating request. + */ public function flushOnDeactivate(): void { - flush_rewrite_rules(); + update_option('rewrite_rules', ''); } public function registerRewriteRules(): void From 1e755af15fbbcd907cfe63de62fc0a8ed57d98ec Mon Sep 17 00:00:00 2001 From: Abdul Kaioum Date: Tue, 27 Oct 2026 14:23:28 +0600 Subject: [PATCH 12/12] refactor(request): match content type prefix with str_starts_with Assisted-By: AI --- src/Http/Request/Request.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Http/Request/Request.php b/src/Http/Request/Request.php index 9551e53..60fa9db 100644 --- a/src/Http/Request/Request.php +++ b/src/Http/Request/Request.php @@ -143,7 +143,8 @@ public function contentType() { $type = (string) $this->header('Content-Type', ''); - return stripos($type, self::APPLICATION_MEDIA_PREFIX) === 0 + // Media types are case-insensitive (RFC 9110 ยง8.3.1). + return str_starts_with(strtolower($type), self::APPLICATION_MEDIA_PREFIX) ? substr($type, \strlen(self::APPLICATION_MEDIA_PREFIX)) : $type; }