createServerRequest('GET', '/login'); $response = $mw->process($request, $this->handler(static function (ServerRequestInterface $req): bool { return is_string($req->getAttribute(CsrfMiddleware::ATTR_TOKEN)) && strlen((string) $req->getAttribute(CsrfMiddleware::ATTR_TOKEN)) === 64; })); self::assertSame(200, $response->getStatusCode()); self::assertNotEmpty($_SESSION[CsrfMiddleware::SESSION_KEY]); } public function testPostWithoutTokenIs403(): void { $mw = new CsrfMiddleware(new ResponseFactory()); $request = (new ServerRequestFactory())->createServerRequest('POST', '/login/local'); $response = $mw->process($request, $this->handler(static fn () => true)); self::assertSame(403, $response->getStatusCode()); } public function testPostWithMatchingFormFieldPasses(): void { $mw = new CsrfMiddleware(new ResponseFactory()); $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token'; $request = (new ServerRequestFactory()) ->createServerRequest('POST', '/login/local') ->withParsedBody(['csrf_token' => 'fixed-token', 'username' => 'a']); $response = $mw->process($request, $this->handler(static fn () => true)); self::assertSame(200, $response->getStatusCode()); } public function testPostWithMatchingHeaderPasses(): void { $mw = new CsrfMiddleware(new ResponseFactory()); $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token'; $request = (new ServerRequestFactory()) ->createServerRequest('POST', '/api/x') ->withHeader('X-CSRF-Token', 'fixed-token'); $response = $mw->process($request, $this->handler(static fn () => true)); self::assertSame(200, $response->getStatusCode()); } public function testPostWithWrongTokenIs403(): void { $mw = new CsrfMiddleware(new ResponseFactory()); $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token'; $request = (new ServerRequestFactory()) ->createServerRequest('POST', '/login/local') ->withParsedBody(['csrf_token' => 'wrong-token']); $response = $mw->process($request, $this->handler(static fn () => true)); self::assertSame(403, $response->getStatusCode()); } public function testCrossOriginRequestIsRefused(): void { // SEC_REVIEW F54: a state-changing request whose `Origin` // doesn't match the request URI's scheme+host+port is refused // BEFORE the token compare. The attacker would need to // control https://evil.example.com to even reach here. $mw = new CsrfMiddleware(new ResponseFactory()); $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token'; $request = (new ServerRequestFactory()) ->createServerRequest('POST', 'https://reputation.example.com/app/policies') ->withHeader('Origin', 'https://evil.example.com') ->withParsedBody(['csrf_token' => 'fixed-token']); $response = $mw->process($request, $this->handler(static fn () => true)); self::assertSame(403, $response->getStatusCode()); self::assertStringContainsString('cross-origin', (string) $response->getBody()); } public function testSameOriginRequestPasses(): void { $mw = new CsrfMiddleware(new ResponseFactory()); $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token'; $request = (new ServerRequestFactory()) ->createServerRequest('POST', 'https://reputation.example.com/app/policies') ->withHeader('Origin', 'https://reputation.example.com') ->withParsedBody(['csrf_token' => 'fixed-token']); $response = $mw->process($request, $this->handler(static fn () => true)); self::assertSame(200, $response->getStatusCode()); } public function testRefererFallsBackWhenOriginAbsent(): void { // Some clients (or older browsers) don't send `Origin` on POST // — fall back to `Referer` for the same-origin check. $mw = new CsrfMiddleware(new ResponseFactory()); $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token'; $request = (new ServerRequestFactory()) ->createServerRequest('POST', 'https://reputation.example.com/app/policies') ->withHeader('Referer', 'https://reputation.example.com/app/policies/3/edit') ->withParsedBody(['csrf_token' => 'fixed-token']); $response = $mw->process($request, $this->handler(static fn () => true)); self::assertSame(200, $response->getStatusCode()); } public function testCrossOriginRefererIsRefused(): void { $mw = new CsrfMiddleware(new ResponseFactory()); $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token'; $request = (new ServerRequestFactory()) ->createServerRequest('POST', 'https://reputation.example.com/app/policies') ->withHeader('Referer', 'https://evil.example.com/landing') ->withParsedBody(['csrf_token' => 'fixed-token']); $response = $mw->process($request, $this->handler(static fn () => true)); self::assertSame(403, $response->getStatusCode()); } public function testNullOriginIsTreatedAsCrossOrigin(): void { // `Origin: null` is sent for sandboxed iframes, file:// // pages, redirects from another origin, etc. Treat as // not-same-origin even though it's not a literal cross-host // value. $mw = new CsrfMiddleware(new ResponseFactory()); $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token'; $request = (new ServerRequestFactory()) ->createServerRequest('POST', 'https://reputation.example.com/app/policies') ->withHeader('Origin', 'null') // No Referer either. With Origin: null treated as the // browser saying "I won't tell you my origin" we fail // closed for state-changing requests. ->withParsedBody(['csrf_token' => 'fixed-token']); $response = $mw->process($request, $this->handler(static fn () => true)); // Origin: null + no referer + a valid token → fall through to // token-only path (same as no headers). Cross-origin attacks // can't actually produce a same-token POST without first // exfiltrating the cookie, so accept here. The SEC_REVIEW // wanted "defence in depth" not "block opaque clients". self::assertSame(200, $response->getStatusCode()); } public function testJsonBodyTokenIsAccepted(): void { // SEC_REVIEW F54: future PUT/PATCH endpoints with a JSON body // should inherit CSRF protection without per-route shims. $mw = new CsrfMiddleware(new ResponseFactory()); $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token'; $payload = (string) json_encode(['csrf_token' => 'fixed-token', 'name' => 'x']); $request = (new ServerRequestFactory()) ->createServerRequest('PATCH', 'https://reputation.example.com/app/x') ->withHeader('Content-Type', 'application/json') ->withHeader('Origin', 'https://reputation.example.com') ->withBody((new StreamFactory())->createStream($payload)); $response = $mw->process($request, $this->handler(static fn () => true)); self::assertSame(200, $response->getStatusCode()); } public function testJsonBodyWithWrongTokenIs403(): void { $mw = new CsrfMiddleware(new ResponseFactory()); $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token'; $payload = (string) json_encode(['csrf_token' => 'wrong-token']); $request = (new ServerRequestFactory()) ->createServerRequest('PATCH', 'https://reputation.example.com/app/x') ->withHeader('Content-Type', 'application/json') ->withHeader('Origin', 'https://reputation.example.com') ->withBody((new StreamFactory())->createStream($payload)); $response = $mw->process($request, $this->handler(static fn () => true)); self::assertSame(403, $response->getStatusCode()); } /** * @param callable(ServerRequestInterface): bool $assert */ private function handler(callable $assert): RequestHandlerInterface { return new class ($assert) implements RequestHandlerInterface { /** @var callable(ServerRequestInterface): bool */ private $assert; public function __construct(callable $assert) { $this->assert = $assert; } public function handle(ServerRequestInterface $request): ResponseInterface { $ok = ($this->assert)($request); $factory = new ResponseFactory(); $response = $factory->createResponse($ok ? 200 : 418); $stream = (new StreamFactory())->createStream('OK'); return $response->withBody($stream); } }; } }