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 toCsrfTokenGenerator::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
- patch from #46 was committed to D8 8.x in #48/49
- Backport | Contributor task document for backporting a patch
User interface changes
API changes
Blocking
Follow-ups
- #2238087: Rebase SessionManager onto Symfony NativeSessionStorage improves ugly workarounds needed here
- when (if) #2238087: Rebase SessionManager onto Symfony NativeSessionStorage goes in, there are some @todo's introduced here which will need their own issues. (So that issue is tagged with needs follow-ups)
Comment | File | Size | Author |
---|---|---|---|
#109 | drupal-n2245003-109.patch | 8.9 KB | DamienMcKenna |
#108 | drupal-n2245003-108.patch | 2.12 KB | DamienMcKenna |
#105 | drupal-n2245003-105.patch | 8.75 KB | Solthun |
Comments
Comment #1
YesCT CreditAttribution: YesCT commentedissue 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)
Comment #2
znerol CreditAttribution: znerol commentedLet'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.Comment #3
sunThe proposal makes sense to me, especially given the referenced other issues.
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).Instead of copying things around, an
isset()
&&array_diff_key()
would be much faster.Comment #4
znerol CreditAttribution: znerol commentedIt 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?
Comment #5
amateescu CreditAttribution: amateescu commentedComment #6
andypostany reason for this argument? looks unused and not documented
Comment #7
znerol CreditAttribution: znerol commentedAdresses #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.
Comment #8
andypostAwesome and clean!
Comment #9
YesCT CreditAttribution: YesCT commentedjust 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. :)
Comment #10
catchWhy 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.
Comment #11
znerol CreditAttribution: znerol commentedThere 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.Comment #12
catchThose 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.
Comment #13
Damien Tournoud CreditAttribution: Damien Tournoud commented@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.
Comment #14
catchWell 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.
Comment #15
znerol CreditAttribution: znerol commentedThis 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().
Comment #16
znerol CreditAttribution: znerol commentedAnd another thing:
We do not add
form_token
for anonymous users. CSRF protected forms are not cacheable.Comment #17
catchArggh 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.
Comment #18
sunLike @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)
Comment #19
znerol CreditAttribution: znerol commented@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.
Comment #20
znerol CreditAttribution: znerol commentedI 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.
Comment #22
znerol CreditAttribution: znerol commentedAs discussed with @sun on IRC, rename the method
isSessionEmpty()
toisSessionObsolete()
and justify the emptiness-test in the @todo comment.Comment #24
znerol CreditAttribution: znerol commentedAlso 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.Comment #25
znerol CreditAttribution: znerol commentedComment #27
YesCT CreditAttribution: YesCT commentedthe interdiff in #22 is nice. :)
Comment #28
znerol CreditAttribution: znerol commentedIt does not make sense to introduce a helper method (
getTokenKey
) which makes things more complex. Just inline it intoCsrfTokenGenerator::get
andCsrfTokenGenerator::validate
in order to improve readability.I think this is ready, should it come back green from the test-bot.
Comment #30
damiankloip CreditAttribution: damiankloip commentedI 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.
Comment #31
damiankloip CreditAttribution: damiankloip commentedSomething 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.
Comment #33
damiankloip CreditAttribution: damiankloip commented31: 2245003-31.patch queued for re-testing.
Comment #35
sun@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 ofCsrfTokenManager
.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 theMetadataBag
. This built-in/hard-coded check withinSessionManager
is meant to be temporary for now.Comment #36
damiankloip CreditAttribution: damiankloip commentedOk, 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.
Comment #37
znerol CreditAttribution: znerol commentedThis 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...)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.
Comment #38
znerol CreditAttribution: znerol commentedComment #39
sunCleaned up docs/comments.
Comment #40
YesCT CreditAttribution: YesCT commentedSo. 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.
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
another nit to have this be a "sentence" , in other words, should have a period at the end.
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:
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:
is this sessiontestform meant to be reused for other more generic things? Or is this doc mentioning the specific test fine?
Comment #41
YesCT CreditAttribution: YesCT commentedComment #42
sunThanks! :-)
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.
Comment #43
damiankloip CreditAttribution: damiankloip commentedWhy 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.
Comment #44
znerol CreditAttribution: znerol commentedBecause
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 currentHEAD
, because the session itself is not touched byget()
there.Comment #45
damiankloip CreditAttribution: damiankloip commentedBut if it was not present in the session it would not ever read h the get() call?
Comment #46
znerol CreditAttribution: znerol commentedOn IRC @damiankloip pointed out that we can safely use
get()
from on the last line ofvalidate()
because there is already a conditional invalidate()
which makes sure thatget()
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).Comment #47
damiankloip CreditAttribution: damiankloip commentedI think the computeToken() method makes it clearer as to what is going on. Looks good
Comment #48
catchCommitted/pushed to 8.x, thanks!
Comment #50
catchActually 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.
Comment #51
YesCT CreditAttribution: YesCT commentedComment #52
YesCT CreditAttribution: YesCT commentedthat interdiff in #46 was not accurate.
#46 had un-intentional changes.
diffing the two patches (which I know is messy) is:
Comment #53
pwolanin CreditAttribution: pwolanin commentedso, 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?
Comment #54
YesCT CreditAttribution: YesCT commentedone 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...
Comment #55
znerol CreditAttribution: znerol commentedRe #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()).
Comment #56
znerol CreditAttribution: znerol commentedRe #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.
Comment #57
Island Usurper CreditAttribution: Island Usurper commentedHere 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.
Comment #59
Island Usurper CreditAttribution: Island Usurper commentedMissed changing isSecure() back to the global $is_https.
Comment #61
Island Usurper CreditAttribution: Island Usurper commentedHuh. Don't know how that test worked on my local site.
Changed
drupalPostForm()
todrupalPost()
.Comment #62
Island Usurper CreditAttribution: Island Usurper commentedComment #64
Island Usurper CreditAttribution: Island Usurper commentedForgot to close function documentation. Awesome.
Comment #65
Island Usurper CreditAttribution: Island Usurper commentedAnd to reset the status. Ugh.
Comment #66
Fabianx CreditAttribution: Fabianx commentedI 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.
Comment #67
Fabianx CreditAttribution: Fabianx commentedI 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!
Comment #68
znerol CreditAttribution: znerol commentedBefore 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.
Comment #69
Fabianx CreditAttribution: Fabianx commentedI 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?
Comment #70
znerol CreditAttribution: znerol commentedThe 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.
Comment #71
Fabianx CreditAttribution: Fabianx commentedOh, yes you are right.
In that case RTBC - it is no worse than before.
Comment #74
Fabianx CreditAttribution: Fabianx commentedBack to RTBC
Comment #77
znerol CreditAttribution: znerol commentedBack to RTBC
Comment #78
YesCT CreditAttribution: YesCT commented@Fabianx is the issue you mentioned in #69 still needed?
Comment #79
Fabianx CreditAttribution: Fabianx commentedNope, this is okay. I misread the patch, we still need to talk about anonymous tokens in general somewhere, but that is not critical.
Comment #80
David_Rothstein CreditAttribution: David_Rothstein commentedI 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.
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 :)
(minor) "token_based" => "token based"
Comment #81
Fabianx CreditAttribution: Fabianx commentedI 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!
Comment #82
znerol CreditAttribution: znerol commentedYes, thats right. It should be:
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).
Another option would be to continue with #961508-232: Dual http/https session cookies interfere with drupal_valid_token() for D7.
Comment #84
pwolanin CreditAttribution: pwolanin as a volunteer and at Acquia commentedFor 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.
Comment #85
znerol CreditAttribution: znerol commentedRe #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).Comment #86
mausolos CreditAttribution: mausolos commentedChecking 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?
Comment #87
mgiffordjust re-uploading for the bots. Can't test the patch in 64.
Comment #89
mausolos CreditAttribution: mausolos commentedIt 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.
Comment #90
mausolos CreditAttribution: mausolos commentedI'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;
Comment #94
DamienMcKennaFYI 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.
Comment #95
DamienMcKennaRerolled.
Comment #97
DamienMcKennaThe login attempt on line 555 of session.test results in a failed login attempt..
Comment #98
DamienMcKennaWhat's the code in testCsrfTokenWithMixedModeSsl() supposed to actually do?
Comment #99
DuneBLI 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
Comment #100
DamienMcKennaRerolled.
Comment #104
DuneBL#100 solve the #99 issues.
Many thanks for this updated patch.
There are still fews warning when applying it:
Comment #105
Solthun CreditAttribution: Solthun commentedRerolled.
Comment #106
Solthun CreditAttribution: Solthun commentedComment #108
DamienMcKennaQuick reroll to make it apply cleanly.
Comment #109
DamienMcKennaWhoops, sorry about that.
Comment #111
stephencamilo CreditAttribution: stephencamilo as a volunteer commentedComment #112
gregglesReverting to proper status.