Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

[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

Closed
wants to merge 10 commits into from
Next Next commit
extend csrf token storage infrastructure
- 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
backbone87 committed Sep 21, 2016
commit d746f314ba424ff6af29bd370d78eb306220f3e8
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()
Copy link
Member

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.

Copy link
Contributor Author

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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all those @see should be removed, they do not add anything useful (the class implements TokenStorageInterface).

*/
public function getToken($tokenId)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it makes sense to mark concrete public methods as final?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 = [];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be at variable setting not in constructor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems to be set on properties in symfony core.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@HeahDude is right. And you should use array() instead of [].

$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)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be '' === $token.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe '' === $token ?

throw new TokenNotFoundException;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing () at the end.

}

return $token;
}

/**
* {@inheritDoc}
* @see \Symfony\Component\Security\Csrf\TokenStorage\TokenStorageInterface::hasToken()
*/
public function hasToken($tokenId)
{
return strlen($this->resolveToken($tokenId)) > 0;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be 0 < strlen($this->resolveToken($tokenId)).

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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"

Copy link
Contributor

Choose a reason for hiding this comment

The 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.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

false === (bool) '0' or do you mean casting the strlen to bool? latter would work, but then again, i thing "the length of the string is greater than zero" is so much more explicit, especially since casting from string to something else is very adventurous in PHP.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean the strlen of courses :)

return (bool) strlen($this->resolveToken($tokenId));

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This in fact should be '' !== $this->resolveToken($tokenId).

Copy link
Member

Choose a reason for hiding this comment

The 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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here could be '' !== $token

}

/**
* @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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's preventing you to normalize it ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the question is: is the behavior of Cookie correct to overwrite a zero length cookie value with the value "deleted" (and a past expiration date). opinions on stackoverflow suggest using the empty string value and a past expiration date to "delete" a cookie. (in general a past expiration date should be enough, but browsers are not required to dismiss expired cookies, so in the case the browser decides to submit the cookie anyway, we must detect it as "deleted". But there is no way to distinguish between a "deleted" cookie and a cookie actually containing the value "deleted".)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the string "deleted" used anywhere else in the code ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would clash, if someone tries to setToken($tokenId, 'deleted') (basically in every use case, where the cookie value "deleted" is in the allowed value set of the cookie)


// TODO http only?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would be the reason not to ? IIUC this is about security.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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, '')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't it be $this->cookies->get($name, false) to be sure that's the name which is empty ? Or it doesn't matter ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 setToken (and the empty token makes no sense from a security point of view anyway).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So update can be an alias to remove ? of courses not it is protected

unset($this->transientTokens[$tokenId]);
} else {
$this->transientTokens[$tokenId] = $token;
}
}

/**
*
* @param string $tokenId
* @return string
*/
protected function generateCookieName($tokenId)
Copy link
Contributor

Choose a reason for hiding this comment

The 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 removeToken() is called, doesn't the name generation happen twice ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 CookieTokenStorage instance. so calling it multiple times is perfectly fine.

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?
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Symfony/Component/Security/Csrf/EventListener/ ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really think Symfony/Component/Security/Csrf/EventListener is fine

*
* @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;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the TokenStorageInterface ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But any TokenStorage might need this constant eventually, right ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand, I mean that any class implementing TokenStorageInterface might need a default storage key so it could make sense to hold this constant in the interface.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It make more sense to me to move this constant in the TokenStorageInterface anyway as it could be a default key used by any object needing to identify a TokenStorageInterface instance and interact with it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On this second round I agree with you :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

@HeahDude HeahDude Sep 28, 2016

Choose a reason for hiding this comment

The 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This return type is not needed when "void".

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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);
}
}
}
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.