Problem/Motivation

Drupal implements the Synchronized Token Pattern in order to protect against Cross-site request forgery. OWASP provides a nice description of the mechanism on their CSRF wiki page:

In general, developers need only generate this token once for the current session. After initial generation of this token, the value is stored in the session and is utilized for each subsequent request until the session expires. When a request is issued by the end-user, the server-side component must verify the existence and validity of the token in the request as compared to the token found in the session.

The mechanism implemented in Drupal varies in two details:

  • By supplying the optional $value parameter to CsrfTokenGenerator::get, the token can be varied for different purposes, e.g. per form-id. This is a very reasonable security hardening measure.
  • Instead of randomly generating the token, Drupal uses the session id as the token seed.

The idea behind the second point likely was that in order to make the token per-session it is enough to generate it from the session-id. However, this assumption leads to various problems. First and foremost to #961508: Dual http/https session cookies interfere with drupal_valid_token(). But also to a unnecessary dependency of the CsrfTokenGenerator to the custom way we generate session ids (#2238561: Use the default PHP session ID instead of generating a custom one).

Proposed resolution

Instead of using session-id as the token seed, generate it on demand and store it in the session.

Remaining tasks

User interface changes

API changes

Blocking

Follow-ups

CommentFileSizeAuthor
#109 drupal-n2245003-109.patch8.9 KBDamienMcKenna
#108 drupal-n2245003-108.patch2.12 KBDamienMcKenna
#105 drupal-n2245003-105.patch8.75 KBSolthun
#100 drupal-n2245003-100.patch8.75 KBDamienMcKenna
#95 drupal-n2245003-95.patch8.75 KBDamienMcKenna
#87 2245003-use-random-seed-instead-of-session-id-for-csrf-token-d7_2.patch8.76 KBmgifford
#64 2245003-use-random-seed-instead-of-session-id-for-csrf-token-d7.patch8.76 KBIsland Usurper
#61 2245003-use-random-seed-instead-of-session-id-for-csrf-token-d7.patch8.76 KBIsland Usurper
#59 2245003-use-random-seed-instead-of-session-id-for-csrf-token-d7.patch8.77 KBIsland Usurper
#57 2245003-use-random-seed-instead-of-session-id-for-csrf-token-d7.patch8.76 KBIsland Usurper
#46 interdiff.txt2.6 KBznerol
#46 2245003-use-random-seed-instead-of-session-id-for-csrf-token-46.diff12.19 KBznerol
#40 session.csrf_.40.patch10.75 KBYesCT
#40 interdiff-2245003-39-40.txt1.81 KBYesCT
#39 interdiff.txt5.04 KBsun
#39 session.csrf_.39.patch10.51 KBsun
#37 interdiff.txt1.1 KBznerol
#37 2245003-use-random-seed-instead-of-session-id-for-csrf-token-37.diff10.66 KBznerol
#31 interdiff-2245003-31.txt3.98 KBdamiankloip
#31 2245003-31.patch11.82 KBdamiankloip
#28 interdiff.txt1.97 KBznerol
#28 2245003-use-random-seed-instead-of-session-id-for-csrf-token-28.diff9.56 KBznerol
#24 interdiff.txt1.95 KBznerol
#24 2245003-use-random-seed-instead-of-session-id-for-csrf-token-24.diff10.08 KBznerol
#22 interdiff.txt2.46 KBznerol
#22 2245003-use-random-seed-instead-of-session-id-for-csrf-token-22.diff9.49 KBznerol
#20 2245003-use-random-seed-instead-of-session-id-for-csrf-token-20.diff9.11 KBznerol
#20 2245003-TEST-ONLY.diff5.69 KBznerol
#9 2245003-use-random-seed-instead-of-session-id-for-csrf-token-9.patch3.51 KBYesCT
#9 interdiff-2245003-7-9.txt2.32 KBYesCT
#7 interdiff.txt2.5 KBznerol
#7 2245003-use-random-seed-instead-of-session-id-for-csrf-token-7.diff3.5 KBznerol
#2 2245003-use-random-seed-instead-of-session-id-for-csrf-token.diff2.91 KBznerol
Support from Acquia helps fund testing for Drupal Acquia logo

Comments

YesCT’s picture

Issue summary: View changes
Priority: Normal » Critical
Related issues: +#961508: Dual http/https session cookies interfere with drupal_valid_token()

issue that is postponed on this is critical, so I'm pretty sure that means we want this one critical also. (or that that other issue is not actually postponed on this, or the other is not actually critical)

znerol’s picture

Status: Active » Needs review
FileSize
2.91 KB

Let's try this. Please note that the ugly workaround in SessionManager.php can be removed and replaced by a property on the session metadata bag as soon as #2238087: Rebase SessionManager onto Symfony NativeSessionStorage gets in.

