-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[Security][CSRF] Double Submit Cookies CSRF prevention strategy #18333
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
d746f31
e9573af
beed130
1cea0d2
a298be6
a533b0e
893fa0c
61b5483
6e19ef9
99dde3b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- cookie token storage managing csrf tokens stored inside cookies (prerequisite for "double submit cookies" csrf prevention strategy) - kernel response event listener add the cookie headers of the cookie token storage to request responses - token storage factory interface to create token storages based on requests - session token storage factory using the requests session to create a session token storage - cookie token storage factory using the requests cookies to create a cookie token storage - request stack token storage managing and using token storages inside master request's attributes
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,62 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\Security\Csrf\TokenStorage; | ||
|
||
/** | ||
* Forwards calls to another TokenStorageInterface | ||
* | ||
* @author Oliver Hoff <oliver@hofff.com> | ||
*/ | ||
abstract class AbstractTokenStorageProxy implements TokenStorageInterface | ||
{ | ||
/** | ||
* {@inheritDoc} | ||
* @see \Symfony\Component\Security\Csrf\TokenStorage\TokenStorageInterface::getToken() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. all those |
||
*/ | ||
public function getToken($tokenId) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it makes sense to mark concrete public methods as final? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think final should only be used in very special cases when you absolutely rely on the implemented behavior and to keep the internal state of the class clean and valid, and this cannot be provided by subclasses (e.g. not being able to access private properties). Both is not the case here. Imagine someone wants to create a decorater that fixes the token ID to a certain value, using this class as a parent would not be possible with final methods. |
||
{ | ||
return $this->getProxiedTokenStorage()->getToken($tokenId); | ||
} | ||
|
||
/** | ||
* {@inheritDoc} | ||
* @see \Symfony\Component\Security\Csrf\TokenStorage\TokenStorageInterface::setToken() | ||
*/ | ||
public function setToken($tokenId, $token) | ||
{ | ||
// TODO interface declares return void, use return stmt or not? | ||
$this->getProxiedTokenStorage()->setToken($tokenId, $token); | ||
} | ||
|
||
/** | ||
* {@inheritDoc} | ||
* @see \Symfony\Component\Security\Csrf\TokenStorage\TokenStorageInterface::removeToken() | ||
*/ | ||
public function removeToken($tokenId) | ||
{ | ||
return $this->getProxiedTokenStorage()->removeToken($tokenId); | ||
} | ||
|
||
/** | ||
* {@inheritDoc} | ||
* @see \Symfony\Component\Security\Csrf\TokenStorage\TokenStorageInterface::hasToken() | ||
*/ | ||
public function hasToken($tokenId) | ||
{ | ||
return $this->getProxiedTokenStorage()->hasToken($tokenId); | ||
} | ||
|
||
/** | ||
* @return TokenStorageInterface | ||
*/ | ||
abstract protected function getProxiedTokenStorage(); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,165 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\Security\Csrf\TokenStorage; | ||
|
||
use Symfony\Component\HttpFoundation\Cookie; | ||
use Symfony\Component\HttpFoundation\ParameterBag; | ||
use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException; | ||
|
||
/** | ||
* Accesses tokens in a set of cookies. A changeset records edits made to | ||
* tokens. The changeset can be retrieved as a list of cookies to be used in a | ||
* response's headers to "persist" the changes. | ||
* | ||
* @author Oliver Hoff <oliver@hofff.com> | ||
*/ | ||
class CookieTokenStorage implements TokenStorageInterface | ||
{ | ||
/** | ||
* @var array | ||
*/ | ||
private $transientTokens; | ||
|
||
/** | ||
* @var ParameterBag | ||
*/ | ||
private $cookies; | ||
|
||
/** | ||
* @var boolean | ||
*/ | ||
private $secure; | ||
|
||
/** | ||
* @param ParameterBag $cookies | ||
* @param boolean $secure | ||
*/ | ||
public function __construct(ParameterBag $cookies, $secure) | ||
{ | ||
$this->transientTokens = []; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be at variable setting not in constructor. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i think its personal preference, i try to do all property initialization within constructors and cleanly separate it from property declaration. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It seems to be set on properties in symfony core. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @HeahDude is right. And you should use |
||
$this->cookies = $cookies; | ||
$this->secure = (bool) $secure; | ||
} | ||
|
||
/** | ||
* {@inheritDoc} | ||
* @see \Symfony\Component\Security\Csrf\TokenStorage\TokenStorageInterface::getToken() | ||
*/ | ||
public function getToken($tokenId) | ||
{ | ||
$token = $this->resolveToken($tokenId); | ||
|
||
if (!strlen($token)) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe |
||
throw new TokenNotFoundException; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. missing |
||
} | ||
|
||
return $token; | ||
} | ||
|
||
/** | ||
* {@inheritDoc} | ||
* @see \Symfony\Component\Security\Csrf\TokenStorage\TokenStorageInterface::hasToken() | ||
*/ | ||
public function hasToken($tokenId) | ||
{ | ||
return strlen($this->resolveToken($tokenId)) > 0; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. according to symfony code style guide the reversing only applies to equality and identity checks (and their negations). I find it much harder to understand when "zero is smaller than something" vs "something is greater than zero" There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I missed it the first time :) I guess casting to bool can work too and would be more readable imo. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I mean the return (bool) strlen($this->resolveToken($tokenId)); There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This in fact should be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. agree with @stloyd |
||
} | ||
|
||
/** | ||
* {@inheritDoc} | ||
* @see \Symfony\Component\Security\Csrf\TokenStorage\TokenStorageInterface::setToken() | ||
*/ | ||
public function setToken($tokenId, $token) | ||
{ | ||
if (!strlen($token)) { | ||
throw new \InvalidArgumentException('Empty tokens are not allowed'); | ||
} | ||
|
||
$this->updateToken($tokenId, $token); | ||
} | ||
|
||
/** | ||
* {@inheritDoc} | ||
* @see \Symfony\Component\Security\Csrf\TokenStorage\TokenStorageInterface::removeToken() | ||
*/ | ||
public function removeToken($tokenId) | ||
{ | ||
$token = $this->resolveToken($tokenId); | ||
|
||
$this->updateToken($tokenId, ''); | ||
|
||
return strlen($token) ? $token : null; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Here could be |
||
} | ||
|
||
/** | ||
* @return array | ||
*/ | ||
public function createCookies() | ||
{ | ||
$cookies = []; | ||
|
||
foreach ($this->transientTokens as $tokenId => $token) { | ||
// FIXME empty tokens are handled by the http foundations cookie class | ||
// and are recognized as a "delete" cookie | ||
// the problem is the that the value of deleted cookies get set to | ||
// the string "deleted" and not the empty string | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What's preventing you to normalize it ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the question is: is the behavior of There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the string "deleted" used anywhere else in the code ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would clash, if someone tries to |
||
|
||
// TODO http only? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What would be the reason not to ? IIUC this is about security. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i missunderstood the httponly flag. it must be a cookie that is allowed to be read by the client side scripts, so one could use it in ajax requests headers. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IIRC http only allows reading but prevents writing, so this should be the way to go, right ? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it also disallows reading according to https://www.owasp.org/index.php/HttpOnly#What_is_HttpOnly.3F |
||
$name = $this->generateCookieName($tokenId); | ||
$cookies[] = new Cookie($name, $token, 0, null, null, $this->secure, true); | ||
} | ||
|
||
return $cookies; | ||
} | ||
|
||
/** | ||
* @param string $tokenId | ||
* @return string | ||
*/ | ||
protected function resolveToken($tokenId) | ||
{ | ||
if (isset($this->transientTokens[$tokenId])) { | ||
return $this->transientTokens[$tokenId]; | ||
} | ||
|
||
$name = $this->generateCookieName($tokenId); | ||
|
||
return $this->cookies->get($name, ''); | ||
} | ||
|
||
/** | ||
* @param string $tokenId | ||
* @param string $token | ||
* @return void | ||
*/ | ||
protected function updateToken($tokenId, $token) | ||
{ | ||
$token = (string) $token; | ||
$name = $this->generateCookieName($tokenId); | ||
|
||
if ($token === $this->cookies->get($name, '')) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't it be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The empty string token is the non-existant token. Thats because we dont have an explicit "delete" cookie header. If you want to delete a cookie, you need to overwrite a previously existing one. Thats also why it is disallowed to pass an empty string token to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
unset($this->transientTokens[$tokenId]); | ||
} else { | ||
$this->transientTokens[$tokenId] = $token; | ||
} | ||
} | ||
|
||
/** | ||
* | ||
* @param string $tokenId | ||
* @return string | ||
*/ | ||
protected function generateCookieName($tokenId) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. May be you could hold the names by ids in a private array to prevent useless call to generate them. I'm confused about what's happening when There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the same cookie name should be generated for the same token id within the same also the generation is simple and fast and only called very few times during CSRF-protected requests (normally 2 times, if a token exists). |
||
{ | ||
return sprintf('_csrf/%s/%s', $this->secure ? 'insecure' : 'secure', $tokenId); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\Security\Csrf\TokenStorage; | ||
|
||
use Symfony\Component\HttpFoundation\Request; | ||
|
||
/** | ||
* Creates CSRF token storages based on the requests cookies. | ||
* | ||
* @author Oliver Hoff <oliver@hofff.com> | ||
*/ | ||
class CookieTokenStorageFactory implements TokenStorageFactoryInterface | ||
{ | ||
/** | ||
* {@inheritDoc} | ||
* @see \Symfony\Component\Security\Csrf\TokenStorage\TokenStorageFactoryInterface::createTokenStorage() | ||
*/ | ||
public function createTokenStorage(Request $request) { | ||
return new CookieTokenStorage($request->cookies, $request->isSecure()); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <fabien@symfony.com> | ||
* | ||
* For the full copyright and license information, please view the LICENSE | ||
* file that was distributed with this source code. | ||
*/ | ||
|
||
namespace Symfony\Component\Security\Csrf\TokenStorage; | ||
|
||
use Symfony\Component\HttpKernel\Event\FilterResponseEvent; | ||
|
||
/** | ||
* Checks the request's attributes for a CookieTokenStorage instance. If one is | ||
* found, the cookies representing the storage's changeset are appended to the | ||
* response headers. | ||
* | ||
* TODO where to put this class? | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Was speculating about, but then i saw that every other kernel listener sits in the EventListener namespace of the HttpKernel component. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I really think |
||
* | ||
* @author Oliver Hoff <oliver@hofff.com> | ||
*/ | ||
class CookieTokenStorageListener | ||
{ | ||
/** | ||
* @var string | ||
*/ | ||
private $tokenStorageKey; | ||
|
||
/** | ||
* @param string|null $tokenStorageKey | ||
*/ | ||
public function __construct($tokenStorageKey = null) | ||
{ | ||
// TODO should this class get its own DEFAULT_TOKEN_STORAGE_KEY constant? | ||
// if no, where should the sole constant be put? | ||
$this->tokenStorageKey = $tokenStorageKey === null ? RequestStackTokenStorage::DEFAULT_TOKEN_STORAGE_KEY : $tokenStorageKey; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. managing a token storage within the request attributes is something specific to the RequestStackTokenStorage, not something a TokenStorageInterface must do in general. but you can use the CookieTokenStorage and CookieTokenStorageListener without RequestStackTokenStorage, if you handle the management yourself. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But any There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. only code that maintains (access or set) a token storage within the request attributes There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't understand, I mean that any class implementing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. well its also not specific to TokenStorageInterface, but to code managing TokenStorageInterfaces within higher aggregates. the managing code can be a TokenStorageInterface itself (like the RequestStackTokenStorage), but doesnt need to be (like the CookieTokenStorageListener). its also just a default key, if no explicit one is passed to the managing code. each class could provide its own default key, but it would be handy, if every implementation uses the same default, especially when they are meant to be used together (like the RequestStackTokenStorage and the CookieTokenStorageListener). There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It make more sense to me to move this constant in the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On this second round I agree with you :) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You agree on putting an own constant on CookieTokenStorageListener? That means the contant values need to be synced by hand and changed in both places. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry, I've missed that duplication, it was not there at the time of my original comment there was only one implementation using it. So now I guess it really makes sense to have this constant in the interface instead, don't you think? |
||
} | ||
|
||
/** | ||
* @param FilterResponseEvent $event | ||
* @return void | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This return type is not needed when "void". There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yep. CS fixer will remove it. I didnt cleaned up the code yet. will do, when i finish it. |
||
*/ | ||
public function onKernelResponse(FilterResponseEvent $event) | ||
{ | ||
$storage = $event->getRequest()->attributes->get($this->tokenStorageKey); | ||
if (!$storage instanceof CookieTokenStorage) { | ||
return; | ||
} | ||
|
||
$headers = $event->getResponse()->headers; | ||
foreach ($storage->createCookies() as $cookie) { | ||
$headers->setCookie($cookie); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
the
@see
tag should be removed everywhere as it does not add anything useful.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i can remove it, but i disagree that it doesnt add anything useful: you see where the original declaration comes from, without doin any clicks in your IDE. you only see, if its an overridden method in IDEs, not who the original definer was (without doin more clicks for inspection). also on github you dont have any code assist regarding inheritance.