Skip to content

Commit 5d884af

Browse files
[Security] Implement stateless headers/cookies-based CSRF protection
1 parent 2c000bb commit 5d884af

4 files changed

+508
-1
lines changed

CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.2
5+
---
6+
7+
* Add `SameOriginCsrfTokenManager`
8+
49
6.0
510
---
611

SameOriginCsrfTokenManager.php

+268
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Csrf;
13+
14+
use Psr\Log\LoggerInterface;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\RequestStack;
17+
use Symfony\Component\HttpFoundation\Response;
18+
use Symfony\Component\HttpFoundation\Session\Session;
19+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
20+
21+
/**
22+
* This CSRF token manager uses a combination of cookie and headers to validate non-persistent tokens.
23+
*
24+
* This manager is designed to be stateless and compatible with HTTP-caching.
25+
*
26+
* First, we validate the source of the request using the Origin/Referer headers. This relies
27+
* on the app being able to know its own target origin. Don't miss configuring your reverse proxy to
28+
* send the X-Forwarded-* / Forwarded headers if you're behind one.
29+
*
30+
* Then, we validate the request using a cookie and a CsrfToken. If the cookie is found, it should
31+
* contain the same value as the CsrfToken. A JavaScript snippet on the client side is responsible
32+
* for performing this double-submission. The token value should be regenerated on every request
33+
* using a cryptographically secure random generator.
34+
*
35+
* If either double-submit or Origin/Referer headers are missing, it typically indicates that
36+
* JavaScript is disabled on the client side, or that the JavaScript snippet was not properly
37+
* implemented, or that the Origin/Referer headers were filtered out.
38+
*
39+
* Requests lacking both double-submit and origin information are deemed insecure.
40+
*
41+
* When a session is found, a behavioral check is added to ensure that the validation method does not
42+
* downgrade from double-submit to origin checks. This prevents attackers from exploiting potentially
43+
* less secure validation methods once a more secure method has been confirmed as functional.
44+
*
45+
* On HTTPS connections, the cookie is prefixed with "__Host-" to prevent it from being forged on an
46+
* HTTP channel. On the JS side, the cookie should be set with samesite=strict to strengthen the CSRF
47+
* protection. The cookie is always cleared on the response to prevent any further use of the token.
48+
*
49+
* The $checkHeader argument allows the token to be checked in a header instead of or in addition to a
50+
* cookie. This makes it harder for an attacker to forge a request, though it may also pose challenges
51+
* when setting the header depending on the client-side framework in use.
52+
*
53+
* When a fallback CSRF token manager is provided, only tokens listed in the $tokenIds argument will be
54+
* managed by this manager. All other tokens will be delegated to the fallback manager.
55+
*
56+
* @author Nicolas Grekas <[email protected]>
57+
*/
58+
final class SameOriginCsrfTokenManager implements CsrfTokenManagerInterface
59+
{
60+
public const TOKEN_MIN_LENGTH = 24;
61+
62+
public const CHECK_NO_HEADER = 0;
63+
public const CHECK_HEADER = 1;
64+
public const CHECK_ONLY_HEADER = 2;
65+
66+
/**
67+
* @param self::CHECK_* $checkHeader
68+
* @param string[] $tokenIds
69+
*/
70+
public function __construct(
71+
private RequestStack $requestStack,
72+
private ?LoggerInterface $logger = null,
73+
private ?CsrfTokenManagerInterface $fallbackCsrfTokenManager = null,
74+
private array $tokenIds = [],
75+
private int $checkHeader = self::CHECK_NO_HEADER,
76+
private string $cookieName = 'csrf-token',
77+
) {
78+
if (!$cookieName) {
79+
throw new \InvalidArgumentException('The cookie name cannot be empty.');
80+
}
81+
82+
if (!preg_match('/^[-a-zA-Z0-9_]+$/D', $cookieName)) {
83+
throw new \InvalidArgumentException('The cookie name contains invalid characters.');
84+
}
85+
86+
$this->tokenIds = array_flip($tokenIds);
87+
}
88+
89+
public function getToken(string $tokenId): CsrfToken
90+
{
91+
if (!isset($this->tokenIds[$tokenId]) && $this->fallbackCsrfTokenManager) {
92+
return $this->fallbackCsrfTokenManager->getToken($tokenId);
93+
}
94+
95+
return new CsrfToken($tokenId, $this->cookieName);
96+
}
97+
98+
public function refreshToken(string $tokenId): CsrfToken
99+
{
100+
if (!isset($this->tokenIds[$tokenId]) && $this->fallbackCsrfTokenManager) {
101+
return $this->fallbackCsrfTokenManager->refreshToken($tokenId);
102+
}
103+
104+
return new CsrfToken($tokenId, $this->cookieName);
105+
}
106+
107+
public function removeToken(string $tokenId): ?string
108+
{
109+
if (!isset($this->tokenIds[$tokenId]) && $this->fallbackCsrfTokenManager) {
110+
return $this->fallbackCsrfTokenManager->removeToken($tokenId);
111+
}
112+
113+
return null;
114+
}
115+
116+
public function isTokenValid(CsrfToken $token): bool
117+
{
118+
if (!isset($this->tokenIds[$token->getId()]) && $this->fallbackCsrfTokenManager) {
119+
return $this->fallbackCsrfTokenManager->isTokenValid($token);
120+
}
121+
122+
if (!$request = $this->requestStack->getCurrentRequest()) {
123+
$this->logger?->error('CSRF validation failed: No request found.');
124+
125+
return false;
126+
}
127+
128+
if (\strlen($token->getValue()) < self::TOKEN_MIN_LENGTH && $token->getValue() !== $this->cookieName) {
129+
$this->logger?->warning('Invalid double-submit CSRF token.');
130+
131+
return false;
132+
}
133+
134+
if (false === $isValidOrigin = $this->isValidOrigin($request)) {
135+
$this->logger?->warning('CSRF validation failed: origin info doesn\'t match.');
136+
137+
return false;
138+
}
139+
140+
if (false === $isValidDoubleSubmit = $this->isValidDoubleSubmit($request, $token->getValue())) {
141+
return false;
142+
}
143+
144+
if (null === $isValidOrigin && null === $isValidDoubleSubmit) {
145+
$this->logger?->warning('CSRF validation failed: double-submit and origin info not found.');
146+
147+
return false;
148+
}
149+
150+
// Opportunistically lookup at the session for a previous CSRF validation strategy
151+
$session = $request->hasPreviousSession() ? $request->getSession() : null;
152+
$usageIndexValue = $session instanceof Session ? $usageIndexReference = &$session->getUsageIndex() : 0;
153+
$usageIndexReference = \PHP_INT_MIN;
154+
$previousCsrfProtection = (int) $session?->get($this->cookieName);
155+
$usageIndexReference = $usageIndexValue;
156+
$shift = $request->isMethodSafe() ? 8 : 0;
157+
158+
if ($previousCsrfProtection) {
159+
if (!$isValidOrigin && (1 & ($previousCsrfProtection >> $shift))) {
160+
$this->logger?->warning('CSRF validation failed: origin info was used in a previous request but is now missing.');
161+
162+
return false;
163+
}
164+
165+
if (!$isValidDoubleSubmit && (2 & ($previousCsrfProtection >> $shift))) {
166+
$this->logger?->warning('CSRF validation failed: double-submit info was used in a previous request but is now missing.');
167+
168+
return false;
169+
}
170+
}
171+
172+
if ($isValidOrigin && $isValidDoubleSubmit) {
173+
$csrfProtection = 3;
174+
$this->logger?->debug('CSRF validation accepted using both origin and double-submit info.');
175+
} elseif ($isValidOrigin) {
176+
$csrfProtection = 1;
177+
$this->logger?->debug('CSRF validation accepted using origin info.');
178+
} else {
179+
$csrfProtection = 2;
180+
$this->logger?->debug('CSRF validation accepted using double-submit info.');
181+
}
182+
183+
if (1 & $csrfProtection) {
184+
// Persist valid origin for both safe and non-safe requests
185+
$previousCsrfProtection |= 1 & (1 << 8);
186+
}
187+
188+
$request->attributes->set($this->cookieName, ($csrfProtection << $shift) | $previousCsrfProtection);
189+
190+
return true;
191+
}
192+
193+
public function clearCookies(Request $request, Response $response): void
194+
{
195+
if (!$request->attributes->has($this->cookieName)) {
196+
return;
197+
}
198+
199+
$cookieName = ($request->isSecure() ? '__Host-' : '').$this->cookieName;
200+
201+
foreach ($request->cookies->all() as $name => $value) {
202+
if ($this->cookieName === $value && str_starts_with($name, $cookieName.'_')) {
203+
$response->headers->clearCookie($name, '/', null, $request->isSecure(), false, 'strict');
204+
}
205+
}
206+
}
207+
208+
public function persistStrategy(Request $request): void
209+
{
210+
if ($request->hasSession(true) && $request->attributes->has($this->cookieName)) {
211+
$request->getSession()->set($this->cookieName, $request->attributes->get($this->cookieName));
212+
}
213+
}
214+
215+
public function onKernelResponse(ResponseEvent $event): void
216+
{
217+
if (!$event->isMainRequest()) {
218+
return;
219+
}
220+
221+
$this->clearCookies($event->getRequest(), $event->getResponse());
222+
$this->persistStrategy($event->getRequest());
223+
}
224+
225+
/**
226+
* @return bool|null Whether the origin is valid, null if missing
227+
*/
228+
private function isValidOrigin(Request $request): ?bool
229+
{
230+
$source = $request->headers->get('Origin') ?? $request->headers->get('Referer') ?? 'null';
231+
232+
return 'null' === $source ? null : str_starts_with($source.'/', $request->getSchemeAndHttpHost().'/');
233+
}
234+
235+
/**
236+
* @return bool|null Whether the double-submit is valid, null if missing
237+
*/
238+
private function isValidDoubleSubmit(Request $request, string $token): ?bool
239+
{
240+
if ($this->cookieName === $token) {
241+
return null;
242+
}
243+
244+
if ($this->checkHeader && $request->headers->get($this->cookieName, $token) !== $token) {
245+
$this->logger?->warning('CSRF validation failed: wrong token found in header info.');
246+
247+
return false;
248+
}
249+
250+
$cookieName = ($request->isSecure() ? '__Host-' : '').$this->cookieName;
251+
252+
if (self::CHECK_ONLY_HEADER === $this->checkHeader) {
253+
if (!$request->headers->has($this->cookieName)) {
254+
return null;
255+
}
256+
257+
$request->cookies->set($cookieName.'_'.$token, $this->cookieName); // Ensure clearCookie() can remove any cookie filtered by a reverse-proxy
258+
259+
return true;
260+
}
261+
262+
if (($request->cookies->all()[$cookieName.'_'.$token] ?? null) !== $this->cookieName && !($this->checkHeader && $request->headers->has($this->cookieName))) {
263+
return null;
264+
}
265+
266+
return true;
267+
}
268+
}

0 commit comments

Comments
 (0)