sun’s picture

The proposal makes sense to me, especially given the referenced other issues.

  1. +++ b/core/lib/Drupal/Core/Session/SessionManager.php
    @@ -289,4 +289,27 @@ protected function isCli() {
    +  protected function isEmptySession(array $session = NULL) {
    

    Any chance to rename this to just isEmpty()?

    Or if "session" is really necessary, can we at least switch it around into isSessionEmpty()?

    Second, the $session parameter doesn't make sense to me (and is undocumented).

  2. +++ b/core/lib/Drupal/Core/Session/SessionManager.php
    @@ -289,4 +289,27 @@ protected function isCli() {
    +      $session = $_SESSION;
    ...
    +    unset($session['csrf_token_seed']);
    +
    +    if (empty($session)) {
    

    Instead of copying things around, an isset() && array_diff_key() would be much faster.

znerol’s picture

It probably would make sense to regenerate (or remove/invalidate) the CSRF token seed whenever the session-id is regenerated? Are there other occasions where this needs to happen?

amateescu’s picture

Issue tags: +Security
andypost’s picture

+++ b/core/lib/Drupal/Core/Session/SessionManager.php
@@ -289,4 +289,27 @@ protected function isCli() {
+  protected function isEmptySession(array $session = NULL) {

any reason for this argument? looks unused and not documented

znerol’s picture

Adresses #3 and #5. Please note that the isSessionEmpty method will be merged with the one to be introduced in #2238087: Rebase SessionManager onto Symfony NativeSessionStorage. Therefore please consider commenting over at the other issue on that subject.

Also addressed #4 by enforcing the seed to be regenerated whenever the session id changes.

andypost’s picture

Status: Needs review » Reviewed & tested by the community

Awesome and clean!

YesCT’s picture

just fixed nits like @todo coding style and third person verb on one line summaries of methods.
changed Tests ... to Determines ... on the isSessionEmpty() cause at first I thought it was a test. :)

catch’s picture

Status: Reviewed & tested by the community » Needs work

Why exactly do we treat the session as empty when it only has the crsf seed in it?

drupal_valid_token() does allow using tokens for anonymous users. Seems like the seed could get set differently in $_SESSION each request then discarded (since we don't save empty sessions).

Very quick look and I couldn't find test coverage for drupal_valid_token() with $skip_anonymous = TRUE.

znerol’s picture

Status: Needs work » Needs review

drupal_valid_token() does allow using tokens for anonymous users. [...] Very quick look and I couldn't find test coverage for drupal_valid_token() with $skip_anonymous = TRUE.

There is test-coverage for that in CsrfTokenGeneratorTest::testValidate(). However, please note that $skip_anonymous is nonsense, see #2245117: Remove the optional $skip_anonymous parameter from CsrfTokenGenerator::validate and remove the dependency on current_user service.

catch’s picture

Status: Needs review » Needs work

Those unit tests don't cover the bug I'm thinking of, it would only happen when the token is generated for one request, then validated on the next.

Damien Tournoud’s picture

@catch: we have no uses of fully anonymous CSRF tokens in core, and if #2245117: Remove the optional $skip_anonymous parameter from CsrfTokenGenerator::validate and remove the dependency on current_user service gets in we will completely stop supporting this.

Tokens will work as long as the user has an active session, before or after this patch.

On the other hand, trying to generate a CSRF token without an active session will not work (before or after this patch). I'm wonder if we should not try to trigger an exception in that case.

catch’s picture

Well we should either throw an exception, or start a session.

More generally we need to figure out what to do with forms that get rendered on cached pages.

session_id() returns an empty string when there is no session, so all sessionless users get the same token. That's the only reason that anonymous users can submit forms on cached pages at all. #1694574: drupal_process_form() deletes cached form + form_state despite still needed for later POSTs with enabled page caching is related.

That's already horribly broken, but the change here makes it more severe.

znerol’s picture

session_id() returns an empty string when there is no session

This is not true. In fact when rendering pages for users without a session we generate a session-id just in case something (especially CSRF token generator) wants to use it (see also #2238561: Use the default PHP session ID instead of generating a custom one). We do this in SessionManager::initialize().

znerol’s picture

And another thing:

More generally we need to figure out what to do with forms that get rendered on cached pages.

We do not add form_token for anonymous users. CSRF protected forms are not cacheable.

catch’s picture

Status: Needs work » Reviewed & tested by the community

Arggh I'm clearly not awake. I'd completely forgotten 'no form token for anonymous users' then it got worse from there.

I think this is OK then, except we should decide what happens when generating a csrf token with no session since that could still happen with the link case, even if forms prevent it now, opened #2247259: Either create a session or throw an exception when a csrf token is generated with no session for that.

sun’s picture

Status: Reviewed & tested by the community » Needs review

Like @catch, the only part I don't get is why we're considering a session as empty if it is actually not empty? (containing a CSRF token)

That could really use a more elaborate comment (in code) to explain the idea behind that. (if it is valid/legit in the first place)

znerol’s picture

@sun: I have a hard time to come up with a concise explanation. Basically this just mirrors the behavior we had before. In the original code the token seed was equal to the session-id. In the new code it is stored in the session itself. Recall that we need to clear up empty sessions in order to maximize cacheability. If we wouldn't ignore the token seed, we might introduce bugs killing the page cache.

By the way, it just stroke me that #15 likely was the reason for the static started property we removed in #2239181: SessionManager::isStarted() returns TRUE even after session_write_close() has been called.

znerol’s picture

I think the test for emptiness will become a no-brainer as soon as we have the token seed on the session metadata bag (see @todo in the patch). Reuploading #9 plus a test for #961508: Dual http/https session cookies interfere with drupal_valid_token(). The test-only patch is also the interdiff.

The last submitted patch, 20: 2245003-TEST-ONLY.diff, failed testing.

znerol’s picture

As discussed with @sun on IRC, rename the method isSessionEmpty() to isSessionObsolete() and justify the emptiness-test in the @todo comment.

Status: Needs review » Needs work
znerol’s picture

Also upon validation we do not want to generate a new token seed if it does not exist yet in the session, but rather return FALSE straight away.

znerol’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work
YesCT’s picture

the interdiff in #22 is nice. :)

znerol’s picture

Title: Consider using a random seed instead of the session_id for CSRF token generation » Use a random seed instead of the session_id for CSRF token generation
Status: Needs work » Needs review
FileSize
9.56 KB
1.97 KB

It does not make sense to introduce a helper method (getTokenKey) which makes things more complex. Just inline it into CsrfTokenGenerator::get and CsrfTokenGenerator::validate in order to improve readability.

I think this is ready, should it come back green from the test-bot.

Status: Needs review » Needs work
damiankloip’s picture

I am all for this idea, as it will make dual mode stuff much better amongst other things. Should we consider baking this into the session manager instead though? I am not sure about how the current patch divides the logic for ignoring/setting this value.

damiankloip’s picture

Status: Needs work » Needs review
FileSize
11.82 KB
3.98 KB

Something like this (quick patch, not tested). If you feel this is wrong, or don't like it. Please shoot down! :) You have looked into this stuff more than I.

Status: Needs review » Needs work

The last submitted patch, 31: 2245003-31.patch, failed testing.

damiankloip’s picture

31: 2245003-31.patch queued for re-testing.

The last submitted patch, 31: 2245003-31.patch, failed testing.

sun’s picture

@znerol's last patch looks good to me. (Thanks for adding the comment/@todo!)

@damiankloip: When @znerol and I discussed this patch, I was actually arguing the other way around: SessionManager shouldn't have any knowledge about any particular $_SESSION key. The csrf_token is a custom concept of CsrfTokenManager.

Ideally, isSessionObsolete() would trigger an event or something that allows all consumers to remove their data. If no data is left after dispatching, then the session is obsolete. @znerol said that something like that might be possible when we introduce the MetadataBag. This built-in/hard-coded check within SessionManager is meant to be temporary for now.

damiankloip’s picture

Ok, that seems reasonable. It's just a bit crappy having that knowledge spread around like that. As well as just having the globals in the token manager.

znerol’s picture

This is based on #28, fixing the failing tests. Because the token seed is not generated upon validation anymore, it is necessary to ensure that a token seed exists prior to testing for invalid-parameter detection (because invalid parameter exception is thrown from within Crypt::hmacBase64. Tbh those tests smell a little bit...)

Ok, that seems reasonable. It's just a bit crappy having that knowledge spread around like that. As well as just having the globals in the token manager.

Yes indeed. My plan is to register a subclass of MetadataBag as part of #2238087: Rebase SessionManager onto Symfony NativeSessionStorage, then add methods to get/set/clear the token seed and inject it into session manager as well as csrf token generator.

znerol’s picture

Status: Needs work » Needs review
sun’s picture

FileSize
10.51 KB
5.04 KB

Cleaned up docs/comments.

YesCT’s picture

  1. +++ b/core/lib/Drupal/Core/Session/SessionManager.php
    @@ -204,6 +204,13 @@ public function regenerate() {
    +    // @todo Move the token seed into Drupal\Core\Session\MetadataBag and notify
    +    //   the metadata bag when the token should be regenerated.
    +    // @see https://drupal.org/node/2238087
    

    So. I want to clarify. The @see to the issue is what this todo is dependent on, but not the issue that will do it, right?

    So I guess #2238087: Rebase SessionManager onto Symfony NativeSessionStorage would have a child follow-up to do this @todo? Unless it went in first, and then we could make the child here.

    Let's tag this needs follow-up incase we dont have issues for these @todo's when this get's rtbc.

  2. +++ b/core/modules/system/lib/Drupal/system/Tests/Session/SessionHttpsTest.php
    @@ -215,6 +216,75 @@ protected function testHttpsSession() {
    +   * Ensure that a CSRF form token is shared in SSL mixed mode.
    

    fixed nit to have one line summaries start with a third person verb.

    "summary lines must start with a third person singular present tense verb, and they must explain what the function does. Example: "Calculates the maximum weight for a list.""
    https://drupal.org/node/1354#functions

  3. +++ b/core/modules/system/tests/modules/session_test/lib/Drupal/session_test/Form/SessionTestForm.php
    @@ -0,0 +1,51 @@
    + * Contains \Drupal\session_test\Form\SessionTestForm
    

    another nit to have this be a "sentence" , in other words, should have a period at the end.

  4. Also the name of the class, when I changed it to be a third person verb, I noticed that the name includes "session" but to comment did not.
    /**
     * Provides the test config edit forms.
     */
    class SessionTestForm extends FormBase {
    

    So, I looked at the form, it just saves one text field called input.

    So it looks like this just needed to be any old form that could be saved.

    It's used here:

    session_test.form:
    +  path: '/session-test/form'
    +  defaults:
    +    _form: '\Drupal\session_test\Form\SessionTestForm'
    +    _title: 'Test form'
    +  requirements:
    +    _access: 'TRUE'
    

    Maybe we could explain why we need a form in order to test session things?
    That it is used in testCsrfTokenWithMixedModeSsl

    And I dont see where it is actually editing config...

    so I ended up with:

    /**
     * Provides a form that can be submitted, to test session tokens.
     *
     * @see \Drupal\system\Tests\Session\SessionHttpsTest::testCsrfTokenWithMixedModeSsl()
     */
    

    is this sessiontestform meant to be reused for other more generic things? Or is this doc mentioning the specific test fine?

YesCT’s picture

sun’s picture

Status: Needs review » Reviewed & tested by the community

Thanks! :-)

I don't think we need (yet) another follow-up for that @todo, since we already have a bunch of issues that are trying to refactor this code entirely.

damiankloip’s picture

Status: Reviewed & tested by the community » Needs review
+++ b/core/lib/Drupal/Core/Access/CsrfTokenGenerator.php
@@ -70,7 +73,10 @@ public function get($value = '') {
+    return $token === Crypt::hmacBase64($value, $_SESSION['csrf_token_seed'] . $this->privateKey->get() . drupal_get_hash_salt());

Why can't we just call get() again here? You are already checking the session 'csrf_token_seed' value above and returning before this if it's not there.

znerol’s picture

Why can't we just call get() again here?

Because get() ensures that there is a token seed on the session and generates a new one if necessary. This is not desirable during token validation. We do not have this problem in current HEAD, because the session itself is not touched by get() there.

damiankloip’s picture

But if it was not present in the session it would not ever read h the get() call?

znerol’s picture

On IRC @damiankloip pointed out that we can safely use get() from on the last line of validate() because there is already a conditional in validate() which makes sure that get() is never called when the token seed does not exist in the session. This is certainly true, but let's fight the code duplication properly (which I think is the reason for the concern).

damiankloip’s picture

Status: Needs review » Reviewed & tested by the community

I think the computeToken() method makes it clearer as to what is going on. Looks good

catch’s picture

Status: Reviewed & tested by the community » Fixed

Committed/pushed to 8.x, thanks!

  • Commit b73b86b on 8.x by catch:
    Issue #2245003 by znerol, YesCT, sun, damiankloip: Use a random seed...
catch’s picture

Version: 8.x-dev » 7.x-dev
Status: Fixed » Patch (to be ported)

Actually if we're ever going to fix this in 7.x, it'll probably be a backport from here.

Marking #961508: Dual http/https session cookies interfere with drupal_valid_token() as duplicate.

YesCT’s picture

Issue summary: View changes
Issue tags: +Needs backport to D7
YesCT’s picture

that interdiff in #46 was not accurate.

#46 had un-intentional changes.

diffing the two patches (which I know is messy) is:

548 $ diff session.csrf_.40.patch 2245003-use-random-seed-instead-of-session-id-for-csrf-token-46.diff
2c2
< index 9918610..b847db4 100644
---
> index 9918610..7d68750 100644
5c5,24
< @@ -55,7 +55,10 @@ public function __construct(PrivateKey $private_key) {
---
> @@ -38,7 +38,7 @@ public function __construct(PrivateKey $private_key) {
>    /**
>     * Generates a token based on $value, the user session, and the private key.
>     *
> -   * The generated token is based on the session ID of the current user. Normally,
> +   * The generated token is based on the session of the current user. Normally,
>     * anonymous users do not have a session, so the generated token will be
>     * different on every page request. To generate a token for users without a
>     * session, manually start a session prior to calling this function.
> @@ -47,15 +47,19 @@ public function __construct(PrivateKey $private_key) {
>     *   (optional) An additional value to base the token on.
>     *
>     * @return string
> -   *   A 43-character URL-safe token for validation, based on the user session
> -   *   ID, the hash salt provided by drupal_get_hash_salt(), and the
> +   *   A 43-character URL-safe token for validation, based on the token seed,
> +   *   the hash salt provided by drupal_get_hash_salt(), and the
>     *   'drupal_private_key' configuration variable.
>     *
>     * @see drupal_get_hash_salt()
13c32,33
< +    return Crypt::hmacBase64($value, $_SESSION['csrf_token_seed'] . $this->privateKey->get() . drupal_get_hash_salt());
---
> +
> +    return $this->computeToken($_SESSION['csrf_token_seed'], $value);
17c37
< @@ -70,7 +73,10 @@ public function get($value = '') {
---
> @@ -70,7 +74,28 @@ public function get($value = '') {
25c45,63
< +    return $token === Crypt::hmacBase64($value, $_SESSION['csrf_token_seed'] . $this->privateKey->get() . drupal_get_hash_salt());
---
> +
> +    return $token === $this->computeToken($_SESSION['csrf_token_seed'], $value);
> +  }
> +
> +  /**
> +   * Generates a token based on $value, the token seed, and the private key.
> +   *
> +   * @param string $seed
> +   *   The per-session token seed.
> +   * @param string $value
> +   *   (optional) An additional value to base the token on.
> +   *
> +   * @return string
> +   *   A 43-character URL-safe token for validation, based on the token seed,
> +   *   the hash salt provided by drupal_get_hash_salt(), and the
> +   *   'drupal_private_key' configuration variable.
> +   */
> +  protected function computeToken($seed, $value = '') {
> +    return Crypt::hmacBase64($value, $seed . $this->privateKey->get() . drupal_get_hash_salt());
30c68
< index 1c37dc3..f7c22a3 100644
---
> index 1c37dc3..6a08c6d 100644
51c89
< @@ -204,6 +204,13 @@ public function regenerate() {
---
> @@ -204,6 +204,14 @@ public function regenerate() {
55,57c93,96
< +    // @todo Move the token seed into Drupal\Core\Session\MetadataBag and notify
< +    //   the metadata bag when the token should be regenerated.
< +    // @see https://drupal.org/node/2238087
---
> +    // @todo As soon as https://drupal.org/node/2238087 lands, the token seed
> +    //   can be moved onto Drupal\Core\Session\MetadataBag. The session manager
> +    //   then needs to notify the metadata bag when the token should be
> +    //   regenerated.
65c104
< @@ -289,4 +296,28 @@ protected function isCli() {
---
> @@ -289,4 +297,30 @@ protected function isCli() {
70c109
< +   * Determines whether the session does not contain user data.
---
> +   * Determines whether the session contains user data.
73,74c112,113
< +   *   TRUE when the session does not contain any values and can be destroyed,
< +   *   FALSE otherwise.
---
> +   *   TRUE when the session does not contain any values and therefore can be
> +   *   destroyed.
83,89c122,130
< +    // @todo Anonymous users normally do not get a CSRF token, but if they do,
< +    //   then the originating code is responsible for cleaning up the session
< +    //   once obsolete. Since that is not guaranteed to be the case, this check
< +    //   force-ignores the CSRF token, so as to avoid performance regressions.
< +    // @todo Move the CSRF token seed into \Drupal\Core\Session\MetadataBag, so
< +    //   it will be ignored automatically.
< +    // @see https://drupal.org/node/2238087
---
> +    //
> +    // @todo Anonymous users should not get a CSRF token at any time, or if they
> +    //   do, then the originating code is responsible for cleaning up the
> +    //   session once obsolete. Since that is not guaranteed to be the case,
> +    //   this check force-ignores the CSRF token, so as to avoid performance
> +    //   regressions.
> +    //   As soon as https://drupal.org/node/2238087 lands, the token seed can be
> +    //   moved onto \Drupal\Core\Session\MetadataBag. This will result in the
> +    //   CSRF token to be ignored automatically.
95c136
< index 02523d0..18bd3bb 100644
---
> index 02523d0..f1779f4 100644
98c139
< @@ -10,9 +10,10 @@
---
> @@ -10,6 +10,7 @@
105,109c146
< - * Ensure that when running under HTTPS two session cookies are generated.
< + * Tests that when running under HTTPS two session cookies are generated.
<   */
<  class SessionHttpsTest extends WebTestBase {
<
---
>   * Ensure that when running under HTTPS two session cookies are generated.
174c211
< +   * Returns the token of the current form.
---
> +   * Return the token of the current form.
188c225
< index 0000000..e611b1e
---
> index 0000000..706c79c
191c228
< @@ -0,0 +1,53 @@
---
> @@ -0,0 +1,51 @@
196c233
< + * Contains \Drupal\session_test\Form\SessionTestForm.
---
> + * Contains Drupal\session_test\Form\SessionTestForm
205,207c242
< + * Provides a form that can be submitted, to test session tokens.
< + *
< + * @see \Drupal\system\Tests\Session\SessionHttpsTest::testCsrfTokenWithMixedModeSsl()
---
> + * Form controller for the test config edit forms.
262c297
< index 8607304..a205731 100644
---
> index 8607304..81ac83d 100644
270c305
< +    $this->generator->get();
---
> +    $ignored_token = $this->generator->get();
280c315
< +    $this->generator->get();
---
> +    $ignored_token = $this->generator->get();
pwolanin’s picture

so, this looks like a reasonable change, but I don't really see how it adds to security other than by decoupling session ID itself from CSRF tokens. However, if the session cookie is sniffed, you can get the token values anyhow by becoming authenticated?

YesCT’s picture

one of the changes not shown in the interdiff
< + $this->generator->get();
---
> + $ignored_token = $this->generator->get();

was adding an unused var.

adding #2081153: Remove unused local variables from core/tests as related now...

znerol’s picture

Re #53 By decoupling the CSRF tokens from the session ID it is possible to make mixed-mode SSL actually work for forms. Previously it was not possible to submit a form via HTTPS when it was rendered on a HTTP response, because CSRF tokens did not match in that case (see #961508: Dual http/https session cookies interfere with drupal_valid_token()).

znerol’s picture

Re #52: It looks like #46 does not contain the documentation changes of #39 and #40. I guess I've been working from an outdated local branch which was based on #37 in order to get to #46. Sorry for that.

Island Usurper’s picture

Status: Patch (to be ported) » Needs review
FileSize
8.76 KB

Here is a backport to Drupal 7, as best as I can figure out. The only thing I couldn't figure out was where Drupal\Tests\Core\Access\CsrfTokenGeneratorTest lives. Do we not have tests for drupal_random_key() or drupal_hmac_base64() in D7?

I deliberately didn't backport #2245117: Remove the optional $skip_anonymous parameter from CsrfTokenGenerator::validate and remove the dependency on current_user service since that's an API change, so if $skip_anonymous is passed, then the token will be considered valid.

Status: Needs review » Needs work
Island Usurper’s picture

Status: Needs work » Needs review
FileSize
8.77 KB

Missed changing isSecure() back to the global $is_https.

Status: Needs review » Needs work
Island Usurper’s picture

Huh. Don't know how that test worked on my local site.

Changed drupalPostForm() to drupalPost().

Island Usurper’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work
Island Usurper’s picture

Forgot to close function documentation. Awesome.

Island Usurper’s picture

Status: Needs work » Needs review

And to reset the status. Ugh.

Fabianx’s picture

Status: Needs review » Needs work

I think storing something in $_SESSION will actually write the session?

Did you verify that a SESSION cookie is not send to the user if drupal_session_is_obsolete() is true.

For most reverse proxies, it is not enough to send public headers, they also check for existance of SESSION cookie.

Wouldn't it be better to _only_ use the SESSION seed if we have an authenticated user in the first place?

Is drupal_get_token() defined if a user is anonymous? What does it 'mean' for the user - if the token is either shared anyway (cached) or not working (cached)?

Or should any drupal_get_token() call actually always create a SESSION to distinguish for anonymous users, but then the obsolete part is pretty senseless.

Fabianx’s picture

Version: 7.x-dev » 8.0.x-dev
Issue tags: +needs revert

I am really sorry for all that worked on this issue, but after reviewing my last comment even the original patch in 8.0.x is broken.

I spoke with catch and I am putting this back to 8.0.x and I think the sanest would be to revert the patch for now and do a new one for 8.0.x first.

Points that should be fixed are:

- There is two approaches to do drupal_get_token() for anonymous users:

a) Create one token that is shared - this allows it to work with caching, but does only prevent CSRF attacks if the attacker does not visit the site to get the current token, so not really useful. In this case performance would be kept, but security would suffer.

b) Create a token that is not shared, but treat the anonymous user as 'logged in anonymous' from then on with no caching, etc. in place anymore. This is no worse than opening a session before in any module like a shopping cart. If someone calls drupal_get_token for anon users they need to fix their code according to their business logic to make sense.

- The session_is_obsolete() stuff should be completely removed - if we have a SESSION we have a session, no way around that.

- Either do not set SESSION as seed for anon users (and use a fixed seed) OR open a SESSION, but keep it open.

Overall I agree with the random seed in SESSION and overall we need to speak about tokens in core with render_caching more - see e.g. cacheable_csrf module and if the hardening could be optional for system that need to cache more, but that is follow-up.

Thanks and sorry for being too late for this party!

znerol’s picture

Before and after this patch token generation and lazy sessions in Drupal 8 work exactly the same as they did in Drupal 7 (but without the mixed mode SSL headache). Also the interaction with page caching is not different at all.

@Fabianx if you disagree with what I wrote above then please specify exactly the scenario when you think things work differently. On the other hand if you think token generation / lazy sessions or their interaction with the page cache is broken (which would mean it is also broken for Drupal 7), then this is another issue.

In my opinion the patch should not be reverted unless somebody can proof that the patch in fact changed the behavior.

Fabianx’s picture

Version: 8.0.x-dev » 7.x-dev
Status: Needs work » Needs review
Issue tags: -needs revert

I was able to test it and yes it works correctly - I misread the patch.

I still think anonymous token generation is totally broken, but that is indeed another issue.

Question on the D7 patch:

- Why not a simple isset($_SESSION['csrf_token']) instead of the complicated count(array_intersect_key(...)) == 0 thingy?

znerol’s picture

Why not a simple isset($_SESSION['csrf_token']) instead of the complicated count(array_intersect_key(...)) == 0 thingy?

The code is supposed to detect whether anything beside the csrf_token_seed is set. If the session only contains the seed, it is considered obsolete.

Fabianx’s picture

Status: Needs review » Reviewed & tested by the community

Oh, yes you are right.

In that case RTBC - it is no worse than before.

Status: Reviewed & tested by the community » Needs work

Status: Needs work » Needs review
Fabianx’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC

Status: Reviewed & tested by the community » Needs work

Status: Needs work » Needs review
znerol’s picture

Status: Needs review » Reviewed & tested by the community

Back to RTBC

YesCT’s picture

@Fabianx is the issue you mentioned in #69 still needed?

Fabianx’s picture

Nope, this is okay. I misread the patch, we still need to talk about anonymous tokens in general somewhere, but that is not critical.

David_Rothstein’s picture

Status: Reviewed & tested by the community » Needs review
 function drupal_valid_token($token, $value = '', $skip_anonymous = FALSE) {
   global $user;
-  return (($skip_anonymous && $user->uid == 0) || ($token === drupal_get_token($value)));
+  if (!$skip_anonymous && empty($_SESSION['csrf_token_seed'])) {
+    return FALSE;
+  }
+
+  return (($skip_anonymous && $user->uid == 0) || ($token === drupal_compute_token($_SESSION['csrf_token_seed'], $value)));

I am not sure the logic here is correct. If the caller set $skip_anonymous to TRUE but the current user is not anonymous, don't we need to bail out early when $_SESSION['csrf_token_seed'] is empty then also? Otherwise, seems like there would be PHP notices, at least.

-  if (empty($user->uid) && empty($_SESSION)) {
+  if (empty($user->uid) && drupal_session_obsolete()) {

I have to say these kinds of changes look scary, though I understand why they're there. Essentially it's deliberately preserving an existing bug (where drupal_get_token() doesn't work at all for anonymous users who don't have a session) for the sake of not hurting performance :)

But something just seems fishy about putting data in $_SESSION and then throwing it out later in the page request... any code that is examining $_SESSION in the interim could get confused by that.

I am wondering if another way to do this would be to have drupal_get_token() not set the session value directly (at least for anonymous users), but rather do something to indicate that it wants a session to be set. Call it drupal_set_conditional_session_data($key, $value) or something like that. Later on, drupal_session_commit() checks the data from that function and and adds it to $_SESSION only if something else is already in there (and also removes it from $_SESSION if it's already there from a previous request but $_SESSION is otherwise empty). In that case drupal_session_initialize() wouldn't need to change at all, I don't think.

Does that make sense? Pros/cons? I think that might make the code easier to understand plus a bit safer as well (this patch is already scary enough for a stable release as it is :)

+ * Generates a token_based on $value, the token seed, and the private key.

(minor) "token_based" => "token based"

Fabianx’s picture

I 100% agree with #80, that have also been my concerns. Just opening a SESSION to then not use it was like ... ugh ... for me.

It still all works correctly, but yes scary changes.

I agree that your way is much better with the conditional session data.

Thanks!

znerol’s picture

If the caller set $skip_anonymous to TRUE but the current user is not anonymous, don't we need to bail out early when $_SESSION['csrf_token_seed'] is empty then also? Otherwise, seems like there would be PHP notices, at least.

Yes, thats right. It should be:

+  if (!($skip_anonymous && $user->uid == 0) && empty($_SESSION['csrf_token_seed'])) {
But something just seems fishy about putting data in $_SESSION and then throwing it out later in the page request... any code that is examining $_SESSION in the interim could get confused by that.

Also true. This would indeed affect one of my own D7 projects. Note to @Fabianx: In D8 we cannot get around that because the Symfony session component always wants to set stuff on the session (i.e., session bags).

this patch is already scary enough for a stable release as it is :)

Another option would be to continue with #961508-232: Dual http/https session cookies interfere with drupal_valid_token() for D7.

pwolanin’s picture

For 8, did this change actually weaken the CSRF prevention system compared to what it could be since the seed is stored plain in the session data?

Now this makes it a little closer to being able to CSRF you with values from a DB dump, with your protect falling back to maybe only the hope that the hash salt is in the code or on the filesystem and not exposed at the same time.

If we generated the token based on something from the cookie (the session ID) sent by the browser, Drupal would never be storing the raw value in 8.

znerol’s picture

Re #84 I do not think so, because in order to generate a valid token an attacker needs to know the hash salt in addition to the private key and the token seed. AFAIK anyone who is in position of the hash salt/private key is also able to generate a password reset link and hence does not have to resort to CSRF.

On the other hand it might be definitely interesting to get rid of the token seed, since it introduced a dependency of the session manager to the token generator which is unfortunate. Retrieving the session id directly from the cookie (instead of using the session_id() function as it was before), would resolve the issues mentioned in the IS. However, that might lead to problems when tokens are to be generated in the same request as a login is performed / the session is regenerated / a new session is started (lazily).

mausolos’s picture

Checking status on this critical issue. Has anyone had a chance to consider #80-82 in the last year in terms of a new patch we could try?

mgifford’s picture

Status: Needs review » Needs work
mausolos’s picture

It looks like this is still broken, then. Did I see right from the test that it's failing in a mixed-mode scenario? Seems like that would be predicted, based on the above conversations.

mausolos’s picture

I'm sure this is a stupid question, but is the test system configured to support the https conf setting that allows mixed-mode?
$conf['https'] = TRUE;

  • catch committed b73b86b on 8.3.x
    Issue #2245003 by znerol, YesCT, sun, damiankloip: Use a random seed...

  • catch committed b73b86b on 8.3.x
    Issue #2245003 by znerol, YesCT, sun, damiankloip: Use a random seed...
DamienMcKenna’s picture

FYI the patch in #87 resolves a problem I had on one site where SecurePages was causing an infinite loop of redirects. I'll try to help fix the remaining tests.

Status: Needs review » Needs work

The last submitted patch, 95: drupal-n2245003-95.patch, failed testing.

DamienMcKenna’s picture

The login attempt on line 555 of session.test results in a failed login attempt..

DamienMcKenna’s picture

What's the code in testCsrfTokenWithMixedModeSsl() supposed to actually do?

DuneBL’s picture

I have applied #95:
Hunk #1 FAILED at 5187
And after that I could not use the forms in my site as I got every time the following message:
The form has become outdated. Copy any unsaved work in the form below

DamienMcKenna’s picture

Status: Needs work » Needs review
FileSize
8.75 KB

Rerolled.

Status: Needs review » Needs work

The last submitted patch, 100: drupal-n2245003-100.patch, failed testing.

  • catch committed b73b86b on 8.4.x
    Issue #2245003 by znerol, YesCT, sun, damiankloip: Use a random seed...

  • catch committed b73b86b on 8.4.x
    Issue #2245003 by znerol, YesCT, sun, damiankloip: Use a random seed...
DuneBL’s picture

#100 solve the #99 issues.
Many thanks for this updated patch.
There are still fews warning when applying it:

patching file includes/common.inc
Hunk #1 succeeded at 5209 (offset 7 lines).
Hunk #2 succeeded at 5245 (offset 7 lines).
patching file includes/session.inc
patching file modules/simpletest/tests/session.test
patching file modules/simpletest/tests/session_test.module
Solthun’s picture

Status: Needs work » Needs review

Status: Needs review » Needs work

The last submitted patch, 105: drupal-n2245003-105.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

DamienMcKenna’s picture

Status: Needs work » Needs review
FileSize
2.12 KB

Quick reroll to make it apply cleanly.

DamienMcKenna’s picture

Whoops, sorry about that.

Status: Needs review » Needs work

The last submitted patch, 109: drupal-n2245003-109.patch, failed testing. View results

stephencamilo’s picture

Status: Needs work » Closed (won't fix)
greggles’s picture

Status: Closed (won't fix) » Needs work

Reverting to proper status.