Randomized $_SESSION token is changed before validation when PHP file is specified in built-in server

80 Views Asked by At

I am generating a unique token and saving it in a session variable on every request (in a typical CSRF protection fashion). Token is refreshed after checking it for validation with the POSTED token value.

Here is my code (index.php):

<?php
    
session_start();

if (!empty($_POST['token'])) {
    var_dump($_POST['token'], $_SESSION['token']);
    exit;
}

$_SESSION['token'] = rand();

echo '<form action="index.php" method="post"><input name="token" value="' . $_SESSION['token'] . '"></form>';

When I use php -S localhost:8888 to run the script, it works fine. But when I specify the index.php file like php -S localhost:8888 index.php the $_SESSION['token'] is changed. ($_POST['token'] and $_SESSION['token'] does not match).

php -S localhost:8888

before after

php -S localhost:8888 index.php

before after

I have also tried using a routing file. It does not work either. php -S localhost:8888 server.php

<?php
// server.php

$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$uri = urldecode($uri);

if ($uri !== '/' and file_exists($uri))
{
    return false;
}

require_once 'index.php';

Console output:

php -S localhost:8888

php -S localhost:8878 
[Mon Mar 29 11:49:49 2021] PHP 8.0.3 Development Server (http://localhost:8878) started 
[Mon Mar 29 11:49:52 2021] [::1]:47410 Accepted 
[Mon Mar 29 11:49:52 2021] [::1]:47412 Accepted 
[Mon Mar 29 11:49:52 2021] [::1]:47410 [200]: GET / 
[Mon Mar 29 11:49:52 2021] [::1]:47410 Closing 
[Mon Mar 29 11:49:53 2021] [::1]:47412 [404]: GET /favicon.ico - No such file or directory 
[Mon Mar 29 11:49:53 2021] [::1]:47412 Closing

php -S localhost:8888 server.php

php -S localhost:8858 server.php
[Mon Mar 29 11:48:51 2021] PHP 8.0.3 Development Server (http://localhost:8858) started 
[Mon Mar 29 11:48:53 2021] [::1]:33156 Accepted 
[Mon Mar 29 11:48:53 2021] [::1]:33158 Accepted 
[Mon Mar 29 11:48:53 2021] [::1]:33156 Closing 
[Mon Mar 29 11:48:54 2021] [::1]:33158 Closing

Tested using:

PHP 7.3.27-1~deb10u1 (cli) (built: Feb 13 2021 16:31:40) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.3.27, Copyright (c) 1998-2018 Zend Technologies
    with Zend OPcache v7.3.27-1~deb10u1, Copyright (c) 1999-2018, by Zend Technologies

and

PHP 8.0.3 (cli) (built: Mar  5 2021 08:38:30) ( NTS )
Copyright (c) The PHP Group
Zend Engine v4.0.3, Copyright (c) Zend Technologies
    with Zend OPcache v8.0.3, Copyright (c), by Zend Technologies

Is this a bug in PHP built-in server?

1

There are 1 best solutions below

14
On

Routing files

This is caused because of the way you have your options set in your command to php: you set the routing file to be index.php`...

php -S localhost:8888 index.php
                      ^^^^^^^^^

...this means that every request will go via that file first and then decide what to do. For example, suppose our index.php file contains the following:

if (rand(1,100) % 2) {
    echo "Not a multiple of 2\n";
    return true;
} else {
    echo "Multiple of 2\n";
    return false;
}

Note: return true; technically isn't needed but I've included it for clarity

What happens here is that the request is made and the routing file is run. If the routing file returns false (i.e. is a Multiple of 2) then the request goes through to the requested file. If the code returns true then execution stops and the requested file isn't accessed.

In your case the requested file is index.php (the same file) so, with the above code, you will always end up with output like:

Not a multiple of 2

// OR

Multiple of 2
Multiple of 2

// OR 

Multiple of 2
Not a multiple of 2

Break down of the http request with your code/setup

So, looking at your actual code...

session_start();

if (!empty($_POST['token'])) {
    var_dump($_POST['token'], $_SESSION['token']);
    exit;
}

$_SESSION['token'] = rand();

echo '<form ...>...</form';

What happens here?

  1. You make a request for index.php*
  2. The server accesses the routing file index.php to decide what to do
  3. The routing file outputs data which effectively indicates true
  4. The data (form) from the routing file is returned & the SESSION variable has been set
    • The request for a file is ignored
  5. You submit the form
  6. The request goes through to the routing file to decide what to do (NOT the location of the attribute action**)
  7. The routing file checks the POST>token against SESSION>token.
  8. They should match and so the routing file effectively outputs data which indicates true***
  9. The data is returned
    • The request for a file (from the action attribute) is ignored

* It makes 0 difference what file you attempt to access; try it with infkdsngfdslghfdslgnfdg.php and it'll still work the same way. Your requested file is never accessed it only appears that it is because the requested and routed file are the same!

** As above, you can set the action attribute to almost anything, try fdsfnldgksdf.php

*** Whether the tokens match or not the routing file still outputs data which equates to true

As per @brombeer's test this does work as expected, so why doesn't it work for you?

The problem

If you check the command prompt/terminal where the server is running you get a stream of what is happening (e.g. when a request is a accepted etc.). You'll notice that if you watch that when making a request you get results like:

[DATE] [::1]:XXX01 Accepted
[DATE] [::1]:XXX02 Accepted
[DATE] [::1]:XXX01 Closing
[DATE] [::1]:XXX02 Closing

@brombeer on the other hand will get results like:

[DATE] [::1]:XXX01 Accepted
[DATE] [::1]:XXX01 Closing

This is the problem. You're making two requests to @brombeer's one request and both requests go through the routing file.

The first request is the one you expect and you get output as you would expect. However, after you receive that output the second request runs (which follows the exact same flow as described above - remember it doesn't matter what file is requested the script will output the same thing(!) - and effectively changes the $_SESSION["token"] to a new random number.

This can perhaps be seen more easily if you change...

$_SESSION["token"] = rand();

...to...

$_SESSION["token"]++;

What is the second request?

This is nothing to do with PHP; it's all to do with your browser. Browsers make requests for all sorts of things other than the requested file. For example:

  • JavaScript files
  • CSS files
  • Images used on the page

Of course, you aren't using any of that in this example. However, there are some resources that browsers look for whether you tell them to or not: usually based on context.

In this case your browser is smart enough to know that you're trying to access a website (likely because of the port number but maybe because of the request method or URI).

So it tries to locate some additional files that it would expect to find on a website, specifically: favicon.ico (you should be able to see this request in your bowser's dev tools under Network).

As already explained, because you've set your server up with a routing file that request goes through the same exact process as the index.php or fnjksgjfndsglkjnsf.php requests. In fact the actual icon file is never even looked for.

You can prove this further by adding this code to the top of your file...

if (!strpos($_SERVER["REQUEST_URI"], ".php")) {
    return false;
}

This will stop execution in your routing file if the requested file isn't a php file. Additionally because we return false the server will look for the icon file. Returning true would also work but the icon file wouldn't be looked for.

You could also try changing the port to something like :8030 and I expect the code would work as you expect (because the browser won't request a favicon).

The solution

Referencing back to my first comment on the question...

Why would you pass a file to the server in this scenario?

I'm still not sure why you've done it: I assume that it's because you didn't understand what a routing file did? Or perhaps that you didn't understand that you were creating a routing file?

Hopefully we've cleared that up here?

Either way I am fairly certain that setting up a routing file isn't what you intended and for your purposes it doesn't appear that it's what you need either.

So just don't add index.php to the end of the command.


Additional worked example

Replace your index.php with the following code:

session_start();

echo "<pre>";

if (!empty($_POST['token'])) {
    var_dump($_POST['token'], $_SESSION['token']);
    exit;
}

$_SESSION['token']++;

echo '<form action="indasdasfdex.php" method="post"><input name="token" value="' . $_SESSION['token'] . '"><input type="submit" value="submit"></form>';

var_dump($_POST['token'] ?? null, $_SESSION['token'] ?? null);

Run your server with index.php as the router file (as per your original question):

php -S localhost:8888 index.php

// Because you require `index.php` in your `server.php`
// this will work the same if you use `server.php` instead

This is what happens

Request: index.php

Router file runs `$_SESSION["token"] == 1`
Router file returns data to browser with a form: `"token" == 1`
Request terminated
Browser shows returned data
    Form: `token.value == 1`
    `var_dump` output
        `$_POST["token"] == null`
        `$_SESSION["token"] == 1`

Request: favicon.ico

Router file runs `$_SESSION["token"] == 2`
Router file returns data to browser with a form `"token" == 2`
Request terminated
Browser doesn't show returned data (but you can see it in dev tools)
    _Not shown because an image isn't returned!_
    _Can be seen in dev tools_
    Form: `token.value == 2`
    `var_dump` output
        `$_POST["token"] == null`
        `$_SESSION["token"] == 2`

Request: submit form to index.php

Router file runs
    Dumps `$_SERVER["token"]` and `$_POST["token"]` (2,1)
Router file `exit`
Request terminated
Browser shows returned data
    `var_dump` output
        `$_POST["token"] == 1`
        `$_SESSION["token"] == 2`

Request: favicon.ico

Router file runs `$_SESSION["token"] == 3`
Router file returns data to browser with a form `"token" == 3`
Request terminated
Browser doesn't show returned data (but you can see it in dev tools)
    _Not shown because an image isn't returned!_
    _Can be seen in dev tools_
    Form: `token.value == 3`
    `var_dump` output
        `$_POST["token"] == null`
        `$_SESSION["token"] == 3`**