CSRF form validation fires even when form token matches session variable token - MVC

I have a function which generates a value for the form token. That is in a hidden field in the view:

<form action="<?php echo URLROOT; ?>/users/" method="post">
	<input class="form-email" type="email" placeholder="Email Address" name="email" autocomplete="off" value="<?php echo $data['email']; ?>">
	<input class="form-password" type="password" placeholder="Password" name="password">
	<input class="login-btn btn-filled" id="login" type="submit" value="Login">
	<input type="hidden" name="form_token" id="form_token" value="<?php echo make_form_token(); ?>">
</form>

Open in new window



In my controller I have:

public function index() 
		
	{
	
		if($_SERVER['REQUEST_METHOD'] == 'POST') {
			
			$email = filter_var($_POST['email'], FILTER_SANITIZE_EMAIL);
			$password = trim($_POST['password']);
		
				$data = [
				
					'email' => $email,
					'password' => $password,
					'message' => ''
					
				];
			
				if(!isset($_SESSION['token']) || $_POST['form_token'] !== $_SESSION['token']) {
			
					$data['message'] .= "CSRF token invalid <br />";
				}

                              // other form validation here
                                
                           if(!empty($data['message'])) {
			
					$this->view('users/index', $data);
				
				} else {

// success

Open in new window


if I echo out the session variable and view the page source, I can see that the hidden variable value for token is exactly the same as the session variable value. Why then do I keep getting my error message that says the CSRF token is invalid?

The strange thing is that I do exactly the same thing in another area of my website and it works just fine. Very confused...

I also know that the session is set because if I change the validation to just:

if(!isset($_SESSION['token'])) {

Open in new window


then the error goes away. So, it is something to do with the hidden field form token.
LVL 1
Black SulfurAsked:
Who is Participating?

[Product update] Infrastructure Analysis Tool is now available with Business Accounts.Learn More

x
I wear a lot of hats...

"The solutions and answers provided on Experts Exchange have been extremely helpful to me over the last few years. I wear a lot of hats - Developer, Database Administrator, Help Desk, etc., so I know a lot of things but not a lot about one thing. Experts Exchange gives me answers from people who do know a lot about one thing, in a easy to use platform." -Todd S.

gr8gonzoConsultantCommented:
Well, !== checks for both the value AND the type. So if your form token is numeric (e.g. 12345) when it's generated and saved to your session, you might have an INTEGER in your session and a STRING in your post:

POST = "12345"
SESSION = 12345

If that's the case, then !== would return true because of the different data types. A quick way to test this is to just change !== to != (remove one of the equals signs) and see if it works at that point.

I'd also suggest doing a var_export() in your invalid message:

                        if(!isset($_SESSION['token']) || $_POST['form_token'] !== $_SESSION['token']) {
                  
                              $data['message'] .= "CSRF token invalid: " . var_export($_POST["form_token"],true) . " !== " . var_export($_SESSION["token"],true) . "<br />";
                        }

Then re-test and see what the error message says.

On a side note, be mindful of what would happen if your users want to open up multiple tabs or use the back button on their browser. If you're only storing one valid token in your session and if you're overwriting it each time a new token is generated, then you're likely going to end up with quite a few "invalid token" messages that will frustrate your users. So make sure that you're first thinking through your workflow as if you were an average user and attempting to use your application the way you would normally use any other web application. What happens when you hit the Back button on different pages? What happens if you refresh on a screen that processes a form POST? What happens if someone right-clicks on a link and chooses to open it in a new tab so they have two tabs open at the same time for your application?
0
Black SulfurAuthor Commented:
Hi there. I changed from !== to != and still get the same error. After running your code, I get:

CSRF token invalid: 'ozzYHKIBsClRase0yQ+Fi9JxnRAQdAk8L07M62Hy7ng=' != '/GAwFb6QwNgAZuyHAV08E0KoV5+0W7VJDAQlnNCg3SA='

What would you suggest to remedy the scenarios you put to me? This is pretty much how I have been doing it for a while now and thought it was the "right" way to do it. Clearly not! Haha.
0
Black SulfurAuthor Commented:
The function which creates the session token is:

function make_form_token() {
	
		$token = base64_encode(openssl_random_pseudo_bytes(32));
		$_SESSION['token'] = $token;
		return $token;
	
} 

Open in new window

0
Python 3 Fundamentals

This course will teach participants about installing and configuring Python, syntax, importing, statements, types, strings, booleans, files, lists, tuples, comprehensions, functions, and classes.

Black SulfurAuthor Commented:
Another thing I noticed. Underneath the form I have a link to another page (forgot password). On the forgot password page I have a link back to login. If I start on the login page and then click on the link to go to the forgot password page, and then click on the link again to go back to login and submit the form. The tokens match. If I click submit again then they don't match. That is odd behavior.
0
Black SulfurAuthor Commented:
I figured it out but I don't know why it broke it.

<form action="<?php echo URLROOT; ?>/users/" id="loginForm" method="post"> 

Open in new window


had a slash after /users/ but if I remove it so it is /users without the trailing slash then it works just fine.
0
Black SulfurAuthor Commented:
Well, that was short lived. The reset password page also gives me the CSRF error now.
0
gr8gonzoConsultantCommented:
My guess is that your make_form_token() function is being called more times than you expect. So if you hit the users page with the /, maybe the resulting redirect is triggering the function call so the token is changed before it's compared.

The "quick fix" method would be to have an array of valid tokens that each have a timestamp of when they were generated, and they get cleaned up automatically after X seconds (e.g. 3600 seconds = 1 hour). Example:

function make_form_token() {
	
  // Set up the array
  if(!isset($_SESSION['token'])) { $_SESSION['token'] = array(); }

  // Convert the old single token to an array of tokens (you can remove this line after you're sure nobody has the old non-array structure)
  if(!is_array($_SESSION['token'])) { $_SESSION['token'] = array($_SESSION['token'] => time()); }

  // Clear up any old tokens
  foreach($_SESSION["token"] as $token => $ts)
  {
    // Check to see if the token was issued over an hour (3600 seconds) ago
    if((time() - $ts) >= 3600)
    {
      // If so, remove it
      unset($_SESSION["token"][$token];
    }
  }

  // Generate the new token and record it with its timestamp
  $token = base64_encode(openssl_random_pseudo_bytes(32));
  $_SESSION['token'][$token] = time();

  // ...and return the new token
  return $token;
} 

Open in new window


However, you should still try to eliminate any "extra" calls to this so you're not filling up the session data with unused tokens. Also, if you expect your users to have a page / form open for over an hour, you can either extend the time or you can use shorter timespans (e.g. 10 minutes) and use AJAX to "refresh" the token right before it expires so the form can stay active as long as needed while CSRF tokens aren't being kept around for long periods of time.
1

Experts Exchange Solution brought to you by

Your issues matter to us.

Facing a tech roadblock? Get the help and guidance you need from experienced professionals who care. Ask your question anytime, anywhere, with no hassle.

Start your 7-day free trial
Black SulfurAuthor Commented:
My guess is that your make_form_token() function is being called more times than you expect.

Indeed. I am still not 100% sure but in my index method, I put die() right at the beginning of it so that the index method never actually runs. This issue goes away after doing this. If I let the index method run as normal, the error appears again. So for some reason, the token is being generated twice. I didn't find the exact solution but all I did was rename the index method to login.

So, my url went from mysite.com/users to mysite.com/users/login and everything seems to work properly now.

I am keen to use your 'quick fix' in production. Would that be okay?
0
gr8gonzoConsultantCommented:
Sure - it will simply have a little bit more overhead but if you can identify the root cause then you can eliminate most of that overhead.
0
Black SulfurAuthor Commented:
Thanks!
0
It's more than this solution.Get answers and train to solve all your tech problems - anytime, anywhere.Try it for free Edge Out The Competitionfor your dream job with proven skills and certifications.Get started today Stand Outas the employee with proven skills.Start learning today for free Move Your Career Forwardwith certification training in the latest technologies.Start your trial today
PHP

From novice to tech pro — start learning today.