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()andbin2hex()(32 characters) and compared withhash_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
SameSitecookie 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.