Csrf Token Invalid For Long Sessions And Multiple Tabs

My application tends to live a long live in the client space and for several reasons the user might still have a web page open where one CSRF token is used, but where the CSRF token is no longer valid (session expiry after disconnect, login/logout in other tab/…).

I alread fixed the fact that the enduser might open several tabs pointing to the site "at the same time" (when the closed navigator is reopened with remembered tab urls), which implied a change in CHttpRequest (by extending the class in a YHttpRequest I created and add some CSRF caching there)[size=2]. [/size]

[size=2]

[/size]

Next thing I would like to do is to force a reload of the page when the CSRF token is no longer valid.

I can see two methods:

a) Polling the server and let the server decide if the CSRF token is still valid;

B) Compare locally with the YII_CSRF_TOKEN cookie.

In both cases the ideal would be a blocking popup with a button for the user to proceed with reloading the page.

I am checking if somebody already did this to avoid writing the code. I haven’t seen an extension for this, but there could very well be one for it. I’ld appreciate the sharing ;-).

I did it a while ago like this:


function manageError(jqXHR) {

    dialog = $('<div id="errorDialog"></div>').dialog({

        autoOpen: false,

        closeOnEscape: true,

        modal: true,

        resizable: false,

        show: {effect: 'drop', direction: 'up'},

        close: function(event, ui) {

            dialog.dialog('destroy');

            $('#errorDialog').remove();

            window.location.reload();

        },

        title: 'Something went wrong with your request'

    });


    if ((jqXHR.status == 400) && (jqXHR.responseText === 'Here you can put a string that is surely in the response text when CSRF is no longer valid')) {

        CSRF_error = '<h1>You have exceeded the time allocated to filling this form in. This page is going to refresh.</h1>';

    }


    dialog.html(CSRF_error);

    dialog.dialog('open');        

}


…


// I use this ajax snippet as a separate ajax call, but you can use for long polling

$.ajax({

    'type':'POST',

    'url':url,

    'cache':false,

    'error':function(jqXHR){

        manageError(jqXHR);

    }

});

Edit: I am using here jQuery+jQuery UI. The latter is not mandatory as there are several modal window libraries out there, you just use the one that suits you.

Hi

Good to see that others experienced the same issue & thanks for sharing.

I am kind of thinking of a solution that does not require me to add this to every ajax call which is built with Yii’s utility functions.

At the javascript level it would be interesting to use one of the methods that allow to hook into $.ajax events (I believe that is possible).

I almost preferred the initial post (in french) as that almost avoids me to translate although my rule is to use Yii::t everywhere.

I’ll probably resort to checking the YII_CSRF_TOKEN cookie value with the known value in the page to void that the user enters a lot of configuration which he would have to re-enter when his token expired.

Well that app was an ajax-based one so it was not a big deal in my case… But you can see here: http://api.jquery.com/ajaxError/ it should work

So you had time to see it :)

But what’s the purpose then of having CSRF expiration? Shouldn’t you just extend its validity? Also you could use the not-so-rare scenario where seconds before expiration, you ask the user something like “Are you still here?” but I guess it really depends on your app.

Actually, it is the first post that arrives in the email notification ;-).

I do not think that there is actual management of CSRF expiration itself - it has the lifetime of the session.

The user can loose the session for several reasons:

  • loss of internet connection for an extended period (no internet, or hibernation of the computer);

  • close/open the connection in a seperate tab;

  • close/open the navigator (open several tabs again) -> fixed this issue by caching the CSRF token for 5 seconds (for given IP, browser, …);

  • backoffice ‘superuser’ function. For support reasons the operator logs in to the user session and forgets that he opened several accounts.

I created the following utility function.

I call this function in my layout file to apply it to all controllers using the layout:




    public static function MonitorSession($timeoutSeconds=2,$jsActionCode=null) {

        $timeoutMs=$timeoutSeconds*1000;

        Yii::app()->clientScript->registerCoreScript('jquery');

        if(Yii::app()->request->enableCsrfValidation) {

            $csrfTokenName = CJavaScript::encode(Yii::app()->request->csrfTokenName);

            $csrfTokenValueRegex= CJavaScript::encode('"'.Yii::app()->request->csrfToken.'"');


            if($jsActionCode===null) {

                $expiredMessage=CJavaScript::encode(Yii::t('app','Your session expired and this page must be reloaded.'));

                $jsActionCode=<<<EOJS

 					alert($expiredMessage);

 					location.reload(true);

EOJS;

            }

            $jsMonitorScript=<<<EOJS

                    (function($) {

                        "strict";

                        function checkCSRF() {

                            if(!$.cookie)return;

                            var csrf=$.cookie($csrfTokenName);

                            if(csrf===null||!csrf.match($csrfTokenValueRegex)) {

                                window.clearInterval(timer);

                                $jsActionCode

                            }

                        }

                        window.setInterval(checkCSRF,$timeoutMs);

                    })(jQuery);

EOJS;

                Yii::app()->clientScript->registerScript(__FILE__."#MonitorSession", $jsMonitorScript,CClientScript::POS_READY);

        }

    }

By doing this, I got this alert on initial page load (after deleting the YII_CSRF_TOKEN in the cookie) and it turns out that my page has differrent YII_CSRF_TOKENs in the same HTML page! That can not be ok and might be the real reason behind most CSRF_TOKEN issues.

I updated my code to add checking for the user Id.

