Skip to content

Commit 6fe1b7c

Browse files
committed
feat(router): add method spoofing support for HTML forms
1 parent b1459db commit 6fe1b7c

File tree

3 files changed

+328
-1
lines changed

3 files changed

+328
-1
lines changed

packages/router/src/MatchRouteMiddleware.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ public function __construct(
2424

2525
public function __invoke(Request $request, HttpMiddlewareCallable $next): Response
2626
{
27+
$request = $this->applyMethodSpoofing($request);
28+
2729
$matchedRoute = $this->routeMatcher->match($request);
2830

2931
if ($matchedRoute === null && $request->method === Method::HEAD && $request instanceof GenericRequest) {
@@ -70,4 +72,40 @@ private function resolveRequest(Request $request, MatchedRoute $matchedRoute): R
7072

7173
return $request;
7274
}
75+
76+
private function applyMethodSpoofing(Request $request): Request
77+
{
78+
if ($request->method !== Method::POST) {
79+
return $request;
80+
}
81+
82+
if (! ($request instanceof GenericRequest)) {
83+
return $request;
84+
}
85+
86+
if (! $request->hasBody('_method')) {
87+
return $request;
88+
}
89+
90+
$spoofedMethod = $request->get('_method');
91+
$spoofedEnum = ($spoofedMethod instanceof Method)
92+
? $spoofedMethod
93+
: Method::tryFrom(strtoupper((string) $spoofedMethod));
94+
95+
if ($spoofedEnum === null) {
96+
return $request;
97+
}
98+
99+
$allowedMethods = [
100+
Method::PUT,
101+
Method::PATCH,
102+
Method::DELETE,
103+
];
104+
105+
if (! in_array($spoofedEnum, $allowedMethods, true)) {
106+
return $request;
107+
}
108+
109+
return $request->withMethod($spoofedEnum);
110+
}
73111
}
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Router\Tests;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Tempest\Container\Container;
9+
use Tempest\Container\GenericContainer;
10+
use Tempest\Http\GenericRequest;
11+
use Tempest\Http\Method;
12+
use Tempest\Http\Request;
13+
use Tempest\Http\Response;
14+
use Tempest\Http\Responses\NotFound;
15+
use Tempest\Http\Responses\Ok;
16+
use Tempest\Router\HttpMiddlewareCallable;
17+
use Tempest\Router\MatchedRoute;
18+
use Tempest\Router\MatchRouteMiddleware;
19+
use Tempest\Router\RouteConfig;
20+
use Tempest\Router\Routing\Matching\GenericRouteMatcher;
21+
22+
final class MatchRouteMiddlewareTest extends TestCase
23+
{
24+
private Container $container;
25+
private RouteConfig $routeConfig;
26+
private MatchRouteMiddleware $middleware;
27+
28+
protected function setUp(): void
29+
{
30+
parent::setUp();
31+
32+
$this->container = new GenericContainer();
33+
$this->routeConfig = new RouteConfig();
34+
$this->container->singleton(RouteConfig::class, fn () => $this->routeConfig);
35+
$routeMatcher = new GenericRouteMatcher($this->routeConfig);
36+
$this->middleware = new MatchRouteMiddleware(
37+
routeMatcher: $routeMatcher,
38+
container: $this->container,
39+
);
40+
}
41+
42+
public function test_method_spoofing_with_put(): void
43+
{
44+
$this->addRoute(Method::PUT, '/users/1');
45+
46+
$request = new GenericRequest(
47+
method: Method::POST,
48+
uri: '/users/1',
49+
body: ['_method' => 'PUT', 'name' => 'John'],
50+
);
51+
52+
$next = new HttpMiddlewareCallable(
53+
fn (Request $_request) => new Ok('Middleware processed'),
54+
);
55+
56+
$response = ($this->middleware)($request, $next);
57+
$matchedRoute = $this->container->get(MatchedRoute::class);
58+
59+
$this->assertInstanceOf(Ok::class, $response);
60+
$this->assertNotNull($matchedRoute);
61+
$this->assertEquals(Method::PUT, $matchedRoute->route->method);
62+
}
63+
64+
public function test_method_spoofing_with_patch(): void
65+
{
66+
$this->addRoute(Method::PATCH, '/users/1');
67+
68+
$request = new GenericRequest(
69+
method: Method::POST,
70+
uri: '/users/1',
71+
body: ['_method' => 'PATCH'],
72+
);
73+
74+
$next = new HttpMiddlewareCallable(
75+
fn (Request $_request) => new Ok('Middleware processed'),
76+
);
77+
78+
$response = ($this->middleware)($request, $next);
79+
$matchedRoute = $this->container->get(MatchedRoute::class);
80+
81+
$this->assertInstanceOf(Ok::class, $response);
82+
$this->assertNotNull($matchedRoute);
83+
$this->assertEquals(Method::PATCH, $matchedRoute->route->method);
84+
}
85+
86+
public function test_method_spoofing_with_delete(): void
87+
{
88+
$this->addRoute(Method::DELETE, '/users/1');
89+
90+
$request = new GenericRequest(
91+
method: Method::POST,
92+
uri: '/users/1',
93+
body: ['_method' => 'DELETE'],
94+
);
95+
96+
$next = new HttpMiddlewareCallable(
97+
fn (Request $_request) => new Ok('Middleware processed'),
98+
);
99+
100+
$response = ($this->middleware)($request, $next);
101+
$matchedRoute = $this->container->get(MatchedRoute::class);
102+
103+
$this->assertInstanceOf(Ok::class, $response);
104+
$this->assertNotNull($matchedRoute);
105+
$this->assertEquals(Method::DELETE, $matchedRoute->route->method);
106+
}
107+
108+
public function test_method_spoofing_with_lowercase_method(): void
109+
{
110+
$this->addRoute(Method::PUT, '/users/1');
111+
112+
$request = new GenericRequest(
113+
method: Method::POST,
114+
uri: '/users/1',
115+
body: ['_method' => 'put'],
116+
);
117+
118+
$next = new HttpMiddlewareCallable(
119+
fn (Request $_request) => new Ok('Middleware processed'),
120+
);
121+
122+
$response = ($this->middleware)($request, $next);
123+
$matchedRoute = $this->container->get(MatchedRoute::class);
124+
125+
$this->assertInstanceOf(Ok::class, $response);
126+
$this->assertNotNull($matchedRoute);
127+
$this->assertEquals(Method::PUT, $matchedRoute->route->method);
128+
}
129+
130+
public function test_method_spoofing_ignores_invalid_method(): void
131+
{
132+
$this->addRoute(Method::POST, '/users/1');
133+
134+
$request = new GenericRequest(
135+
method: Method::POST,
136+
uri: '/users/1',
137+
body: ['_method' => 'INVALID'],
138+
);
139+
140+
$next = new HttpMiddlewareCallable(
141+
fn (Request $_request) => new Ok('Middleware processed'),
142+
);
143+
144+
$response = ($this->middleware)($request, $next);
145+
$matchedRoute = $this->container->get(MatchedRoute::class);
146+
147+
$this->assertInstanceOf(Ok::class, $response);
148+
$this->assertNotNull($matchedRoute);
149+
$this->assertEquals(Method::POST, $matchedRoute->route->method);
150+
}
151+
152+
public function test_method_spoofing_not_allowed_for_get(): void
153+
{
154+
$this->addRoute(Method::GET, '/users/1');
155+
$this->addRoute(Method::POST, '/users/1');
156+
157+
$request = new GenericRequest(
158+
method: Method::POST,
159+
uri: '/users/1',
160+
body: ['_method' => 'GET'],
161+
);
162+
163+
$next = new HttpMiddlewareCallable(
164+
fn (Request $_request) => new Ok('Middleware processed'),
165+
);
166+
167+
$response = ($this->middleware)($request, $next);
168+
$matchedRoute = $this->container->get(MatchedRoute::class);
169+
170+
$this->assertInstanceOf(Ok::class, $response);
171+
$this->assertNotNull($matchedRoute);
172+
$this->assertEquals(Method::POST, $matchedRoute->route->method);
173+
}
174+
175+
public function test_method_spoofing_only_applies_to_post(): void
176+
{
177+
$this->addRoute(Method::PUT, '/users/1');
178+
179+
$request = new GenericRequest(
180+
method: Method::GET,
181+
uri: '/users/1',
182+
body: ['_method' => 'PUT'],
183+
);
184+
185+
$next = new HttpMiddlewareCallable(
186+
fn (Request $_request) => new Ok('Middleware processed'),
187+
);
188+
189+
$response = ($this->middleware)($request, $next);
190+
191+
$this->assertInstanceOf(NotFound::class, $response);
192+
}
193+
194+
public function test_no_spoofing_without_method_parameter(): void
195+
{
196+
$this->addRoute(Method::POST, '/users/1');
197+
198+
$request = new GenericRequest(
199+
method: Method::POST,
200+
uri: '/users/1',
201+
body: ['name' => 'John'],
202+
);
203+
204+
$next = new HttpMiddlewareCallable(
205+
fn (Request $_request) => new Ok('Middleware processed'),
206+
);
207+
208+
$response = ($this->middleware)($request, $next);
209+
$matchedRoute = $this->container->get(MatchedRoute::class);
210+
211+
$this->assertInstanceOf(Ok::class, $response);
212+
$this->assertNotNull($matchedRoute);
213+
$this->assertEquals(Method::POST, $matchedRoute->route->method);
214+
}
215+
216+
public function test_head_to_get_fallback_still_works(): void
217+
{
218+
$this->addRoute(Method::GET, '/users');
219+
220+
$request = new GenericRequest(
221+
method: Method::HEAD,
222+
uri: '/users',
223+
);
224+
225+
$next = new HttpMiddlewareCallable(
226+
fn (Request $_request) => new Ok('Middleware processed'),
227+
);
228+
229+
$response = ($this->middleware)($request, $next);
230+
$matchedRoute = $this->container->get(MatchedRoute::class);
231+
232+
$this->assertInstanceOf(Ok::class, $response);
233+
$this->assertNotNull($matchedRoute);
234+
$this->assertEquals(Method::GET, $matchedRoute->route->method);
235+
}
236+
237+
public function test_method_spoofing_preserves_request_data(): void
238+
{
239+
$this->addRoute(Method::PUT, '/users/1');
240+
241+
$request = new GenericRequest(
242+
method: Method::POST,
243+
uri: '/users/1',
244+
body: [
245+
'_method' => 'PUT',
246+
'name' => 'John',
247+
'email' => '[email protected]',
248+
],
249+
);
250+
251+
$processedRequest = null;
252+
$next = new HttpMiddlewareCallable(
253+
function (Request $request) use (&$processedRequest) {
254+
$processedRequest = $request;
255+
return new Ok('Middleware processed');
256+
},
257+
);
258+
259+
($this->middleware)($request, $next);
260+
261+
$this->assertNotNull($processedRequest);
262+
$this->assertEquals('John', $processedRequest->get('name'));
263+
$this->assertEquals('[email protected]', $processedRequest->get('email'));
264+
$this->assertEquals('PUT', $processedRequest->get('_method'));
265+
}
266+
267+
private function addRoute(Method $method, string $uri): void
268+
{
269+
$route = new FakeRouteBuilder();
270+
$route = $route
271+
->withMethod($method)
272+
->withUri($uri)
273+
->asDiscoveredRoute();
274+
275+
if ($route->isDynamic) {
276+
$this->routeConfig->dynamicRoutes[$method->value][$route->markName] = $route;
277+
} else {
278+
$this->routeConfig->staticRoutes[$method->value][$uri] = $route;
279+
}
280+
}
281+
}

packages/view/src/Components/x-form.view.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,21 @@
1010
$action ??= null;
1111
$method ??= Method::POST;
1212

13+
$originalMethod = $method;
1314
if ($method instanceof Method) {
1415
$method = $method->value;
1516
}
17+
18+
$needsSpoofing = in_array(strtoupper($method), ['PUT', 'PATCH', 'DELETE'], true);
19+
$formMethod = $needsSpoofing ? 'POST' : $method;
1620
?>
1721

18-
<form :action="$action" :method="$method" :enctype="$enctype">
22+
<form :action="$action" :method="$formMethod" :enctype="$enctype">
1923
<x-csrf-token />
2024

25+
<?php if ($needsSpoofing): ?>
26+
<input type="hidden" name="_method" value="<?= htmlspecialchars($method) ?>">
27+
<?php endif; ?>
28+
2129
<x-slot />
2230
</form>

0 commit comments

Comments
 (0)