diff --git a/src/Symfony/Component/HttpFoundation/CHANGELOG.md b/src/Symfony/Component/HttpFoundation/CHANGELOG.md index 72d45303b31f1..44c4de107b082 100644 --- a/src/Symfony/Component/HttpFoundation/CHANGELOG.md +++ b/src/Symfony/Component/HttpFoundation/CHANGELOG.md @@ -9,6 +9,7 @@ CHANGELOG * Add support for structured MIME suffix * Deprecate using `Request::sendHeaders()` after headers have already been sent; use a `StreamedResponse` instead * Deprecate method `Request::get()`, use properties `->attributes`, `query` or `request` directly instead + * Make `Request::createFromGlobals()` parse the body of PUT, DELETE, PATCH and QUERY requests 7.3 --- diff --git a/src/Symfony/Component/HttpFoundation/Request.php b/src/Symfony/Component/HttpFoundation/Request.php index feea8716d4d77..f26f7950ffd82 100644 --- a/src/Symfony/Component/HttpFoundation/Request.php +++ b/src/Symfony/Component/HttpFoundation/Request.php @@ -280,9 +280,19 @@ public static function createFromGlobals(): static { $request = self::createRequestFromFactory($_GET, $_POST, [], $_COOKIE, $_FILES, $_SERVER); - if (str_starts_with($request->headers->get('CONTENT_TYPE', ''), 'application/x-www-form-urlencoded') - && \in_array(strtoupper($request->server->get('REQUEST_METHOD', 'GET')), ['PUT', 'DELETE', 'PATCH', 'QUERY'], true) - ) { + if (!\in_array($request->server->get('REQUEST_METHOD', 'GET'), ['PUT', 'DELETE', 'PATCH', 'QUERY'], true)) { + return $request; + } + + if (\PHP_VERSION_ID >= 80400) { + try { + [$post, $files] = request_parse_body(); + + $request->request->add($post); + $request->files->add($files); + } catch (\RequestParseBodyException) { + } + } elseif (str_starts_with($request->headers->get('CONTENT_TYPE', ''), 'application/x-www-form-urlencoded')) { parse_str($request->getContent(), $data); $request->request = new InputBag($data); } diff --git a/src/Symfony/Component/HttpFoundation/Tests/Fixtures/request-functional/index.php b/src/Symfony/Component/HttpFoundation/Tests/Fixtures/request-functional/index.php new file mode 100644 index 0000000000000..349a7038413ce --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Tests/Fixtures/request-functional/index.php @@ -0,0 +1,45 @@ + $request->request->all(), + 'files' => array_map( + static fn (UploadedFile $file) => [ + 'clientOriginalName' => $file->getClientOriginalName(), + 'clientMimeType' => $file->getClientMimeType(), + 'content' => $file->getContent(), + ], + $request->files->all() + ), +]); + +$r->send(); diff --git a/src/Symfony/Component/HttpFoundation/Tests/RequestFunctionalTest.php b/src/Symfony/Component/HttpFoundation/Tests/RequestFunctionalTest.php new file mode 100644 index 0000000000000..691e1e56b8f9d --- /dev/null +++ b/src/Symfony/Component/HttpFoundation/Tests/RequestFunctionalTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Tests; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\RequiresPhp; +use PHPUnit\Framework\TestCase; + +#[RequiresPhp('>=8.4')] +class RequestFunctionalTest extends TestCase +{ + /** @var resource|false */ + private static $server; + + public static function setUpBeforeClass(): void + { + $spec = [ + 1 => ['file', '/dev/null', 'w'], + 2 => ['file', '/dev/null', 'w'], + ]; + if (!self::$server = @proc_open('exec '.\PHP_BINARY.' -S localhost:8054', $spec, $pipes, __DIR__.'/Fixtures/request-functional')) { + self::markTestSkipped('PHP server unable to start.'); + } + sleep(1); + } + + public static function tearDownAfterClass(): void + { + if (self::$server) { + proc_terminate(self::$server); + proc_close(self::$server); + } + } + + public static function provideMethodsRequiringExplicitBodyParsing() + { + return [ + ['PUT'], + ['DELETE'], + ['PATCH'], + // PHP’s built-in server doesn’t support QUERY + ]; + } + + #[DataProvider('provideMethodsRequiringExplicitBodyParsing')] + public function testFormUrlEncodedBodyParsing(string $method) + { + $response = file_get_contents('http://localhost:8054/', false, stream_context_create([ + 'http' => [ + 'header' => 'Content-type: application/x-www-form-urlencoded', + 'method' => $method, + 'content' => http_build_query(['foo' => 'bar']), + ], + ])); + + $this->assertSame(['foo' => 'bar'], json_decode($response, true)['request']); + } + + #[DataProvider('provideMethodsRequiringExplicitBodyParsing')] + public function testMultipartFormDataBodyParsing(string $method) + { + $response = file_get_contents('http://localhost:8054/', false, stream_context_create([ + 'http' => [ + 'header' => 'Content-Type: multipart/form-data; boundary=boundary', + 'method' => $method, + 'content' => "--boundary\r\n". + "Content-Disposition: form-data; name=foo\r\n". + "\r\n". + "bar\r\n". + "--boundary\r\n". + "Content-Disposition: form-data; name=baz; filename=baz.txt\r\n". + "Content-Type: text/plain\r\n". + "\r\n". + "qux\r\n". + '--boundary--', + ], + ])); + + $data = json_decode($response, true); + + $this->assertSame(['foo' => 'bar'], $data['request']); + $this->assertSame(['baz' => [ + 'clientOriginalName' => 'baz.txt', + 'clientMimeType' => 'text/plain', + 'content' => 'qux', + ]], $data['files']); + } +} diff --git a/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php b/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php index f5de5f89fb8cd..498fe1df42c69 100644 --- a/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php +++ b/src/Symfony/Component/HttpFoundation/Tests/RequestTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; @@ -21,9 +22,7 @@ use Symfony\Component\HttpFoundation\Exception\ConflictingHeadersException; use Symfony\Component\HttpFoundation\Exception\JsonException; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; -use Symfony\Component\HttpFoundation\InputBag; use Symfony\Component\HttpFoundation\IpUtils; -use Symfony\Component\HttpFoundation\ParameterBag; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; @@ -1281,15 +1280,13 @@ public static function getContentCanBeCalledTwiceWithResourcesProvider() ]; } - public static function provideOverloadedMethods() + public static function provideMethodsRequiringExplicitBodyParsing() { return [ ['PUT'], ['DELETE'], ['PATCH'], - ['put'], - ['delete'], - ['patch'], + ['QUERY'], ]; } @@ -1331,11 +1328,8 @@ public function testGetPayload() $this->assertSame([], $req->getPayload()->all()); } - #[DataProvider('provideOverloadedMethods')] - public function testCreateFromGlobals($method) + public function testCreateFromGlobals() { - $normalizedMethod = strtoupper($method); - $_GET['foo1'] = 'bar1'; $_POST['foo2'] = 'bar2'; $_COOKIE['foo3'] = 'bar3'; @@ -1343,38 +1337,33 @@ public function testCreateFromGlobals($method) $_SERVER['foo5'] = 'bar5'; $request = Request::createFromGlobals(); - $this->assertEquals('bar1', $request->query->get('foo1'), '::fromGlobals() uses values from $_GET'); - $this->assertEquals('bar2', $request->request->get('foo2'), '::fromGlobals() uses values from $_POST'); - $this->assertEquals('bar3', $request->cookies->get('foo3'), '::fromGlobals() uses values from $_COOKIE'); - $this->assertEquals(['bar4'], $request->files->get('foo4'), '::fromGlobals() uses values from $_FILES'); - $this->assertEquals('bar5', $request->server->get('foo5'), '::fromGlobals() uses values from $_SERVER'); - $this->assertInstanceOf(InputBag::class, $request->request); - $this->assertInstanceOf(ParameterBag::class, $request->request); + $this->assertEquals('bar1', $request->query->get('foo1'), '::createFromGlobals() uses values from $_GET'); + $this->assertEquals('bar2', $request->request->get('foo2'), '::createFromGlobals() uses values from $_POST'); + $this->assertEquals('bar3', $request->cookies->get('foo3'), '::createFromGlobals() uses values from $_COOKIE'); + $this->assertEquals(['bar4'], $request->files->get('foo4'), '::createFromGlobals() uses values from $_FILES'); + $this->assertEquals('bar5', $request->server->get('foo5'), '::createFromGlobals() uses values from $_SERVER'); + } + + public function testGetRealMethod() + { + Request::enableHttpMethodParameterOverride(); + $request = new Request(request: ['_method' => 'PUT'], server: ['REQUEST_METHOD' => 'PoSt']); - unset($_GET['foo1'], $_POST['foo2'], $_COOKIE['foo3'], $_FILES['foo4'], $_SERVER['foo5']); + $this->assertEquals('POST', $request->getRealMethod(), '->getRealMethod() returns the uppercased request method, even if it has been overridden'); + $this->disableHttpMethodParameterOverride(); + } + + #[RequiresPhp('< 8.4')] + #[DataProvider('provideMethodsRequiringExplicitBodyParsing')] + public function testFormUrlEncodedBodyParsing(string $method) + { $_SERVER['REQUEST_METHOD'] = $method; $_SERVER['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'; - $request = RequestContentProxy::createFromGlobals(); - $this->assertEquals($normalizedMethod, $request->getMethod()); - $this->assertEquals('mycontent', $request->request->get('content')); - $this->assertInstanceOf(InputBag::class, $request->request); - $this->assertInstanceOf(ParameterBag::class, $request->request); - unset($_SERVER['REQUEST_METHOD'], $_SERVER['CONTENT_TYPE']); - - Request::createFromGlobals(); - Request::enableHttpMethodParameterOverride(); - $_POST['_method'] = $method; - $_POST['foo6'] = 'bar6'; - $_SERVER['REQUEST_METHOD'] = 'PoSt'; - $request = Request::createFromGlobals(); - $this->assertEquals($normalizedMethod, $request->getMethod()); - $this->assertEquals('POST', $request->getRealMethod()); - $this->assertEquals('bar6', $request->request->get('foo6')); + $request = RequestContentProxy::createFromGlobals(); - unset($_POST['_method'], $_POST['foo6'], $_SERVER['REQUEST_METHOD']); - $this->disableHttpMethodParameterOverride(); + $this->assertEquals('mycontent', $request->request->get('content')); } public function testOverrideGlobals() @@ -2675,7 +2664,7 @@ class RequestContentProxy extends Request { public function getContent($asResource = false) { - return http_build_query(['_method' => 'PUT', 'content' => 'mycontent'], '', '&'); + return http_build_query(['content' => 'mycontent'], '', '&'); } }