diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 68636ec..b8e21c3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -11,7 +11,7 @@ jobs: - 8080:80 strategy: matrix: - php-versions: [ '8.0', '8.1', '8.2', '8.3', '8.4' ] + php-versions: [ '8.1', '8.2', '8.3', '8.4' ] composer-options: [ '--prefer-lowest', ''] runs-on: ubuntu-latest steps: diff --git a/Controller/ReplayController.php b/Controller/ReplayController.php index d3acafd..5f8da4e 100644 --- a/Controller/ReplayController.php +++ b/Controller/ReplayController.php @@ -3,9 +3,15 @@ namespace evaisse\SimpleHttpBundle\Controller; use evaisse\SimpleHttpBundle\Service\Helper; +use evaisse\SimpleHttpBundle\Service\ReplayRequestResolver; +use evaisse\SimpleHttpBundle\Service\ReplayRequestSignature; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Annotation\Route; /** @@ -14,19 +20,43 @@ */ class ReplayController extends AbstractController { - public function __construct(protected Helper $serviceHelper) - { + public function __construct( + protected Helper $serviceHelper, + private ReplayRequestResolver $replayRequestResolver, + private ReplayRequestSignature $replayRequestSignature, + private bool $debug + ) { } - #[Route('/http-replay', name: 'simple_http.replay_request')] + #[Route('/http-replay', name: 'simple_http.replay_request', methods: ['POST'])] public function replayRequestAction(Request $request): Response { - $request = json_decode($request->request->get('request')); + if (!$this->debug || !$this->replayRequestResolver->isAvailable()) { + throw new NotFoundHttpException('Replay is only available with the profiler enabled.'); + } - $service = $this->serviceHelper->prepare($request->method, $request->uri); - $service->getRequest()->setContent($request->content); + $token = $request->request->getString('token'); + $signature = $request->request->getString('_token'); + $callIndex = filter_var( + $request->request->get('callIndex'), + FILTER_VALIDATE_INT, + ['options' => ['min_range' => 0]] + ); - foreach ($request->headers as $header) { + if ('' === $token || false === $callIndex) { + throw new BadRequestHttpException('Invalid replay request.'); + } + + if (!$this->replayRequestSignature->isValid($token, $callIndex, $signature)) { + throw new AccessDeniedHttpException('Invalid replay token.'); + } + + $storedRequest = $this->replayRequestResolver->resolve($token, $callIndex); + + $service = $this->serviceHelper->prepare($storedRequest['method'], $storedRequest['uri']); + $service->getRequest()->setContent($storedRequest['content']); + + foreach ($storedRequest['headers'] as $header) { if (fnmatch('*:*', $header)) { list($headerName, $headerValue) = explode(':', $header, 2); $headerName = trim($headerName); @@ -35,15 +65,14 @@ public function replayRequestAction(Request $request): Response } } - foreach ($request->cookies as $cookieName => $cookieValue) { + foreach ($storedRequest['cookies'] as $cookieName => $cookieValue) { $service->getRequest()->cookies->set($cookieName, $cookieValue); } - $this->serviceHelper->execute([ $service, ]); - return new Response('ok'); + return new JsonResponse(['status' => 'ok']); } } diff --git a/README.md b/README.md index 65e9ea1..d780eac 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,13 @@ SimpleHttpBundle ### While Still maintained, I recommand you to replace this bundle with the [symfony/http-client](https://github.com/symfony/http-client) with has better support and a handy plug-in support. -A symfony2/3/4/5 http client bundle built on the httpfoundation component (instead of guzzle), using cURL as engine. +A Symfony 5.4/6.x HTTP client bundle built on the HttpFoundation component (instead of guzzle), using cURL as engine. + +Requirements +====== + +- PHP 8.1+ +- Symfony 5.4 or 6.x [![Coverage Status](https://coveralls.io/repos/evaisse/SimpleHttpBundle/badge.svg?branch=master)](https://coveralls.io/r/evaisse/SimpleHttpBundle?branch=master) @@ -78,6 +84,8 @@ or You can now resend an http call directly from the profiler debug panel + +Replay requests are now resolved server-side from the profiler storage. The browser only sends the profiler token and request index, not the serialized request payload. Panels details @@ -200,4 +208,3 @@ $http->execute([ ``` - diff --git a/Resources/config/services.yml b/Resources/config/services.yml index 956ab88..d1c14f3 100644 --- a/Resources/config/services.yml +++ b/Resources/config/services.yml @@ -5,7 +5,13 @@ services: autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. public: false - evaisse\SimpleHttpBundle\Controller\ReplayController: ~ + evaisse\SimpleHttpBundle\Controller\ReplayController: + arguments: + $debug: '%kernel.debug%' + evaisse\SimpleHttpBundle\Service\ReplayRequestResolver: ~ + evaisse\SimpleHttpBundle\Service\ReplayRequestSignature: + arguments: + $secret: '%kernel.secret%' evaisse\SimpleHttpBundle\Http\Kernel: ~ evaisse\SimpleHttpBundle\Service\Helper: ~ @@ -38,6 +44,7 @@ services: - { name: data_collector, template: "@SimpleHttp/Collector/profiler", id: "simplehttpprofiler" } evaisse\SimpleHttpBundle\Twig\Extension: - arguments: ["@twig.loader"] + arguments: + $loader: "@twig.loader" tags: - - { name: twig.extension } \ No newline at end of file + - { name: twig.extension } diff --git a/Resources/public/simple-http-bundle.css b/Resources/public/simple-http-bundle.css index 06e2e26..6f99102 100644 --- a/Resources/public/simple-http-bundle.css +++ b/Resources/public/simple-http-bundle.css @@ -196,7 +196,7 @@ a.http-call__index:hover { display: inline; } -button[data-simple-http-replay] { +button[data-simple-http-replay-token] { border: 1px solid #CCD; background: #EEF; color: #777; @@ -216,13 +216,13 @@ button[data-simple-http-replay] { } -button[data-simple-http-replay] img { +button[data-simple-http-replay-token] img { float: left; zoom: 75%; margin-right: 10px; } -button[data-simple-http-replay] img.rotating { +button[data-simple-http-replay-token] img.rotating { -webkit-animation: rotating 2s linear infinite; } @@ -266,4 +266,4 @@ button[data-simple-http-replay] img.rotating { overflow-x: hidden; text-decoration: none; border-bottom: 0; -} \ No newline at end of file +} diff --git a/Resources/public/simple-http-bundle.js b/Resources/public/simple-http-bundle.js index 76d2153..e74f840 100644 --- a/Resources/public/simple-http-bundle.js +++ b/Resources/public/simple-http-bundle.js @@ -2,13 +2,13 @@ * */ (function ($) { - function replayRequest(url, request, callback) { - window.console && console.log('Replay request :', request); - + function replayRequest(url, token, callIndex, signature, callback) { $.post({ url: url, data: { - request: JSON.stringify(request) + token: token, + callIndex: callIndex, + _token: signature } }).done(function (data, success, xhr) { // Wait 1 second to be sure the profiler has been generated and can be accessed @@ -69,7 +69,7 @@ filterByHost(event.target.value); }); - $(element).find('[data-simple-http-replay]').click(function (replay, i) { + $(element).find('[data-simple-http-replay-token]').click(function (replay, i) { var $panel = $(this).closest('.http-call'), $butt = $(this); @@ -78,7 +78,12 @@ $butt.prop('disabled', true); - replayRequest($(this).data('simpleHttpReplayUrl'), $(this).data('simpleHttpReplay'), function (error, block) { + replayRequest( + $(this).data('simpleHttpReplayUrl'), + $(this).data('simpleHttpReplayToken'), + $(this).data('simpleHttpReplayIndex'), + $(this).data('simpleHttpReplaySignature'), + function (error, block) { if (error) { alert('Error on replay request'); } @@ -90,7 +95,8 @@ .appendTo($panel.find('.http-call__replay-calls:first')); $(document).trigger('setup', [block]); // init events $buttImg.removeClass('rotating'); - }); + } + ); }); @@ -105,4 +111,3 @@ })(jQuery); - diff --git a/Resources/views/Collector/partials/call.html.twig b/Resources/views/Collector/partials/call.html.twig index 444504b..492888c 100644 --- a/Resources/views/Collector/partials/call.html.twig +++ b/Resources/views/Collector/partials/call.html.twig @@ -137,7 +137,9 @@
diff --git a/Resources/views/Collector/profiler.html.twig b/Resources/views/Collector/profiler.html.twig index c246e63..10f02a6 100644 --- a/Resources/views/Collector/profiler.html.twig +++ b/Resources/views/Collector/profiler.html.twig @@ -135,7 +135,7 @@ {% for index, call in collector.calls %} - {% include '@SimpleHttp/Collector/partials/call.html.twig' with { 'call': call } %} + {% include '@SimpleHttp/Collector/partials/call.html.twig' with { 'call': call, 'index': index, 'token': token } %} {% endfor %} diff --git a/Service/ReplayRequestResolver.php b/Service/ReplayRequestResolver.php new file mode 100644 index 0000000..e3d9031 --- /dev/null +++ b/Service/ReplayRequestResolver.php @@ -0,0 +1,107 @@ +profiler; + } + + /** + * @return array{ + * method: string, + * uri: string, + * content: string, + * headers: string[], + * cookies: array + * } + */ + public function resolve(string $token, int $callIndex): array + { + if (!$this->profiler) { + throw new NotFoundHttpException('Profiler is not available.'); + } + + $profile = $this->profiler->loadProfile($token); + if (!$profile instanceof Profile) { + throw new NotFoundHttpException('Profiler token not found.'); + } + + if (!$profile->hasCollector('simplehttpprofiler')) { + throw new NotFoundHttpException('Simple HTTP collector not found.'); + } + + $collector = $profile->getCollector('simplehttpprofiler'); + if (!$collector instanceof ProfilerDataCollector) { + throw new NotFoundHttpException('Simple HTTP collector is invalid.'); + } + + $calls = $collector->getCalls(); + if (!array_key_exists($callIndex, $calls) || !is_array($calls[$callIndex])) { + throw new NotFoundHttpException('HTTP call not found.'); + } + + $request = $calls[$callIndex]['request'] ?? null; + if (!is_array($request)) { + throw new NotFoundHttpException('Stored request is invalid.'); + } + + $method = isset($request['method']) && is_string($request['method']) ? $request['method'] : ''; + $uri = $this->resolveUri($request); + + if ('' === $method || '' === $uri) { + throw new NotFoundHttpException('Stored request is incomplete.'); + } + + return [ + 'method' => $method, + 'uri' => $uri, + 'content' => isset($request['content']) && is_string($request['content']) ? $request['content'] : '', + 'headers' => $this->normalizeHeaders($request['headers'] ?? []), + 'cookies' => is_array($request['cookies'] ?? null) ? $request['cookies'] : [], + ]; + } + + /** + * @param mixed $headers + * @return string[] + */ + private function normalizeHeaders(mixed $headers): array + { + if (!is_array($headers)) { + return []; + } + + return array_values(array_filter($headers, static fn (mixed $header): bool => is_string($header))); + } + + /** + * @param array $request + */ + private function resolveUri(array $request): string + { + if (isset($request['uri']) && is_string($request['uri']) && '' !== $request['uri']) { + return $request['uri']; + } + + $schemeAndHttpHost = $request['schemeAndHttpHost'] ?? null; + $requestUri = $request['requestUri'] ?? null; + + if (is_string($schemeAndHttpHost) && is_string($requestUri) && '' !== $schemeAndHttpHost && '' !== $requestUri) { + return $schemeAndHttpHost . $requestUri; + } + + return ''; + } +} diff --git a/Service/ReplayRequestSignature.php b/Service/ReplayRequestSignature.php new file mode 100644 index 0000000..6918936 --- /dev/null +++ b/Service/ReplayRequestSignature.php @@ -0,0 +1,46 @@ +buildTokenId($token, $callIndex); + + if ($this->csrfTokenManager) { + return $this->csrfTokenManager->getToken($tokenId)->getValue(); + } + + return hash_hmac('sha256', $tokenId, $this->secret); + } + + public function isValid(string $token, int $callIndex, ?string $submittedToken): bool + { + if (!is_string($submittedToken) || '' === $submittedToken) { + return false; + } + + $tokenId = $this->buildTokenId($token, $callIndex); + + if ($this->csrfTokenManager) { + return $this->csrfTokenManager->isTokenValid(new CsrfToken($tokenId, $submittedToken)); + } + + return hash_equals($this->generate($token, $callIndex), $submittedToken); + } + + private function buildTokenId(string $token, int $callIndex): string + { + return 'simple_http.replay.' . $token . '.' . $callIndex; + } +} diff --git a/Tests/Unit/ReplayRequestSignatureTest.php b/Tests/Unit/ReplayRequestSignatureTest.php new file mode 100644 index 0000000..39c5ab5 --- /dev/null +++ b/Tests/Unit/ReplayRequestSignatureTest.php @@ -0,0 +1,20 @@ +generate('abc123', 4); + + $this->assertNotSame('', $token); + $this->assertTrue($signature->isValid('abc123', 4, $token)); + $this->assertFalse($signature->isValid('abc123', 5, $token)); + $this->assertFalse($signature->isValid('abc123', 4, 'invalid')); + } +} diff --git a/Twig/Extension.php b/Twig/Extension.php index 9fe9671..0587298 100644 --- a/Twig/Extension.php +++ b/Twig/Extension.php @@ -2,6 +2,7 @@ namespace evaisse\SimpleHttpBundle\Twig; +use evaisse\SimpleHttpBundle\Service\ReplayRequestSignature; use Symfony\Component\BrowserKit\Exception\JsonException; use Symfony\Component\HttpFoundation\Response; use Twig\Error\LoaderError; @@ -12,7 +13,10 @@ class Extension extends AbstractExtension { - public function __construct(protected LoaderInterface $loader) + public function __construct( + protected LoaderInterface $loader, + private ?ReplayRequestSignature $replayRequestSignature = null + ) { } @@ -43,6 +47,7 @@ public function getFunctions(): array return [ new TwigFunction('simple_http_decode_body', array($this, 'decodeBody'), $safe), + new TwigFunction('simple_http_replay_token', array($this, 'buildReplayToken')), ]; } @@ -253,4 +258,13 @@ public function decodeBody(array $response): array return []; } + + public function buildReplayToken(string $token, int $callIndex): string + { + if (!$this->replayRequestSignature) { + return ''; + } + + return $this->replayRequestSignature->generate($token, $callIndex); + } } diff --git a/composer.json b/composer.json index 299d3f2..a184285 100644 --- a/composer.json +++ b/composer.json @@ -12,18 +12,20 @@ "client" ], "homepage": "https://github.com/evaisse/SimpleHttpBundle", - "require": { - "php": "^8.0", - "react/promise": "^2.2 || ^3.0", - "symfony/http-foundation": "^5.4 || ^6.0", - "symfony/browser-kit": "^5.4 || ^6.0", - "symfony/event-dispatcher": "^5.4 || ^6.0", + "require": { + "php": "^8.1", + "react/promise": "^2.2 || ^3.0", + "twig/twig": "^3.27", + "symfony/http-foundation": "^5.4 || ^6.0", + "symfony/browser-kit": "^5.4 || ^6.0", + "symfony/event-dispatcher": "^5.4 || ^6.0", "symfony/serializer": "^5.4 || ^6.0", "symfony/http-kernel": "^5.4 || ^6.0", "symfony/stopwatch": "^5.4 || ^6.0", "symfony/routing": "^5.4 || ^6.0", "symfony/config": "^5.4 || ^6.0", "symfony/dependency-injection": "^5.4 || ^6.0", + "symfony/security-csrf": "^5.4 || ^6.0", "symfony/security-http": "^5.4 || ^6.0", "symfony/twig-bundle": "^5.4 || ^6.0", "symfony/mime": "^5.4 || ^6.0",