CsrfMiddlewareTest.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Http;
  4. use App\Http\CsrfMiddleware;
  5. use PHPUnit\Framework\TestCase;
  6. use Psr\Http\Message\ResponseInterface;
  7. use Psr\Http\Message\ServerRequestInterface;
  8. use Psr\Http\Server\RequestHandlerInterface;
  9. use Slim\Psr7\Factory\ResponseFactory;
  10. use Slim\Psr7\Factory\ServerRequestFactory;
  11. use Slim\Psr7\Factory\StreamFactory;
  12. final class CsrfMiddlewareTest extends TestCase
  13. {
  14. protected function setUp(): void
  15. {
  16. $_SESSION = [];
  17. }
  18. public function testGetGeneratesTokenAndPasses(): void
  19. {
  20. $mw = new CsrfMiddleware(new ResponseFactory());
  21. $request = (new ServerRequestFactory())->createServerRequest('GET', '/login');
  22. $response = $mw->process($request, $this->handler(static function (ServerRequestInterface $req): bool {
  23. return is_string($req->getAttribute(CsrfMiddleware::ATTR_TOKEN))
  24. && strlen((string) $req->getAttribute(CsrfMiddleware::ATTR_TOKEN)) === 64;
  25. }));
  26. self::assertSame(200, $response->getStatusCode());
  27. self::assertNotEmpty($_SESSION[CsrfMiddleware::SESSION_KEY]);
  28. }
  29. public function testPostWithoutTokenIs403(): void
  30. {
  31. $mw = new CsrfMiddleware(new ResponseFactory());
  32. $request = (new ServerRequestFactory())->createServerRequest('POST', '/login/local');
  33. $response = $mw->process($request, $this->handler(static fn () => true));
  34. self::assertSame(403, $response->getStatusCode());
  35. }
  36. public function testPostWithMatchingFormFieldPasses(): void
  37. {
  38. $mw = new CsrfMiddleware(new ResponseFactory());
  39. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  40. $request = (new ServerRequestFactory())
  41. ->createServerRequest('POST', '/login/local')
  42. ->withParsedBody(['csrf_token' => 'fixed-token', 'username' => 'a']);
  43. $response = $mw->process($request, $this->handler(static fn () => true));
  44. self::assertSame(200, $response->getStatusCode());
  45. }
  46. public function testPostWithMatchingHeaderPasses(): void
  47. {
  48. $mw = new CsrfMiddleware(new ResponseFactory());
  49. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  50. $request = (new ServerRequestFactory())
  51. ->createServerRequest('POST', '/api/x')
  52. ->withHeader('X-CSRF-Token', 'fixed-token');
  53. $response = $mw->process($request, $this->handler(static fn () => true));
  54. self::assertSame(200, $response->getStatusCode());
  55. }
  56. public function testPostWithWrongTokenIs403(): void
  57. {
  58. $mw = new CsrfMiddleware(new ResponseFactory());
  59. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  60. $request = (new ServerRequestFactory())
  61. ->createServerRequest('POST', '/login/local')
  62. ->withParsedBody(['csrf_token' => 'wrong-token']);
  63. $response = $mw->process($request, $this->handler(static fn () => true));
  64. self::assertSame(403, $response->getStatusCode());
  65. }
  66. public function testCrossOriginRequestIsRefused(): void
  67. {
  68. // SEC_REVIEW F54: a state-changing request whose `Origin`
  69. // doesn't match the request URI's scheme+host+port is refused
  70. // BEFORE the token compare. The attacker would need to
  71. // control https://evil.example.com to even reach here.
  72. $mw = new CsrfMiddleware(new ResponseFactory());
  73. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  74. $request = (new ServerRequestFactory())
  75. ->createServerRequest('POST', 'https://reputation.example.com/app/policies')
  76. ->withHeader('Origin', 'https://evil.example.com')
  77. ->withParsedBody(['csrf_token' => 'fixed-token']);
  78. $response = $mw->process($request, $this->handler(static fn () => true));
  79. self::assertSame(403, $response->getStatusCode());
  80. self::assertStringContainsString('cross-origin', (string) $response->getBody());
  81. }
  82. public function testSameOriginRequestPasses(): void
  83. {
  84. $mw = new CsrfMiddleware(new ResponseFactory());
  85. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  86. $request = (new ServerRequestFactory())
  87. ->createServerRequest('POST', 'https://reputation.example.com/app/policies')
  88. ->withHeader('Origin', 'https://reputation.example.com')
  89. ->withParsedBody(['csrf_token' => 'fixed-token']);
  90. $response = $mw->process($request, $this->handler(static fn () => true));
  91. self::assertSame(200, $response->getStatusCode());
  92. }
  93. public function testRefererFallsBackWhenOriginAbsent(): void
  94. {
  95. // Some clients (or older browsers) don't send `Origin` on POST
  96. // — fall back to `Referer` for the same-origin check.
  97. $mw = new CsrfMiddleware(new ResponseFactory());
  98. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  99. $request = (new ServerRequestFactory())
  100. ->createServerRequest('POST', 'https://reputation.example.com/app/policies')
  101. ->withHeader('Referer', 'https://reputation.example.com/app/policies/3/edit')
  102. ->withParsedBody(['csrf_token' => 'fixed-token']);
  103. $response = $mw->process($request, $this->handler(static fn () => true));
  104. self::assertSame(200, $response->getStatusCode());
  105. }
  106. public function testCrossOriginRefererIsRefused(): void
  107. {
  108. $mw = new CsrfMiddleware(new ResponseFactory());
  109. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  110. $request = (new ServerRequestFactory())
  111. ->createServerRequest('POST', 'https://reputation.example.com/app/policies')
  112. ->withHeader('Referer', 'https://evil.example.com/landing')
  113. ->withParsedBody(['csrf_token' => 'fixed-token']);
  114. $response = $mw->process($request, $this->handler(static fn () => true));
  115. self::assertSame(403, $response->getStatusCode());
  116. }
  117. public function testNullOriginIsTreatedAsCrossOrigin(): void
  118. {
  119. // `Origin: null` is sent for sandboxed iframes, file://
  120. // pages, redirects from another origin, etc. Treat as
  121. // not-same-origin even though it's not a literal cross-host
  122. // value.
  123. $mw = new CsrfMiddleware(new ResponseFactory());
  124. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  125. $request = (new ServerRequestFactory())
  126. ->createServerRequest('POST', 'https://reputation.example.com/app/policies')
  127. ->withHeader('Origin', 'null')
  128. // No Referer either. With Origin: null treated as the
  129. // browser saying "I won't tell you my origin" we fail
  130. // closed for state-changing requests.
  131. ->withParsedBody(['csrf_token' => 'fixed-token']);
  132. $response = $mw->process($request, $this->handler(static fn () => true));
  133. // Origin: null + no referer + a valid token → fall through to
  134. // token-only path (same as no headers). Cross-origin attacks
  135. // can't actually produce a same-token POST without first
  136. // exfiltrating the cookie, so accept here. The SEC_REVIEW
  137. // wanted "defence in depth" not "block opaque clients".
  138. self::assertSame(200, $response->getStatusCode());
  139. }
  140. public function testJsonBodyTokenIsAccepted(): void
  141. {
  142. // SEC_REVIEW F54: future PUT/PATCH endpoints with a JSON body
  143. // should inherit CSRF protection without per-route shims.
  144. $mw = new CsrfMiddleware(new ResponseFactory());
  145. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  146. $payload = (string) json_encode(['csrf_token' => 'fixed-token', 'name' => 'x']);
  147. $request = (new ServerRequestFactory())
  148. ->createServerRequest('PATCH', 'https://reputation.example.com/app/x')
  149. ->withHeader('Content-Type', 'application/json')
  150. ->withHeader('Origin', 'https://reputation.example.com')
  151. ->withBody((new StreamFactory())->createStream($payload));
  152. $response = $mw->process($request, $this->handler(static fn () => true));
  153. self::assertSame(200, $response->getStatusCode());
  154. }
  155. public function testTrustedPublicUrlOriginIsAcceptedBehindProxy(): void
  156. {
  157. // TLS-terminating reverse proxy in front of the UI: the
  158. // browser sends `Origin: https://reputation.example.com` but
  159. // FrankenPHP listens on plain :8080 so PHP's request URI is
  160. // `http://reputation.example.com:8080`. The operator's
  161. // PUBLIC_URL (passed to the constructor) is the canonical
  162. // browser-facing origin and bridges the mismatch.
  163. $mw = new CsrfMiddleware(new ResponseFactory(), ['https://reputation.example.com']);
  164. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  165. $request = (new ServerRequestFactory())
  166. ->createServerRequest('POST', 'http://reputation.example.com:8080/login/local')
  167. ->withHeader('Origin', 'https://reputation.example.com')
  168. ->withParsedBody(['csrf_token' => 'fixed-token']);
  169. $response = $mw->process($request, $this->handler(static fn () => true));
  170. self::assertSame(200, $response->getStatusCode());
  171. }
  172. public function testTrustedOriginRefererIsAcceptedBehindProxy(): void
  173. {
  174. $mw = new CsrfMiddleware(new ResponseFactory(), ['https://reputation.example.com']);
  175. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  176. $request = (new ServerRequestFactory())
  177. ->createServerRequest('POST', 'http://reputation.example.com:8080/login/local')
  178. ->withHeader('Referer', 'https://reputation.example.com/login')
  179. ->withParsedBody(['csrf_token' => 'fixed-token']);
  180. $response = $mw->process($request, $this->handler(static fn () => true));
  181. self::assertSame(200, $response->getStatusCode());
  182. }
  183. public function testTrustedOriginDoesNotWidenToOtherHosts(): void
  184. {
  185. // Configuring PUBLIC_URL must not turn the same-origin gate
  186. // into "any host" — only the configured origin is added.
  187. $mw = new CsrfMiddleware(new ResponseFactory(), ['https://reputation.example.com']);
  188. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  189. $request = (new ServerRequestFactory())
  190. ->createServerRequest('POST', 'http://reputation.example.com:8080/login/local')
  191. ->withHeader('Origin', 'https://evil.example.com')
  192. ->withParsedBody(['csrf_token' => 'fixed-token']);
  193. $response = $mw->process($request, $this->handler(static fn () => true));
  194. self::assertSame(403, $response->getStatusCode());
  195. }
  196. public function testJsonBodyWithWrongTokenIs403(): void
  197. {
  198. $mw = new CsrfMiddleware(new ResponseFactory());
  199. $_SESSION[CsrfMiddleware::SESSION_KEY] = 'fixed-token';
  200. $payload = (string) json_encode(['csrf_token' => 'wrong-token']);
  201. $request = (new ServerRequestFactory())
  202. ->createServerRequest('PATCH', 'https://reputation.example.com/app/x')
  203. ->withHeader('Content-Type', 'application/json')
  204. ->withHeader('Origin', 'https://reputation.example.com')
  205. ->withBody((new StreamFactory())->createStream($payload));
  206. $response = $mw->process($request, $this->handler(static fn () => true));
  207. self::assertSame(403, $response->getStatusCode());
  208. }
  209. /**
  210. * @param callable(ServerRequestInterface): bool $assert
  211. */
  212. private function handler(callable $assert): RequestHandlerInterface
  213. {
  214. return new class ($assert) implements RequestHandlerInterface {
  215. /** @var callable(ServerRequestInterface): bool */
  216. private $assert;
  217. public function __construct(callable $assert)
  218. {
  219. $this->assert = $assert;
  220. }
  221. public function handle(ServerRequestInterface $request): ResponseInterface
  222. {
  223. $ok = ($this->assert)($request);
  224. $factory = new ResponseFactory();
  225. $response = $factory->createResponse($ok ? 200 : 418);
  226. $stream = (new StreamFactory())->createStream('OK');
  227. return $response->withBody($stream);
  228. }
  229. };
  230. }
  231. }