Skip to content
academia.sh

Lesson 13 / 15

Request Forgery Protection

Telling apart whether a request was made by the user's own application or by another site with a request forgery token, the measured effect of the token, and the limit of the SameSite cookie attribute.

Published Updated

In this lesson

  • Measures who changed the result in an email update without a token and with a token
  • Generates and compares a request forgery token with random_bytes(), bin2hex() and hash_equals()
  • Explains that the SameSite cookie attribute is browser behavior and is placed next to the token, not in its place
Contents

Escaping bounded the text printed onto the page. A request comes from another direction: while a user is signed in to the application, a page on another site can send a request to the application from the user’s browser. The question for preventing cross-site request forgery (CSRF) is this: who determines whether this request came from the user’s own screen or from somewhere else? This lesson measures whether the request is accepted; it does not ask whether the code of a rejected request ran.

SE4. In this lesson the “other site” is only an array record: 'origin' => 'https://other.test'. No real second site, cookie or browser is set up. The requests are arrays; what is measured is whether the email update is accepted. The token is printed nowhere, only its length is printed.

Tokenless and Tokened Update

A request forgery token (CSRF token) is a random value that the server puts in the form it shows the user and expects back with the request. A request that comes from the user’s own screen carries this value; a page on another site cannot carry it, because it does not know the value. The token is generated with random_bytes(), made readable with bin2hex(), and compared with hash_equals(): this function compares two strings in a time that does not depend on the position where the first difference is found.

<?php
// request.php — unprotected and tokened email update
declare(strict_types=1);

function generateToken(): string {
    return bin2hex(random_bytes(16));
}

function update(string $current, array $request, ?string $expected): string {
    if ($expected !== null && !hash_equals($expected, (string) ($request['token'] ?? ''))) {
        return $current;
    }
    return $request['email'];
}

update() returns the new email; if the request is rejected, it returns the current value unchanged. If $expected is null, there is no protection. Three requests are tried with two methods: from the user’s own screen, from another site without a token, and from another site with a wrong token.

<?php
// update.php — three requests, two methods
declare(strict_types=1);
require 'request.php';

$current = '[email protected]';
$token = generateToken();
$requests = [
    'from the app   ' => ['origin' => 'https://app.test', 'email' => '[email protected]', 'token' => $token],
    'without token  ' => ['origin' => 'https://other.test', 'email' => '[email protected]'],
    'wrong token    ' => ['origin' => 'https://other.test', 'email' => '[email protected]', 'token' => str_repeat('0', 32)],
];
foreach ($requests as $name => $request) {
    $unprotected = update($current, $request, null) !== $current;
    $tokened = update($current, $request, $token) !== $current;
    echo $name, ' · unprotected ', $unprotected ? 'accepted' : 'rejected', ' · tokened ', $tokened ? 'accepted' : 'rejected', "\n";
}
echo 'token length ', strlen($token), "\n";
echo 'two tokens ', generateToken() === generateToken() ? 'same' : 'different', "\n";
from the app    · unprotected accepted · tokened accepted
without token   · unprotected accepted · tokened rejected
wrong token     · unprotected accepted · tokened rejected
token length 32
two tokens different

The unprotected method accepted all three requests; there was no difference between the other site’s request and the user’s own request. The tokened method accepted only the request that carried the token; the request without a token and the request with a wrong token were rejected. The token is 32 characters (16 bytes, written in hexadecimal) and two generations gave different values. What is measured is only acceptance or rejection; the token itself is not printed, because a printed token would be another value on the next run, and a secret value has no place in output.

Party: Who Makes the Update

The same measurement connects to the party.php core. The decision order is client, third party, environment; if none could change it, the server.

<?php
// party.php — who can change this result?
declare(strict_types=1);

// The parties outside the code, in decision order: every request, every external call, every deployment.
const EXTERNAL_PARTIES = ['client', 'third party', 'environment'];

