Skip to content
16 changes: 15 additions & 1 deletion packages/http/src/Mappers/PsrRequestToGenericRequestMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
}
}
10 changes: 10 additions & 0 deletions packages/http/src/Method.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
}
153 changes: 153 additions & 0 deletions packages/http/tests/Mappers/PsrRequestToGenericRequestMapperTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

declare(strict_types=1);

namespace Tempest\Http\Tests\Mappers;

use Laminas\Diactoros\ServerRequest;
use Laminas\Diactoros\Stream;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\Http\Message\ServerRequestInterface;
use ReflectionClass;
use ReflectionMethod;
use Tempest\Clock\GenericClock;
use Tempest\Core\AppConfig;
use Tempest\Cryptography\Encryption\EncryptionAlgorithm;
use Tempest\Cryptography\Encryption\EncryptionConfig;
use Tempest\Cryptography\Encryption\GenericEncrypter;
use Tempest\Cryptography\Signing\GenericSigner;
use Tempest\Cryptography\Signing\SigningAlgorithm;
use Tempest\Cryptography\Signing\SigningConfig;
use Tempest\Cryptography\Timelock;
use Tempest\Http\Cookie\CookieManager;
use Tempest\Http\Mappers\PsrRequestToGenericRequestMapper;
use Tempest\Http\Method;

final class PsrRequestToGenericRequestMapperTest extends TestCase
{
private PsrRequestToGenericRequestMapper $mapper;
private ReflectionMethod $requestMethod;

protected function setUp(): void
{
parent::setUp();

$this->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;
}
}
9 changes: 8 additions & 1 deletion packages/view/src/Components/x-form.view.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,17 @@
if ($method instanceof Method) {
$method = $method->value;
}

$needsSpoofing = Method::trySpoofingFrom($method) !== null;
$formMethod = $needsSpoofing ? 'POST' : $method;
?>

<form :action="$action" :method="$method" :enctype="$enctype">
<form :action="$action" :method="$formMethod" :enctype="$enctype">
<x-csrf-token />

<?php if ($needsSpoofing): ?>
<input type="hidden" name="_method" value="<?= htmlspecialchars($method) ?>">
<?php endif; ?>

<x-slot />
</form>
Loading