diff --git a/CHANGELOG.md b/CHANGELOG.md index 1077f9c..a362ada 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## 2.1.2 under development +- New #68: Add the `CsrfHeaderMiddleware` middleware to use custom HTTP header to prevent forgery of requests (@olegbaturin) +- Enh #68: Add the `CsrfMiddleware::withSafeMethods()` method to configure a custom safe HTTP methods list (@olegbaturin) - Chg #71: Deprecate `CsrfMiddleware` in favor of `CsrfTokenMiddleware` (@ev-gor) ## 2.1.1 May 08, 2024 diff --git a/README.md b/README.md index 1b9a84c..c435bc7 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ The package provides [PSR-15](https://www.php-fig.org/psr/psr-15/) middleware fo - Synchronizer CSRF token with customizable token generation and storage. By default, it uses random data and session. - HMAC based token with customizable identity generation. Uses session by default. - It has ability to apply masking to CSRF token string to make [BREACH attack](https://breachattack.com/) impossible. +- It supports CSRF protection by custom header for AJAX/SPA backend API. ## Requirements @@ -36,24 +37,30 @@ composer require yiisoft/csrf ## General usage In order to enable CSRF protection you need to add `CsrfTokenMiddleware` to your main middleware stack. -In Yii it is done by configuring `config/web/application.php`: +In Yii it is done by configuring [`MiddlewareDispatcher`](https://github.com/yiisoft/middleware-dispatcher): ```php +$middlewareDispatcher = $injector->make(MiddlewareDispatcher::class); +$middlewareDispatcher = $middlewareDispatcher->withMiddlewares([ + ErrorCatcher::class, + SessionMiddleware::class, + CsrfTokenMiddleware::class, // <-- add this + Router::class, +]); +``` + +or define the `MiddlewareDispatcher` configuration in the [DI container](https://github.com/yiisoft/di): + +```php +// config/web/di/application.php return [ - Yiisoft\Yii\Http\Application::class => [ - '__construct()' => [ - 'dispatcher' => DynamicReference::to(static function (Injector $injector) { - return ($injector->make(MiddlewareDispatcher::class)) - ->withMiddlewares( - [ - ErrorCatcher::class, - SessionMiddleware::class, - CsrfTokenMiddleware::class, // <-- add this - Router::class, - ] - ); - }), - ], + MiddlewareDispatcher::class => [ + 'withMiddlewares()' => [[ + ErrorCatcher::class, + SessionMiddleware::class, + CsrfTokenMiddleware::class, // <-- add this + Router::class, + ]] ], ]; ``` @@ -102,9 +109,39 @@ $failureHandler = new class ($responseFactory) implements RequestHandlerInterfac $middleware = new CsrfTokenMiddleware($responseFactory, $csrfToken, $failureHandler); ``` +By default, `CsrfTokenMiddleware` considers `GET`, `HEAD`, `OPTIONS` methods as safe operations and doesn't perform CSRF validation. You can change this behavior as follows: + +```php +use Yiisoft\Csrf\CsrfTokenMiddleware; +use Yiisoft\Http\Method; + +$csrfTokenMiddleware = $container->get(CsrfTokenMiddleware::class); + +// Returns a new instance with the specified list of safe methods. +$csrfTokenMiddleware = $csrfTokenMiddleware->withSafeMethods([Method::OPTIONS]); + +// Returns a new instance with the specified header name. +$csrfTokenMiddleware = $csrfTokenMiddleware->withHeaderName('X-CSRF-PROTECTION'); +``` + +or define the `CsrfTokenMiddleware` configuration in the [DI container](https://github.com/yiisoft/di): + +```php +// config/web/di/csrf-token.php +use Yiisoft\Csrf\CsrfTokenMiddleware; +use Yiisoft\Http\Method; + +return [ + CsrfTokenMiddleware::class => [ + 'withSafeMethods()' => [[Method::OPTIONS]], + 'withHeaderName()' => ['X-CSRF-PROTECTION'], + ], +]; +``` + ## CSRF Tokens -In case Yii framework is used along with config plugin, the package is [configured](./config/web.php) +In case Yii framework is used along with config plugin, the package is [configured](./config/di-web.php) automatically to use synchronizer token and masked decorator. You can change that depending on your needs. ### Synchronizer CSRF token @@ -157,6 +194,326 @@ the next request either as a hidden form field or via JavaScript async request. It is recommended to always use this decorator. +## CSRF protection for AJAX/SPA backend API + +If you are using a cookie to authenticate your AJAX/SPA, then you do need CSRF protection for the backend API. + +### Employing custom request header + +In this pattern, AJAX/SPA frontend appends a custom header to API requests that require CSRF protection. No token is needed for this approach. This defense relies on the CORS preflight mechanism which sends an `OPTIONS` request to verify CORS compliance with the destination server. All modern browsers, according to the same-origin policy security model, designate requests with custom headers as "to be preflighted". When the API requires a custom header, you know that the request must have been preflighted if it came from a browser. + +The header can be any arbitrary key-value pair, as long as it does not conflict with existing headers. Empty value is also acceptable. + +``` +X-CSRF-HEADER=1 +``` + +When handling the request, the API checks for the existence of this header. If the header does not exist, the backend rejects the request as potential forgery. Employing a custom header allows to reject [simple requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS#simple_requests) that browsers do not designate as "to be preflighted" and permit them to be sent to any origin. + +In order to enable CSRF protection you need to add `CsrfHeaderMiddleware` to the [`MiddlewareDispatcher`](https://github.com/yiisoft/middleware-dispatcher) configuration: + +```php +$middlewareDispatcher = $injector->make(MiddlewareDispatcher::class); +$middlewareDispatcher = $middlewareDispatcher->withMiddlewares([ + ErrorCatcher::class, + CsrfHeaderMiddleware::class, // <-- add this + Router::class, +]); +``` + +or in the [DI container](https://github.com/yiisoft/di): + +```php +// config/web/di/application.php +return [ + MiddlewareDispatcher::class => [ + 'withMiddlewares()' => [[ + ErrorCatcher::class, + CsrfHeaderMiddleware::class, // <-- add this + Router::class, + ]] + ], +]; +``` + +or add `CsrfHeaderMiddleware` to the routes that must be protected to the [router](https://github.com/yiisoft/router) configuration: + +```php +// config/web/di/router.php +return [ + RouteCollectionInterface::class => static function (RouteCollectorInterface $collector) use ($config) { + $collector + ->middleware(CsrfHeaderMiddleware::class) // <-- add this + ->addGroup(Group::create(null)->routes($routes)); + + return new RouteCollection($collector); + }, +]; +``` + +By default, `CsrfHeaderMiddleware` considers only `GET`, `HEAD`, `POST` methods as unsafe operations. Requests with other HTTP methods trigger CORS preflight and do not require CSRF header validation. You can change this behavior as follows: + +```php +use Yiisoft\Csrf\CsrfHeaderMiddleware; +use Yiisoft\Http\Method; + +$csrfHeaderMiddleware = $container->get(CsrfHeaderMiddleware::class); + +// Returns a new instance with the specified list of unsafe methods. +$csrfHeaderMiddleware = $csrfHeaderMiddleware->withUnsafeMethods([Method::POST]); + +// Returns a new instance with the specified header name. +$csrfHeaderMiddleware = $csrfHeaderMiddleware->withHeaderName('X-CSRF-PROTECTION'); +``` + +or define the `CsrfHeaderMiddleware` configuration in the [DI container](https://github.com/yiisoft/di): + +```php +// config/web/di/csrf-header.php +use Yiisoft\Csrf\CsrfHeaderMiddleware; +use Yiisoft\Http\Method; + +return [ + CsrfHeaderMiddleware::class => [ + 'withUnsafeMethods()' => [[Method::POST]], + 'withHeaderName()' => ['X-CSRF-PROTECTION'], + ], +]; +``` + +The use of a custom request header for CSRF protection is based on the CORS Protocol. Thus, you **must** configure the CORS module to allow or deny cross-origin access to the backend API. + +>**Warning** +> +>`CsrfHeaderMiddleware` can be used to prevent forgery of same-origin requests and requests from the list of specific origins only. + + +### Protecting same-origin requests + +In this scenario: + +- AJAX/SPA frontend and API backend have the same origin. +- Cross-origin requests to the API server are denied. +- Simple CORS requests must be restricted. + +#### Configure CORS module + +- Responses to a CORS preflight requests **must not** contain CORS headers. +- Responses to an actual requests **must not** contain CORS headers. + +#### Configure middlewares stack + +Add `CsrfHeaderMiddleware` to the [`MiddlewareDispatcher`](https://github.com/yiisoft/middleware-dispatcher) configuration: + +```php +$middlewareDispatcher = $injector->make(MiddlewareDispatcher::class); +$middlewareDispatcher = $middlewareDispatcher->withMiddlewares([ + ErrorCatcher::class, + CsrfHeaderMiddleware::class, // <-- add this + Router::class, +]); +``` + +or to the routes that must be protected to the [router](https://github.com/yiisoft/router) configuration: + +```php +$collector = $container->get(RouteCollectorInterface::class); +$collector->addGroup( + Group::create('/api') + ->middleware(CsrfHeaderMiddleware::class) // <-- add this + ->routes($routes) +); +``` + +#### Configure frontend requests + +On the frontend add to the `GET`, `HEAD`, `POST` requests a custom header defined in the `CsrfHeaderMiddleware` with an empty or random value. + +```js +let response = fetch('https://example.com/api/whoami', { + headers: { + "X-CSRF-HEADER": crypto.randomUUID() + } +}); +``` + +### Protecting requests from the list of specific origins + +In this scenario: + +- AJAX/SPA frontend and API backend have different origins. +- Allow cross origin requests to the API server from the list of specific origins only. +- Simple CORS requests must be restricted. + +#### Configure CORS module + +- A successful responses to a CORS preflight requests **must** contain appropriate CORS headers. +- Responses to an actual requests **must** contain appropriate CORS headers. +- Value of the CORS header `Access-Control-Allow-Origin` **must** contain origin from the predefined list. + +``` +// assuming frontend origin is https://example.com and backend origin is https://api.example.com +Access-Control-Allow-Origin: https://example.com +``` + +#### Configure middlewares stack + +Add `CsrfHeaderMiddleware` to the [`MiddlewareDispatcher`](https://github.com/yiisoft/middleware-dispatcher) configuration: + +```php +$middlewareDispatcher = $injector->make(MiddlewareDispatcher::class); +$middlewareDispatcher = $middlewareDispatcher->withMiddlewares([ + ErrorCatcher::class, + CsrfHeaderMiddleware::class, // <-- add this + Router::class, +]); +``` + +or to the routes that must be protected to the [router](https://github.com/yiisoft/router) configuration: + +```php +$collector = $container->get(RouteCollectorInterface::class); +$collector->addGroup( + Group::create('/api') + ->middleware(CsrfHeaderMiddleware::class) // <-- add this + ->routes($routes) +); +``` + +#### Configure frontend requests + +On the frontend add to the `GET`, `HEAD`, `POST` requests a custom header defined in the `CsrfHeaderMiddleware` with an empty or random value. + +```js +let response = fetch('https://api.example.com/whoami', { + headers: { + "X-CSRF-HEADER": crypto.randomUUID() + } +}); +``` + +### Protecting requests passed from any origin + +In this scenario: + +- AJAX/SPA frontend and API backend have different origins. +- Allow cross origin requests to the API server from any origin. +- All requests are considered unsafe and **must** be protected against CSRF with CSRF-token. + +#### Configure CORS module + +- A successful responses to a CORS preflight requests **must** contain appropriate CORS headers. +- Responses to an actual requests **must** contain appropriate CORS headers. +- The CORS header `Access-Control-Allow-Origin` has the same value as `Origin` header in the request. + +``` +$frontendOrigin = $request->getOrigin(); + +Access-Control-Allow-Origin: $frontendOrigin +``` + +#### Configure middlewares stack + +By default, `CsrfTokenMiddleware` considers `GET`, `HEAD`, `OPTIONS` methods as safe operations and doesn't perform CSRF validation. +In JavaScript-based apps, requests are made programmatically; therefore, to increase application protection, the only `OPTIONS` method can be considered safe and need not be appended with a CSRF token header. + +Configure `CsrfTokenMiddleware` safe methods: + +```php +use Yiisoft\Csrf\CsrfTokenMiddleware; +use Yiisoft\Http\Method; + +$csrfTokenMiddleware = $container->get(CsrfTokenMiddleware::class); +$csrfTokenMiddleware = $csrfTokenMiddleware->withSafeMethods([Method::OPTIONS]); +``` + +or in the [DI container](https://github.com/yiisoft/di): + +```php +// config/web/di/csrf-token.php +use Yiisoft\Csrf\CsrfTokenMiddleware; +use Yiisoft\Http\Method; + +return [ + CsrfTokenMiddleware::class => [ + 'withSafeMethods()' => [[Method::OPTIONS]], + ], +]; +``` + +Add `CsrfTokenMiddleware` to the [`MiddlewareDispatcher`](https://github.com/yiisoft/middleware-dispatcher) configuration: + +```php +$middlewareDispatcher = $injector->make(MiddlewareDispatcher::class); +$middlewareDispatcher = $middlewareDispatcher->withMiddlewares([ + ErrorCatcher::class, + SessionMiddleware::class, + CsrfTokenMiddleware::class, // <-- add this + Router::class, +]); +``` + +or to the routes that must be protected to the [router](https://github.com/yiisoft/router) configuration: + +```php +$collector = $container->get(RouteCollectorInterface::class); +$collector->addGroup( + Group::create('/api') + ->middleware(CsrfTokenMiddleware::class) // <-- add this + ->routes($routes) +); +``` + +#### Configure routes + +Create a route for acquiring CSRF-tokens from the frontend application to the [router](https://github.com/yiisoft/router) configuration. + +```php +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Yiisoft\Http\Header; +use Yiisoft\Http\Method; +use Yiisoft\Router\Route; + +Route::options('/csrf-token') + ->action(static function ( + ResponseFactoryInterface $responseFactory, + CsrfTokenInterface $token + ): ResponseInterface { + $tokenValue = $token->getValue(); + + $response = $responseFactory->createResponse() + ->withHeader(Header::ALLOW, Method::OPTIONS) + ->withHeader('X-CSRF-TOKEN', $tokenValue); + + $response->getBody()->write($tokenValue); + + return $response; + }), +``` + +#### Configure frontend requests + +On the frontend first make a request to the configured endpoint and acquire a CSRF-token to use it in the subsequent requests. + +```js +let response = await fetch('https://api.example.com/csrf-token'); + +let csrfToken = await response.text(); +// OR +let csrfToken = response.headers.get('X-CSRF-TOKEN'); +``` + +Add to all requests a custom header defined in the `CsrfTokenMiddleware` with acquired CSRF-token value. + +```js +let response = fetch('https://api.example.com/whoami', { + headers: { + "X-CSRF-TOKEN": csrfToken + } +}); +``` + ## Documentation - [Internals](docs/internals.md) diff --git a/src/CsrfHeaderMiddleware.php b/src/CsrfHeaderMiddleware.php new file mode 100644 index 0000000..b820594 --- /dev/null +++ b/src/CsrfHeaderMiddleware.php @@ -0,0 +1,94 @@ +responseFactory = $responseFactory; + $this->failureHandler = $failureHandler; + } + + public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface + { + if ($this->validateCsrfToken($request)) { + return $handler->handle($request); + } + + if ($this->failureHandler !== null) { + return $this->failureHandler->handle($request); + } + + $response = $this->responseFactory->createResponse(Status::UNPROCESSABLE_ENTITY); + $response + ->getBody() + ->write(Status::TEXTS[Status::UNPROCESSABLE_ENTITY]); + return $response; + } + + public function withHeaderName(string $name): self + { + $new = clone $this; + $new->headerName = $name; + return $new; + } + + /** + * @param array $methods "unsafe" methods not triggered a CORS-preflight request + * @link https://fetch.spec.whatwg.org/#http-cors-protocol + */ + public function withUnsafeMethods(array $methods): self + { + $new = clone $this; + $new->unsafeMethods = $methods; + return $new; + } + + public function getHeaderName(): string + { + return $this->headerName; + } + + private function validateCsrfToken(ServerRequestInterface $request): bool + { + if (in_array($request->getMethod(), $this->unsafeMethods, true)) { + return $request->hasHeader($this->headerName); + } + + return true; + } +} diff --git a/src/CsrfMiddleware.php b/src/CsrfMiddleware.php index 679ff1d..038a820 100644 --- a/src/CsrfMiddleware.php +++ b/src/CsrfMiddleware.php @@ -29,6 +29,12 @@ final class CsrfMiddleware implements MiddlewareInterface private string $parameterName = self::PARAMETER_NAME; private string $headerName = self::HEADER_NAME; + /** + * @var array "safe" methods skipped on CSRF token validation + * @link https://datatracker.ietf.org/doc/html/rfc9110#name-safe-methods + */ + private array $safeMethods = [Method::GET, Method::HEAD, Method::OPTIONS]; + private ResponseFactoryInterface $responseFactory; private CsrfTokenInterface $token; private ?RequestHandlerInterface $failureHandler; @@ -74,6 +80,17 @@ public function withHeaderName(string $name): self return $new; } + /** + * @param array $methods "safe" methods skipped on CSRF token validation + * @link https://datatracker.ietf.org/doc/html/rfc9110#name-safe-methods + */ + public function withSafeMethods(array $methods): self + { + $new = clone $this; + $new->safeMethods = $methods; + return $new; + } + public function getParameterName(): string { return $this->parameterName; @@ -86,7 +103,7 @@ public function getHeaderName(): string private function validateCsrfToken(ServerRequestInterface $request): bool { - if (in_array($request->getMethod(), [Method::GET, Method::HEAD, Method::OPTIONS], true)) { + if (in_array($request->getMethod(), $this->safeMethods, true)) { return true; } diff --git a/src/CsrfTokenMiddleware.php b/src/CsrfTokenMiddleware.php index 0ef2b3b..f55d49d 100644 --- a/src/CsrfTokenMiddleware.php +++ b/src/CsrfTokenMiddleware.php @@ -28,6 +28,12 @@ final class CsrfTokenMiddleware implements MiddlewareInterface private string $parameterName = self::PARAMETER_NAME; private string $headerName = self::HEADER_NAME; + /** + * @var array "safe" methods skipped on CSRF token validation + * @link https://datatracker.ietf.org/doc/html/rfc9110#name-safe-methods + */ + private array $safeMethods = [Method::GET, Method::HEAD, Method::OPTIONS]; + private ResponseFactoryInterface $responseFactory; private CsrfTokenInterface $token; private ?RequestHandlerInterface $failureHandler; @@ -73,6 +79,17 @@ public function withHeaderName(string $name): self return $new; } + /** + * @param array $methods "safe" methods skipped on CSRF token validation + * @link https://datatracker.ietf.org/doc/html/rfc9110#name-safe-methods + */ + public function withSafeMethods(array $methods): self + { + $new = clone $this; + $new->safeMethods = $methods; + return $new; + } + public function getParameterName(): string { return $this->parameterName; @@ -85,7 +102,7 @@ public function getHeaderName(): string private function validateCsrfToken(ServerRequestInterface $request): bool { - if (in_array($request->getMethod(), [Method::GET, Method::HEAD, Method::OPTIONS], true)) { + if (in_array($request->getMethod(), $this->safeMethods, true)) { return true; } diff --git a/tests/CsrfHeaderMiddlewareProcessTest.php b/tests/CsrfHeaderMiddlewareProcessTest.php new file mode 100644 index 0000000..8ee926b --- /dev/null +++ b/tests/CsrfHeaderMiddlewareProcessTest.php @@ -0,0 +1,170 @@ +createMiddleware(); + $response = $middleware->process( + $this->createServerRequest(Method::OPTIONS), + $this->createRequestHandler() + ); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testCustomSafeGetRequestResultIn200(): void + { + $middleware = $this + ->createMiddleware() + ->withUnsafeMethods([Method::POST, Method::DELETE]); + $response = $middleware->process( + $this->createServerRequest(Method::GET), + $this->createRequestHandler() + ); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testUnsafeMethodGetRequestResultIn422(): void + { + $middleware = $this->createMiddleware(); + $response = $middleware->process( + $this->createServerRequest(Method::GET), + $this->createRequestHandler() + ); + $this->assertEquals(Status::TEXTS[Status::UNPROCESSABLE_ENTITY], $response->getBody()); + $this->assertEquals(Status::UNPROCESSABLE_ENTITY, $response->getStatusCode()); + } + + public function testUnsafeMethodHeadRequestResultIn422(): void + { + $middleware = $this->createMiddleware(); + $response = $middleware->process( + $this->createServerRequest(Method::HEAD), + $this->createRequestHandler() + ); + $this->assertEquals(Status::TEXTS[Status::UNPROCESSABLE_ENTITY], $response->getBody()); + $this->assertEquals(Status::UNPROCESSABLE_ENTITY, $response->getStatusCode()); + } + + public function testUnsafeMethodPostRequestResultIn422(): void + { + $middleware = $this->createMiddleware(); + $response = $middleware->process( + $this->createServerRequest(Method::POST), + $this->createRequestHandler() + ); + $this->assertEquals(Status::TEXTS[Status::UNPROCESSABLE_ENTITY], $response->getBody()); + $this->assertEquals(Status::UNPROCESSABLE_ENTITY, $response->getStatusCode()); + } + + public function testCustomUnsafeMethodDeleteRequestResultIn422(): void + { + $middleware = $this + ->createMiddleware() + ->withUnsafeMethods([Method::POST, Method::DELETE]); + $response = $middleware->process( + $this->createServerRequest(Method::DELETE), + $this->createRequestHandler() + ); + $this->assertEquals(Status::TEXTS[Status::UNPROCESSABLE_ENTITY], $response->getBody()); + $this->assertEquals(Status::UNPROCESSABLE_ENTITY, $response->getStatusCode()); + } + + public function testValidCustomHeaderResultIn200(): void + { + $headerName = 'X-JGURDA'; + + $middleware = $this + ->createMiddleware() + ->withHeaderName($headerName) + ->withUnsafeMethods([Method::POST]); + $response = $middleware->process( + $this->createServerRequest(Method::POST, [$headerName => Random::string()]), + $this->createRequestHandler() + ); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testEmptyTokenInRequestResultIn200(): void + { + $middleware = $this + ->createMiddleware() + ->withUnsafeMethods([Method::POST]); + $response = $middleware->process( + $this->createServerRequest(Method::POST, [CsrfHeaderMiddleware::HEADER_NAME => '']), + $this->createRequestHandler() + ); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testInvalidHeaderResultIn422(): void + { + $middleware = $this->createMiddleware(); + $response = $middleware->process( + $this->createServerRequest(Method::POST, ['X-JGURDA' => '']), + $this->createRequestHandler() + ); + $this->assertEquals(Status::UNPROCESSABLE_ENTITY, $response->getStatusCode()); + $this->assertEquals(Status::TEXTS[Status::UNPROCESSABLE_ENTITY], $response->getBody()); + } + + public function testInvalidHeaderResultWithCustomFailureHandler(): void + { + $failureHandler = new class () implements RequestHandlerInterface { + public function handle(ServerRequestInterface $request): ResponseInterface + { + $response = new Response(Status::BAD_REQUEST); + $response + ->getBody() + ->write(Status::TEXTS[Status::BAD_REQUEST]); + return $response; + } + }; + $middleware = $this->createMiddleware($failureHandler); + $response = $middleware->process( + $this->createServerRequest(Method::POST, ['X-JGURDA' => '']), + $this->createRequestHandler(), + ); + $this->assertEquals(Status::BAD_REQUEST, $response->getStatusCode()); + $this->assertEquals(Status::TEXTS[Status::BAD_REQUEST], $response->getBody()); + } + + private function createMiddleware( + RequestHandlerInterface $failureHandler = null + ): CsrfHeaderMiddleware { + return new CsrfHeaderMiddleware(new Psr17Factory(), $failureHandler); + } + + private function createRequestHandler(): RequestHandlerInterface + { + $requestHandler = $this->createMock(RequestHandlerInterface::class); + $requestHandler + ->method('handle') + ->willReturn(new Response(200)); + + return $requestHandler; + } + + private function createServerRequest( + string $method = Method::GET, + array $headParams = [] + ): ServerRequestInterface { + return new ServerRequest($method, '/', $headParams); + } +} diff --git a/tests/CsrfHeaderMiddlewareTest.php b/tests/CsrfHeaderMiddlewareTest.php new file mode 100644 index 0000000..0d00a2c --- /dev/null +++ b/tests/CsrfHeaderMiddlewareTest.php @@ -0,0 +1,39 @@ +createMiddleware(); + $this->assertSame(CsrfHeaderMiddleware::HEADER_NAME, $middleware->getHeaderName()); + } + + public function testGetHeaderName(): void + { + $middleware = $this + ->createMiddleware() + ->withHeaderName('X-JGURDA'); + $this->assertSame('X-JGURDA', $middleware->getHeaderName()); + } + + public function testImmutability(): void + { + $original = $this->createMiddleware(); + $this->assertNotSame($original, $original->withHeaderName('X-JGURDA')); + $this->assertNotSame($original, $original->withUnsafeMethods([Method::POST])); + } + + private function createMiddleware(): CsrfHeaderMiddleware + { + return new CsrfHeaderMiddleware(new Psr17Factory()); + } +} diff --git a/tests/TokenCsrfMiddlewareTest.php b/tests/CsrfTokenMiddlewareProcessTest.php similarity index 69% rename from tests/TokenCsrfMiddlewareTest.php rename to tests/CsrfTokenMiddlewareProcessTest.php index fa4224b..66c8763 100644 --- a/tests/TokenCsrfMiddlewareTest.php +++ b/tests/CsrfTokenMiddlewareProcessTest.php @@ -18,15 +18,36 @@ use Yiisoft\Http\Status; use Yiisoft\Security\Random; -abstract class TokenCsrfMiddlewareTest extends TestCase +abstract class CsrfTokenMiddlewareProcessTest extends TestCase { private const PARAM_NAME = 'csrf'; private string $token; + public function testGetIsAlwaysAllowed(): void + { + $middleware = $this->createCsrfTokenMiddleware(); + $response = $middleware->process($this->createServerRequest(Method::GET), $this->createRequestHandler()); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testHeadIsAlwaysAllowed(): void + { + $middleware = $this->createCsrfTokenMiddleware(); + $response = $middleware->process($this->createServerRequest(Method::HEAD), $this->createRequestHandler()); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testOptionsIsAlwaysAllowed(): void + { + $middleware = $this->createCsrfTokenMiddleware(); + $response = $middleware->process($this->createServerRequest(Method::OPTIONS), $this->createRequestHandler()); + $this->assertEquals(200, $response->getStatusCode()); + } + public function testValidTokenInBodyPostRequestResultIn200(): void { - $middleware = $this->createCsrfMiddleware(); + $middleware = $this->createCsrfTokenMiddleware(); $response = $middleware->process( $this->createPostServerRequestWithBodyToken($this->token), $this->createRequestHandler() @@ -36,7 +57,7 @@ public function testValidTokenInBodyPostRequestResultIn200(): void public function testValidTokenInBodyPutRequestResultIn200(): void { - $middleware = $this->createCsrfMiddleware(); + $middleware = $this->createCsrfTokenMiddleware(); $response = $middleware->process( $this->createPutServerRequestWithBodyToken($this->token), $this->createRequestHandler() @@ -46,7 +67,7 @@ public function testValidTokenInBodyPutRequestResultIn200(): void public function testValidTokenInBodyDeleteRequestResultIn200(): void { - $middleware = $this->createCsrfMiddleware(); + $middleware = $this->createCsrfTokenMiddleware(); $response = $middleware->process( $this->createDeleteServerRequestWithBodyToken($this->token), $this->createRequestHandler() @@ -56,7 +77,7 @@ public function testValidTokenInBodyDeleteRequestResultIn200(): void public function testValidTokenInHeaderResultIn200(): void { - $middleware = $this->createCsrfMiddleware(); + $middleware = $this->createCsrfTokenMiddleware(); $response = $middleware->process( $this->createPostServerRequestWithHeaderToken($this->token), $this->createRequestHandler() @@ -69,7 +90,7 @@ public function testValidTokenInCustomHeaderResultIn200(): void $headerName = 'CUSTOM-CSRF'; $middleware = $this - ->createCsrfMiddleware() + ->createCsrfTokenMiddleware() ->withHeaderName($headerName); $response = $middleware->process( $this->createPostServerRequestWithHeaderToken($this->token, $headerName), @@ -79,16 +100,9 @@ public function testValidTokenInCustomHeaderResultIn200(): void $this->assertEquals(200, $response->getStatusCode()); } - public function testGetIsAlwaysAllowed(): void - { - $middleware = $this->createCsrfMiddleware(); - $response = $middleware->process($this->createServerRequest(Method::GET), $this->createRequestHandler()); - $this->assertEquals(200, $response->getStatusCode()); - } - public function testInvalidTokenResultIn422(): void { - $middleware = $this->createCsrfMiddleware(); + $middleware = $this->createCsrfTokenMiddleware(); $response = $middleware->process( $this->createPostServerRequestWithBodyToken(Random::string()), @@ -112,7 +126,7 @@ public function handle(ServerRequestInterface $request): ResponseInterface } }; - $middleware = $this->createCsrfMiddleware(null, $failureHandler); + $middleware = $this->createCsrfTokenMiddleware(null, $failureHandler); $response = $middleware->process( $this->createPostServerRequestWithBodyToken(Random::string()), @@ -125,11 +139,47 @@ public function handle(ServerRequestInterface $request): ResponseInterface public function testEmptyTokenInRequestResultIn422(): void { - $middleware = $this->createCsrfMiddleware(); + $middleware = $this->createCsrfTokenMiddleware(); $response = $middleware->process($this->createServerRequest(), $this->createRequestHandler()); $this->assertEquals(Status::UNPROCESSABLE_ENTITY, $response->getStatusCode()); } + public function testUnsafeMethodPostRequestResultIn422(): void + { + $middleware = $this->createCsrfTokenMiddleware(); + $response = $middleware->process( + $this->createServerRequest(Method::POST), + $this->createRequestHandler() + ); + $this->assertEquals(Status::TEXTS[Status::UNPROCESSABLE_ENTITY], $response->getBody()); + $this->assertEquals(Status::UNPROCESSABLE_ENTITY, $response->getStatusCode()); + } + + public function testCustomSafeOptionsRequestResultIn200(): void + { + $middleware = $this + ->createCsrfTokenMiddleware() + ->withSafeMethods([Method::OPTIONS]); + $response = $middleware->process( + $this->createServerRequest(Method::OPTIONS), + $this->createRequestHandler() + ); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testCustomUnsafeMethodGetRequestResultIn422(): void + { + $middleware = $this + ->createCsrfTokenMiddleware() + ->withSafeMethods([Method::OPTIONS]); + $response = $middleware->process( + $this->createServerRequest(Method::GET), + $this->createRequestHandler() + ); + $this->assertEquals(Status::TEXTS[Status::UNPROCESSABLE_ENTITY], $response->getBody()); + $this->assertEquals(Status::UNPROCESSABLE_ENTITY, $response->getStatusCode()); + } + private function createServerRequest( string $method = Method::POST, array $bodyParams = [], @@ -180,7 +230,7 @@ private function getBodyRequestParamsByToken(string $token): array ]; } - protected function createCsrfMiddleware( + protected function createCsrfTokenMiddleware( ?CsrfTokenInterface $csrfToken = null, RequestHandlerInterface $failureHandler = null ): CsrfTokenMiddleware { diff --git a/tests/CsrfMiddlewareTest.php b/tests/CsrfTokenMiddlewareTest.php similarity index 91% rename from tests/CsrfMiddlewareTest.php rename to tests/CsrfTokenMiddlewareTest.php index cc0847e..ec8efa3 100644 --- a/tests/CsrfMiddlewareTest.php +++ b/tests/CsrfTokenMiddlewareTest.php @@ -10,8 +10,9 @@ use Yiisoft\Csrf\Synchronizer\Generator\RandomCsrfTokenGenerator; use Yiisoft\Csrf\Synchronizer\SynchronizerCsrfToken; use Yiisoft\Csrf\Tests\Synchronizer\Storage\MockCsrfTokenStorage; +use Yiisoft\Http\Method; -final class CsrfMiddlewareTest extends TestCase +final class CsrfTokenMiddlewareTest extends TestCase { public function testDefaultParameterName(): void { @@ -46,6 +47,7 @@ public function testImmutability(): void $original = $this->createMiddleware(); $this->assertNotSame($original, $original->withHeaderName('csrf')); $this->assertNotSame($original, $original->withParameterName('csrf')); + $this->assertNotSame($original, $original->withSafeMethods([Method::HEAD])); } private function createMiddleware(): CsrfTokenMiddleware diff --git a/tests/DeprecatedCsrfMiddlewareTest.php b/tests/DeprecatedCsrfMiddlewareTest.php index 4552f09..2f30f0b 100644 --- a/tests/DeprecatedCsrfMiddlewareTest.php +++ b/tests/DeprecatedCsrfMiddlewareTest.php @@ -10,6 +10,7 @@ use Yiisoft\Csrf\Synchronizer\Generator\RandomCsrfTokenGenerator; use Yiisoft\Csrf\Synchronizer\SynchronizerCsrfToken; use Yiisoft\Csrf\Tests\Synchronizer\Storage\MockCsrfTokenStorage; +use Yiisoft\Http\Method; final class DeprecatedCsrfMiddlewareTest extends TestCase { @@ -46,6 +47,7 @@ public function testImmutability(): void $original = $this->createMiddleware(); $this->assertNotSame($original, $original->withHeaderName('csrf')); $this->assertNotSame($original, $original->withParameterName('csrf')); + $this->assertNotSame($original, $original->withSafeMethods([Method::HEAD])); } private function createMiddleware(): CsrfMiddleware diff --git a/tests/DeprecatedTokenCsrfMiddlewareTest.php b/tests/DeprecatedTokenCsrfMiddlewareTest.php index c00dd65..0ccbb48 100644 --- a/tests/DeprecatedTokenCsrfMiddlewareTest.php +++ b/tests/DeprecatedTokenCsrfMiddlewareTest.php @@ -24,6 +24,27 @@ abstract class DeprecatedTokenCsrfMiddlewareTest extends TestCase private string $token; + public function testGetIsAlwaysAllowed(): void + { + $middleware = $this->createCsrfMiddleware(); + $response = $middleware->process($this->createServerRequest(Method::GET), $this->createRequestHandler()); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testHeadIsAlwaysAllowed(): void + { + $middleware = $this->createCsrfMiddleware(); + $response = $middleware->process($this->createServerRequest(Method::HEAD), $this->createRequestHandler()); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testOptionsIsAlwaysAllowed(): void + { + $middleware = $this->createCsrfMiddleware(); + $response = $middleware->process($this->createServerRequest(Method::OPTIONS), $this->createRequestHandler()); + $this->assertEquals(200, $response->getStatusCode()); + } + public function testValidTokenInBodyPostRequestResultIn200(): void { $middleware = $this->createCsrfMiddleware(); @@ -79,13 +100,6 @@ public function testValidTokenInCustomHeaderResultIn200(): void $this->assertEquals(200, $response->getStatusCode()); } - public function testGetIsAlwaysAllowed(): void - { - $middleware = $this->createCsrfMiddleware(); - $response = $middleware->process($this->createServerRequest(Method::GET), $this->createRequestHandler()); - $this->assertEquals(200, $response->getStatusCode()); - } - public function testInvalidTokenResultIn422(): void { $middleware = $this->createCsrfMiddleware(); @@ -130,6 +144,42 @@ public function testEmptyTokenInRequestResultIn422(): void $this->assertEquals(Status::UNPROCESSABLE_ENTITY, $response->getStatusCode()); } + public function testUnsafeMethodPostRequestResultIn422(): void + { + $middleware = $this->createCsrfMiddleware(); + $response = $middleware->process( + $this->createServerRequest(Method::POST), + $this->createRequestHandler() + ); + $this->assertEquals(Status::TEXTS[Status::UNPROCESSABLE_ENTITY], $response->getBody()); + $this->assertEquals(Status::UNPROCESSABLE_ENTITY, $response->getStatusCode()); + } + + public function testCustomSafeOptionsRequestResultIn200(): void + { + $middleware = $this + ->createCsrfMiddleware() + ->withSafeMethods([Method::OPTIONS]); + $response = $middleware->process( + $this->createServerRequest(Method::OPTIONS), + $this->createRequestHandler() + ); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function testCustomUnsafeMethodGetRequestResultIn422(): void + { + $middleware = $this + ->createCsrfMiddleware() + ->withSafeMethods([Method::OPTIONS]); + $response = $middleware->process( + $this->createServerRequest(Method::GET), + $this->createRequestHandler() + ); + $this->assertEquals(Status::TEXTS[Status::UNPROCESSABLE_ENTITY], $response->getBody()); + $this->assertEquals(Status::UNPROCESSABLE_ENTITY, $response->getStatusCode()); + } + private function createServerRequest( string $method = Method::POST, array $bodyParams = [], diff --git a/tests/Hmac/HmacTokenCsrfMiddlewareTest.php b/tests/Hmac/HmacTokenCsrfTokenMiddlewareProcessTest.php similarity index 76% rename from tests/Hmac/HmacTokenCsrfMiddlewareTest.php rename to tests/Hmac/HmacTokenCsrfTokenMiddlewareProcessTest.php index 6a6ab08..56e1537 100644 --- a/tests/Hmac/HmacTokenCsrfMiddlewareTest.php +++ b/tests/Hmac/HmacTokenCsrfTokenMiddlewareProcessTest.php @@ -7,10 +7,10 @@ use Yiisoft\Csrf\CsrfTokenInterface; use Yiisoft\Csrf\Hmac\HmacCsrfToken; use Yiisoft\Csrf\Tests\Hmac\IdentityGenerator\MockCsrfTokenIdentityGenerator; -use Yiisoft\Csrf\Tests\TokenCsrfMiddlewareTest; +use Yiisoft\Csrf\Tests\CsrfTokenMiddlewareProcessTest; use Yiisoft\Security\Random; -final class HmacTokenCsrfMiddlewareTest extends TokenCsrfMiddlewareTest +final class HmacTokenCsrfTokenMiddlewareProcessTest extends CsrfTokenMiddlewareProcessTest { protected function createCsrfToken(): CsrfTokenInterface { diff --git a/tests/Synchronizer/SynchronizerTokenCsrfMiddlewareTest.php b/tests/Synchronizer/SynchronizerTokenCsrfTokenMiddlewareProcessTest.php similarity index 86% rename from tests/Synchronizer/SynchronizerTokenCsrfMiddlewareTest.php rename to tests/Synchronizer/SynchronizerTokenCsrfTokenMiddlewareProcessTest.php index 958e590..c3a9783 100644 --- a/tests/Synchronizer/SynchronizerTokenCsrfMiddlewareTest.php +++ b/tests/Synchronizer/SynchronizerTokenCsrfTokenMiddlewareProcessTest.php @@ -9,14 +9,14 @@ use Yiisoft\Csrf\Synchronizer\Generator\RandomCsrfTokenGenerator; use Yiisoft\Csrf\Synchronizer\SynchronizerCsrfToken; use Yiisoft\Csrf\Tests\Synchronizer\Storage\MockCsrfTokenStorage; -use Yiisoft\Csrf\Tests\TokenCsrfMiddlewareTest; +use Yiisoft\Csrf\Tests\CsrfTokenMiddlewareProcessTest; use Yiisoft\Security\Random; -final class SynchronizerTokenCsrfMiddlewareTest extends TokenCsrfMiddlewareTest +final class SynchronizerTokenCsrfTokenMiddlewareProcessTest extends CsrfTokenMiddlewareProcessTest { public function testEmptyTokenInSessionResultIn422(): void { - $middleware = $this->createCsrfMiddleware( + $middleware = $this->createCsrfTokenMiddleware( new SynchronizerCsrfToken( new RandomCsrfTokenGenerator(), new MockCsrfTokenStorage()