/** Computes the result once without deviation, then once with each external party's deviation. */
function probe(callable $result, array $deviations): array {
    $baseline = $result([]);
    $changed = [];
    foreach (EXTERNAL_PARTIES as $party) {
        if (array_key_exists($party, $deviations)) {
            $changed[$party] = $result([$party => $deviations[$party]]) !== $baseline;
        }
    }
    return $changed;
}

/** The first external party that can change the result; the server if none can. */
function party(array $changed): string {
    foreach (EXTERNAL_PARTIES as $p) {
        if ($changed[$p] ?? false) return $p;
    }
    return 'server';
}

function partyReport(string $name, callable $result, array $deviations): string {
    $changed = probe($result, $deviations);
    $lines = [];
    foreach ($changed as $p => $c) $lines[] = $p . ($c ? ' changed' : ' could not change');
    $owner = party($changed);
    echo "$name → $owner\n    ", implode(' · ', $lines), "\n";
    return $owner;
}

The result is the email after the update. The deviation is a request sent by the third party without a token: the other site’s record and its own email. The undeviated request is the user’s own tokened request.

<?php
// forgery.php — who changed the email
declare(strict_types=1);
require 'party.php';
require 'request.php';

$token = generateToken();
$own = ['origin' => 'https://app.test', 'email' => '[email protected]', 'token' => $token];
$other = ['origin' => 'https://other.test', 'email' => '[email protected]'];

partyReport('without token', fn(array $d) => update('[email protected]', $d['third party'] ?? $own, null), ['third party' => $other]);
partyReport('with token', fn(array $d) => update('[email protected]', $d['third party'] ?? $own, $token), ['third party' => $other]);
without token → third party
    third party changed
with token → server
    third party could not change

In the update without a token, the third party changed the email: the other site’s request replaced the user’s email with its own value. In the update with a token, the third party could not change it; the remaining owner is the server, because the code that generates and compares the token is there. The token value stays in the server’s hands and only a copy of it is put into the form; the party that sends the request cannot produce its counterpart as long as it does not see the copy.

The Bounding Measurement

Browsers have a cookie attribute that limits sending the cookie with a request from another site: SameSite. The server asks for this attribute by writing it into the cookie header; enforcing it belongs to the browser. The only thing that can be measured on the server side is whether the attribute is inside the header string.

<?php
// samesite.php — SameSite is only a header string on the server
declare(strict_types=1);
require 'party.php';
require 'request.php';

function cookieHeader(string $name, string $value, string $sameSite): string {
    return 'Set-Cookie: ' . $name . '=' . $value . '; SameSite=' . $sameSite . '; HttpOnly';
}

echo cookieHeader('session', 'value', 'Lax'), "\n";

$own = ['origin' => 'https://app.test', 'email' => '[email protected]'];
$other = ['origin' => 'https://other.test', 'email' => '[email protected]'];
partyReport('SameSite only', fn(array $d) => update('[email protected]', $d['third party'] ?? $own, null), ['third party' => $other]);
Set-Cookie: session=value; SameSite=Lax; HttpOnly
SameSite only → third party
    third party changed

SameSite=Lax is in the header string, but the result in the server code did not change: the third party again changed the email. This is the expected result. The effect of the attribute cannot be measured in any line that runs on the server, because it is the browser that chooses to add or not to add the cookie to the request; the server only asks. So SameSite is not put in place of the token, it is put next to it: the attribute adds a layer on the browser side, and the token changes the owner on the server side. Without the second, the server’s result stays in the third party’s hands.

Summary

  • The measured class of this lesson is the third party in the update without a token and the server in the update with a token: without a token the other site’s request changed the email; with a token it could not.
  • The request forgery token is generated with random_bytes() and bin2hex() (32 characters) and compared with hash_equals(); the token itself is not printed, its length is.
  • The defense takes the result away from its owner: a wrong and a missing token were rejected in the same way, and the email stayed at its current value.
  • The SameSite cookie attribute is browser behavior; the server only asks for it and it appears in the header string. It did not change the result on the server, and it is put next to the token, not in its place.

Next Step

If the request now comes from the user’s screen, the same question is asked when the credential that comes with the request is stored: how is the password stored, and who can change the stored hash? The Password Storage lesson measures this.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close