( I know that there is some code duplication here, but for the purpose of sharing that is not critical)




    public static function MonitorSession($timeoutSeconds=2,$jsActionCode=null) {

        $timeoutMs=$timeoutSeconds*1000;

        Yii::app()->clientScript->registerCoreScript('jquery');

        $md5=md5("SessionId".Yii::app()->user->getId());




        $name=md5(Yii::app()->id."idindex");

        if(!Yii::app()->request->cookies->contains('name')) {

            $cookie = new CHttpCookie($name, $md5);

            Yii::app()->request->cookies->add($name, $cookie);

        } else {

            Yii::app()->request->cookies[$name]->value=$md5;

        }


        if($jsActionCode===null) {

            $expiredMessage=CJavaScript::encode(Yii::t('app','Your session expired and this page must be reloaded.'));

            $jsActionCode=<<<EOJS

                 	alert($expiredMessage);

                 	location.reload(true);

EOJS;

        }


        # TODO: combine code.

        if(!Yii::app()->user->getIsGuest()) {

            $csrfTokenName = CJavaScript::encode(Yii::app()->request->csrfTokenName);

            $csrfTokenValueRegex= CJavaScript::encode('"'.Yii::app()->request->csrfToken.'"');


            $jsMonitorScript=<<<EOJS

                    (function($) {

                        "strict";

                        var timer;

                        function checkId() {

                            if(!$.cookie)return;

                            var id=$.cookie('$name');

                            if(id===null||!id.match('$md5')) {

                                window.clearInterval(timer);

                                $jsActionCode

                            }

                        }

                        timer=window.setInterval(checkId,$timeoutMs);

                    })(jQuery);

EOJS;

            Yii::app()->clientScript->registerScript(__FILE__."#MonitorId", $jsMonitorScript,CClientScript::POS_READY);

        }

        if(Yii::app()->request->enableCsrfValidation) {

            $csrfTokenName = CJavaScript::encode(Yii::app()->request->csrfTokenName);

            $csrfTokenValueRegex= CJavaScript::encode('"'.Yii::app()->request->csrfToken.'"');


            $jsMonitorScript=<<<EOJS

                    (function($) {

                        "strict";

                        var timer;

                        function checkCSRF() {

                            if(!$.cookie)return;

                            var csrf=$.cookie($csrfTokenName);

                            if(csrf===null||!csrf.match($csrfTokenValueRegex)) {

                                window.clearInterval(timer);

                                $jsActionCode

                            }

                        }

                        timer=window.setInterval(checkCSRF,$timeoutMs);

                    })(jQuery);

EOJS;

            Yii::app()->clientScript->registerScript(__FILE__."#MonitorSession", $jsMonitorScript,CClientScript::POS_READY);

        }

    }



I made another update and wrote a wiki article with the updated code: http://www.yiiframework.com/wiki/506/checking-for-expired-sessions-logins-on-the-client-side/ .

I found this same issue on my live server but couldn’t reproduce it in local. In application log i found that on form submission $_POST array is empty causing CSRF token failure. Do anyone knows about this issue?

I’m using the code for “Yii 1.1: Checking for “expired” sessions/logins on the client side” but found that with

[list=1]

[*]csrfCookie ‘httpOnly’=>true which means

[*]var csrf=$.cookie(‘YII_CSRF_TOKEN’) can’t be found so

[*]"check" is always true which causes endless calls to

[*]location.reload(true);

[/list]

Is there any way to get this to work with ‘httpOnly’=>true?

Hi

  1. It seems that my code needs a correction for inexisting tokens: [size="2"]

var r=(csrf===null);

should be [/size][size="2"]


var r=(csrf===undefined);

(two times).[/size]

[size="2"]2. I you have httpOnly set to true for the CSRF cookie, I suppose that you have already overloaded the CHttpRequest class because I do not see how you can set it with the default class.

In that case, I suggest that you create another which is not protected by httpOnly. It could be a hash and/or timestamp of the CRSF cookie which you use to check if the CSRF cookie was updated. So you would have one cookie hidden to javascript and the other visible.[/size]

[size="2"]A hash of the cookie would be easiest IMHO and you could add the hash algorithm in the Monitoring code (or move the monitoring code your CHttpRequest implementation so that all related code is in one location).[/size]

CHttpRequest supports httpOnly in addCookie(). Configuration:


'request'=>array(

        'csrfCookie'=>array(

                'httpOnly'=>true,

I agree that a different cookie not protected by httpOnly is the best solution in this case.

Thanks for the advice.

Right, I didn’t think of nested configuration values.

For info, my overloaded createCookie function looks like this (it relies on ENinjaMutex).

When a user opens his browser, he may have multiple pages pointing at your site and all will try to get a CSRF token.

So this code makes CSRF generation for a user exclusive (based on userHostAddress and userAgent - not "perfect" but good enough) and caches the token for 30 seconds in order to reuse the same token as the one that was created for the other tabs.


    /**

 	* (non-PHPdoc)

 	* @see CHttpRequest::createCsrfCookie()

 	*/

    protected function createCsrfCookie() {

        $key=$this->userHostAddress."#".$this->userAgent;

        while(!Yii::app()->mutex->lock($key,1))

        {

            sleep(1);

        }

        try {

            $hasCache=Yii::app()->cache!==null;

            if($hasCache) {

                $result=Yii::app()->cache->get($key);

            } else {

                $result=false;

            }

            if($result===false) {

                $result=parent::createCsrfCookie();

                if($hasCache) {

                    Yii::app()->cache->set($key,$result,30);

                }

            }

        } catch(Exception $e) {

            Yii::app()->mutex->unlock();

            throw $e;

        }

        Yii::app()->mutex->unlock();

        Yii::trace('cookie created'.$result->value);

        return $result;

    }