diff --git a/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php b/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php index ece16c346..d215f2e5a 100644 --- a/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php +++ b/packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php @@ -55,7 +55,7 @@ public function map(mixed $from, mixed $to): GenericRequest ); return map([ - 'method' => Method::from($from->getMethod()), + 'method' => $this->requestMethod($from, $data), 'uri' => (string) $from->getUri(), 'raw' => $raw, 'body' => $data, @@ -81,4 +81,18 @@ public function map(mixed $from, mixed $to): GenericRequest ]) ->to(GenericRequest::class); } + + private function requestMethod(PsrRequest $request, array $data): Method + { + $originalMethod = Method::from($request->getMethod()); + if ($originalMethod !== Method::POST) { + return $originalMethod; + } + + if (! isset($data['_method'])) { + return $originalMethod; + } + + return Method::trySpoofingFrom($data['_method']) ?? $originalMethod; + } } diff --git a/packages/http/src/Method.php b/packages/http/src/Method.php index e037f162f..c015964d6 100644 --- a/packages/http/src/Method.php +++ b/packages/http/src/Method.php @@ -15,4 +15,14 @@ enum Method: string case OPTIONS = 'OPTIONS'; case TRACE = 'TRACE'; case PATCH = 'PATCH'; + + public static function trySpoofingFrom(string $method): ?Method + { + $method = Method::tryFrom(strtoupper($method)); + + return match ($method) { + self::DELETE, self::PATCH, self::PUT => $method, + default => null, + }; + } } diff --git a/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php b/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php new file mode 100644 index 000000000..9400c540c --- /dev/null +++ b/packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php @@ -0,0 +1,153 @@ +mapper = new PsrRequestToGenericRequestMapper( + $this->createEncrypter(), + new CookieManager( + new AppConfig(baseUri: 'https://test.com'), + new GenericClock(), + ), + ); + + $reflection = new ReflectionClass($this->mapper); + $this->requestMethod = $reflection->getMethod('requestMethod'); + $this->requestMethod->setAccessible(true); + } + + #[DataProvider('nonPostMethodsProvider')] + public function test_non_post_requests_are_not_affected_by_method_param(string $originalMethod): void + { + $request = $this->createServerRequest( + $originalMethod, + ['_method' => 'DELETE'], + ); + + $method = $this->requestMethod->invoke($this->mapper, $request, ['_method' => 'DELETE']); + + $this->assertSame(Method::from($originalMethod), $method); + } + + #[DataProvider('validSpoofedMethodsProvider')] + public function test_post_with_valid_method_is_spoofed(string $spoofedMethod): void + { + $request = $this->createServerRequest( + 'POST', + ['_method' => $spoofedMethod], + ); + + $method = $this->requestMethod->invoke($this->mapper, $request, ['_method' => $spoofedMethod]); + + $this->assertSame(Method::from(strtoupper($spoofedMethod)), $method); + } + + public function test_post_with_invalid_method_is_not_spoofed(): void + { + $request = $this->createServerRequest( + 'POST', + ['_method' => 'INVALID'], + ); + + $method = $this->requestMethod->invoke($this->mapper, $request, ['_method' => 'INVALID']); + + $this->assertSame(Method::POST, $method); + } + + public function test_method_param_is_case_insensitive(): void + { + $request = $this->createServerRequest( + 'POST', + ['_method' => 'delete'], + ); + + $method = $this->requestMethod->invoke($this->mapper, $request, ['_method' => 'delete']); + + $this->assertSame(Method::DELETE, $method); + } + + public static function nonPostMethodsProvider(): array + { + return [ + ['GET'], + ['PUT'], + ['PATCH'], + ['DELETE'], + ['HEAD'], + ['OPTIONS'], + ['TRACE'], + ['CONNECT'], + ]; + } + + public static function validSpoofedMethodsProvider(): array + { + return [ + ['PUT'], + ['PATCH'], + ['DELETE'], + ]; + } + + private function createEncrypter(): GenericEncrypter + { + return new GenericEncrypter( + signer: new GenericSigner( + config: new SigningConfig( + algorithm: SigningAlgorithm::SHA256, + key: 'my_secret_key', + minimumExecutionDuration: false, + ), + timelock: new Timelock(new GenericClock()), + ), + config: new EncryptionConfig( + algorithm: EncryptionAlgorithm::AES_256_GCM, + key: 'my_secret_key', + ), + ); + } + + private function createServerRequest(string $method, array $body = []): ServerRequestInterface + { + $request = new ServerRequest([], [], '/', $method); + + if ($body !== []) { + $request = $request->withParsedBody($body); + } + + $stream = new Stream('php://temp', 'r+'); + $request = $request->withBody($stream); + + return $request; + } +} diff --git a/packages/view/src/Components/x-form.view.php b/packages/view/src/Components/x-form.view.php index b8552869d..cc0bd2b24 100644 --- a/packages/view/src/Components/x-form.view.php +++ b/packages/view/src/Components/x-form.view.php @@ -13,10 +13,17 @@ if ($method instanceof Method) { $method = $method->value; } + +$needsSpoofing = Method::trySpoofingFrom($method) !== null; +$formMethod = $needsSpoofing ? 'POST' : $method; ?> -