PHP - CSRF - How to make it works in all tabs?
Asked Answered
P

2

17

I have read about how to prevent CSRF-attacks in the last days. I am going to update the token in every pageload, save the token in the session and make a check when submitting a form.

But what if the user has, lets say 3 tabs open with my website, and I just store the last token in the session? This will overwrite the token with another token, and some post-action is going to fail.

Do I need to store all tokens in the session, or is there a better solution to get this working?

Portuguese answered 22/4, 2010 at 23:10 Comment(0)
K
30

Yes, with the stored-token approach you'd have to keep all generated tokens just in case they came back in at any point. A single stored-token fails not just for multiple browser tabs/windows but also for back/forward navigation. You generally want to manage the potential storage explosion by expiring old tokens (by age and/or number of tokens issued since).

Another approach that avoids token storage altogether is to issue a signed token generated using a server-side secret. Then when you get the token back you can check the signature and if it matches you know you signed it. For example:

// Only the server knows this string. Make it up randomly and keep it in deployment-specific
// settings, in an include file safely outside the webroot
//
$secret= 'qw9pDr$wEyq%^ynrUi2cNi3';

...

// Issue a signed token
//
$token= dechex(mt_rand());
$hash= hash_hmac('sha1', $token, $secret);
$signed= $token.'-'.$hash;

<input type="hidden" name="formkey" value="<?php echo htmlspecialchars($signed); ?>">

...

// Check a token was signed by us, on the way back in
//
$isok= FALSE;
$parts= explode('-', $_POST['formkey']);
if (count($parts)===2) {
    list($token, $hash)= $parts;
    if ($hash===hash_hmac('sha1', $token, $secret))
        $isok= TRUE;
}

With this, if you get a token with a matching signature you know you generated it. That's not much help in itself, but then you can put extra things in the token other than the randomness, for example user id:

$token= dechex($user->id).'.'.dechex(mt_rand())

...

    if ($hash===hash_hmac('sha1', $token, $secret)) {
        $userid= hexdec(explode('.', $token)[0]);
        if ($userid===$user->id)
            $isok= TRUE

Now each form submission has to be authorised by the same user who picked up the form, which pretty much defeats CSRF.

Another thing it's a good idea to put in a token is an expiry time, so that a momentary client compromise or MitM attack doesn't leak a token that'll work for that user forever, and a value that is changes on password resets, so that changing password invalidates existing tokens.

Karakoram answered 22/4, 2010 at 23:45 Comment(20)
Er, yeah, this will work globally. Multiple tabs and multiple windows will be fine.Karakoram
@Karakoram No, the session will be overwritten?Portuguese
If we're talking about the signed-token method above, nothing is written to the session. That's the point: by passing out signed messages in hidden fields you can know they came from you and trust them when they come back to you as a submission in the future, without having to remember any state. You don't need a session at all.Karakoram
I wonder how it is possible to "un-hash" the expiry time in the token to check if the token has been expired.Ress
You don't hash the time, you include it directly in the token passed to the client side. An attacker can see what the timestamp is, but they can't change it as that would invalidate the hash the server used to sign the token.Karakoram
Why do you use htmlspecialchars on a string which can only contain hex characaters anyway?Synchrotron
@ThiefMaster: Because the HTML output part of the app (the view) should not need to know what exactly that string will contain: separation of concerns. Encoding is the right thing to do when you output a string to HTML; skipping the encoding in this case would be a premature optimisation, saving one (trivial) encode call at the expense that (a) if I change the token format in the future I might have an error, and (b) when reviewing the code it's not obvious just from looking at the template that the string is safe.Karakoram
It's explode('-', $_POST['formkey']) not explode($_POST['formkey'], '-')Worshipful
@jules: thanks, fixed. One day I'll start actually testing code I post :-)Karakoram
Isn't showing a part of the message that generates the final hash makes things easier to an attacker? Or it doesn't affect as there's still a part of the hash that is keeped secret?Venireman
Could you give an example of what I can store on forms which users aren't logged in?Katonah
@user1831020: there isn't usually a CSRF attack on forms where the user isn't logged in, since the attacker could usually just as easily submit the form themselves. For the case where you have a different access limitation than being logged in, you can encode that - for example if your service is limited depending on the IP address the user is coming from, you could include that address in the token. In the general case you could also give the user a cookie which is included in the submission hash.Karakoram
You missed the parameter switcheroo of explode() in the example with the userid. Also, addressing the values directly in the result of explode() (eg explode(".", $token)[0]) seems to not work.Causerie
Fixed & changed SHA-1 to HMAC-SHA-1 as this is exactly what MACs are for. explode(".", $token)[0] works fine for me though...?Karakoram
I've implemented something very similar to this. When I look at the token in the source the user id can be seen...it looks like this 2.443j2sah4j0du3rhchdh483 where 2 is the userid. What is a good way to also obsfucate or encode this somehow?Aerology
@Mike: if you need to hide the signed data from the user you'll need to use actual encryption, which is where it gets more hairy (and the output will be much bigger as it'll be padded to a block). PGP is a good way to avoid the pitfalls of implementing encryption yourself (eg see the GnuPG extension if you are using PHP). This is quite a high overhead though... do you care that much about hiding the user ID?Karakoram
@Karakoram I have a question. What if an attacker simply reads the hidden field once and then tries to send the request from external website with same csrf signed code all the time? I guess our website will approve it all the time. How can we prevent this? I know we can put a referrer check but that also can be hacked.Tarrant
The token contains the user ID. An attacker can't read a token generated for another user.Karakoram
@Karakoram I was almost going to thank you for this snippet, but then realized this is worthless. It might generate a "new" signed token each time one if issued, but every single token generated is valid forever, so an attacker only need to get a single token and it will work indefinitely...Extractor
@Vallentin: yes, which is why “another thing it's a good idea to put in a token is an expiry time...”Karakoram
S
1

You could simply use a token which is persistent for the current session or even the user (e.g. a hash of the hash of the user's password) and cannot be determined by a third party (using a hash of the user's IP is bad for example).

Then you don't have to store possibly tons of generated tokens and unless the session expires (which would probably require the user to login again anyway) the user can use as many tabs as he wants.

Synchrotron answered 30/1, 2011 at 10:30 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.