Skip to content
academia.sh

Lesson 01 / 15

Cookies

How the cookie carries state on the client between requests, through the Set-Cookie header and its signed form; the four parties that classify who can change a result, and the ten-result measurement.

Published Updated

In this lesson

  • Explains that a cookie carries state on the client and returns with every request
  • Reads the rule that classifies a result as server, environment, client or third party, together with its decision order
  • Runs the ten-result measurement and interprets the 4, 2, 2 and 2 distribution
  • Builds a Set-Cookie header with attributes and measures that a signed cookie detects a tampered value but does not change the owner of the result
Contents

The Object-Oriented PHP and Standards course split code across files and authors and measured what a user of a definition experiences when that definition changes. This course takes code onto the web: the request comes from a browser, the settings from the environment the code is deployed to, the price from an external service. The author of the code is no longer the only owner of a result. This course measures every mechanism with one question: who can change this result?

The first stop is where the web carries state. HTTP is stateless: the server handles each request independently of the previous one (The HTTP Family lessons). For a cart or a user to be remembered across requests, state has to live somewhere. The oldest place is the cookie: the server sends a Set-Cookie header in the response, the client stores the value and sends it back with every request to the same site. The state lives on the client and returns to the server with each request.

The Measurement Core

The measurement itself lives in the file party.php and does not change throughout the course. The same file is given again in every lesson.

<?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;
}

EXTERNAL_PARTIES lists the three parties outside the code in decision order: client, third party, environment. probe() takes a function that computes a result, calls it first without deviation ($baseline), then once for each external party’s deviation, and records whether the result differs from the baseline. party() walks those records in order and returns the first external party that can change the result. partyReport() names the result and prints the deviations and the outcome.

SS1. The decision order is party() itself: client first, then third party, then environment; if none of them could change the result, the server. The order is the width of the attack surface. The client is everyone who can send a request, the third party is only the parties the application connects to, and the environment is in the hands of whoever performs the deployment alone. The server is not in the deviation list, because the code can change every result; the question is who outside the code can change it. A party is not a label but a rule: the developer names the result, defines one deviation for each relevant external party, and the party follows from those two inputs.

Party Definition
server Only the application’s code determines the result; no external party can change it alone
environment The same code gives a different result when the environment it is deployed to changes (an environment variable, a connection setting, a runtime version, the state of the database)
client The party that sends the request changes the result (a cookie, a form field, a query parameter, a token it presents)
third party A service the application calls, or another site that makes the user’s browser send a request, changes the result

Two distinctions have to be made at the start. In the Object-Oriented PHP and Standards course a change of definition was measured before and after; here the definition is fixed and what changes is the input of an external party. In the Systems Programming with C course the party that saw a change, the observer, was sought; here the direction is reversed and the party that makes the change is sought.

Ten Results, Four Parties

results.php measures the ten results of the course once. This file is written only in this lesson; later lessons pull in party.php and build only their own result.

<?php
// results.php — ten results, four parties
declare(strict_types=1);
require 'party.php';

putenv('APP_DATABASE=app.db');
function inEnvironment(?string $value, callable $f): mixed {
    if ($value === null) return $f();
    $previous = getenv('APP_DATABASE');
    putenv("APP_DATABASE=$value");
    try { return $f(); } finally { putenv("APP_DATABASE=$previous"); }
}

$db = new PDO('sqlite::memory:');
$db->exec('CREATE TABLE members (name TEXT, age INTEGER)');
$db->exec("INSERT INTO members VALUES ('alice', 30), ('bob', 41)");
function preparedStatement(PDO $db, string $name): string {
    $statement = $db->prepare('SELECT age FROM members WHERE name = ?');
    $statement->execute([$name]);
    return $statement->queryString;
}

const TOKEN_LENGTH = 16;
$token = bin2hex(random_bytes(TOKEN_LENGTH));
function processEmail(array $requests, ?string $token): string {
    $email = '[email protected]';
    foreach ($requests as $request) {
        if ($token !== null && !hash_equals($token, $request['token'] ?? '')) continue;
        $email = $request['email'];
    }
    return $email;
}
$userRequest = ['origin' => 'https://example.test', 'token' => $token, 'email' => '[email protected]'];
$fromOtherSite = ['origin' => 'https://other.test', 'email' => '[email protected]'];

$cookie = ['cart' => '2'];
$session = ['cart' => 2];
$serviceResponse = '{"price": 120}';
$runtimeDefault = ['cost' => 12];   // the bcrypt default in PHP 8.4 and later

$results = [
    ['cart (in cookie)', fn(array $d) => (int) ($d['client'] ?? $cookie)['cart'],
        ['client' => ['cart' => '5']]],
    ['cart (in session)', fn(array $d) => $session['cart'],
        ['client' => ['cart' => '5']]],
    ['query text (concatenation)', fn(array $d) => "SELECT age FROM members WHERE name = '" . ($d['client'] ?? 'alice') . "'",
        ['client' => 'bob']],
    ['query text (prepared statement)', fn(array $d) => preparedStatement($db, $d['client'] ?? 'alice'),
        ['client' => 'bob']],
    ['email (without token)', fn(array $d) => processEmail([$userRequest, ...($d['third party'] ?? [])], null),
        ['third party' => [$fromOtherSite]]],
    ['email (with token)', fn(array $d) => processEmail([$userRequest, ...($d['third party'] ?? [])], $token),
        ['third party' => [$fromOtherSite]]],
    ['price (external service)', fn(array $d) => json_decode($d['third party'] ?? $serviceResponse, true, 4, JSON_THROW_ON_ERROR)['price'],
        ['third party' => '{"price": 125}']],
    ['hash prefix (default)', fn(array $d) => substr(password_hash('password', PASSWORD_BCRYPT, $d['environment'] ?? $runtimeDefault), 0, 7),
        ['environment' => ['cost' => 10]]],
    ['hash prefix (explicit cost)', fn(array $d) => substr(password_hash('password', PASSWORD_BCRYPT, ['cost' => 12]), 0, 7),
        ['environment' => ['cost' => 10]]],
    ['database name', fn(array $d) => inEnvironment($d['environment'] ?? null, fn() => getenv('APP_DATABASE')),
        ['environment' => 'test.db']],
];

$tally = ['server' => 0, 'environment' => 0, 'client' => 0, 'third party' => 0];
foreach ($results as [$name, $result, $deviations]) {
    $tally[partyReport($name, $result, $deviations)]++;
}
foreach ($tally as $p => $n) echo "$p $n\n";
cart (in cookie) → client
    client changed
cart (in session) → server
    client could not change
query text (concatenation) → client
    client changed
query text (prepared statement) → server
    client could not change
email (without token) → third party
    third party changed
email (with token) → server
    third party could not change
price (external service) → third party
    third party changed
hash prefix (default) → environment
    environment changed
hash prefix (explicit cost) → server
    environment could not change
database name → environment
    environment changed
server 4
environment 2
client 2
third party 2

SS2. Of the ten results, 4 came out server, 2 environment, 2 client and 2 third party. This distribution is the binding number of the course; none of the later lessons changes it, and each lesson measures its own slice. The output is read starting from the pairs. Eight of the ten results form four pairs, and in each pair a mechanism moves the result from an external party to the server:

  • When the cart lives in a cookie, the 5 the client sent changed the cart; when it lives in the session, the same deviation could not change it.
  • When the query text is built by concatenation, a neutral name (bob) changed the text; with a prepared statement the statement text stayed the same.
  • In the email update without a token, a request from another site changed the email; in the update with a token, hash_equals() did not let that request through.
  • When the hash prefix is produced with the default cost supplied by the environment, the environment changed the prefix; when the cost is given explicitly, it could not.

The owner of two results does not change with any mechanism: the price from the external service always belongs to the third party, and the database name always belongs to the environment. For these two, a defense does not change the owner; it only bounds the result.

Whoever owns the cookie sets it with a header. The header list is empty on the command line, so the code below does not send a header; it builds one as a string and prints it. Two small functions for the signed cookie live in a separate file.

<?php
// cookie.php — two functions that produce and verify a signed cookie value
declare(strict_types=1);

const SIGNING_KEY = 'lesson-key';

function sign(string $value): string
{
    return $value . '.' . hash_hmac('sha256', $value, SIGNING_KEY);
}

function cookieValue(string $cookie, string $fallback): string
{
    [$value, $signature] = array_pad(explode('.', $cookie, 2), 2, '');
    $expected = hash_hmac('sha256', $value, SIGNING_KEY);
    return hash_equals($expected, $signature) ? $value : $fallback;
}

sign() binds the value to a keyed hash (the signature) with hash_hmac('sha256', …) and joins them in the form value.signature. cookieValue() splits the cookie that came from the client, recomputes the signature and compares it with hash_equals(); if the signature does not match, it returns the fallback value. SIGNING_KEY is a lesson constant.

<?php
// header.php — the Set-Cookie header and the measurement of the signed cart cookie
declare(strict_types=1);

require 'party.php';
require 'cookie.php';

$valid = sign('2');
$header = 'Set-Cookie: cart=' . $valid . '; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax';
echo str_replace(substr($valid, 2), '<signature>', $header), "\n";

$tampered = '5' . substr($valid, 1);
echo 'tampered value passed the signature: ',
    cookieValue($tampered, '0') === '5' ? 'yes' : 'no', "\n";

partyReport(
    'cart (signed cookie, tampered value)',
    fn(array $d) => cookieValue($d['client'] ?? $valid, '0'),
    ['client' => $tampered],
);
Set-Cookie: cart=2.<signature>; Path=/; Max-Age=3600; HttpOnly; Secure; SameSite=Lax
tampered value passed the signature: no
cart (signed cookie, tampered value) → client
    client changed

SS3. The attributes in the header are requests to the browser. Path=/ says on which paths the cookie returns, and Max-Age=3600 says how long it is kept. HttpOnly asks that scripts on the page cannot read the cookie, Secure that the cookie is sent only over an encrypted connection, and SameSite=Lax that sending the cookie along with another site’s request be restricted. All three are a string the server writes; enforcing them is up to the client, and the server cannot measure that in this lesson.

The measurement shows two things. The tampered value did not pass the signature, and cookieValue() fell back to the fallback value, that is, to 0. The signature worked: the server did not accept the altered value. But the result still changed: it was 2 and became 0, and the party that made the change was the client. The party is client.

The Bounding Measurement

The limit of the signature is this: a signature shows that a value was produced with the key, not that the value is current. If the client sends back a valid cookie it received earlier, its own previous cart, does the signature pass?

<?php
// stale.php — if the client sends back its previous valid cookie
declare(strict_types=1);

require 'party.php';
require 'cookie.php';

$current = sign('2');
$stale = sign('1');
echo 'stale cookie passed the signature: ',
    cookieValue($stale, '0') === '1' ? 'yes' : 'no', "\n";

partyReport(
    'cart (signed cookie, stale valid value)',
    fn(array $d) => cookieValue($d['client'] ?? $current, '0'),
    ['client' => $stale],
);
stale cookie passed the signature: yes
cart (signed cookie, stale valid value) → client
    client changed

SS4. It passed. The signature proves that the value was produced with the key; it does not prove that the value is current. The stale cookie carried a valid signature, so the server accepted it and the cart became 1 instead of 2. The result is again client. The signature narrows the values the client can produce to those the server signed beforehand; choosing which of them to send stays with the client.

A defense does not always change the owner. In this lesson the signed cookie narrows a value the client produces to the valid set; it does not take the result out of the client’s hands. To change the owner, the state must not live on the client.

Summary

  • A cookie gets around the statelessness of HTTP by keeping state on the client; the server writes it with Set-Cookie, and the client sends it back on every request.
  • The course measures every mechanism with one question: who can change this result. The decision order is client, third party, environment; if none can change it, the server.
  • The core classified the ten results as 4 server, 2 environment, 2 client and 2 third party; this is the distribution of the core, not of the lessons.
  • Set-Cookie attributes are requests to the browser; a signed cookie detects a tampered value but does not move the result to the server.
  • The measured party of this lesson is client: even with a signed cookie, the owner of the result is the client that sends the value.

Next Step

In the cookie, the cart lived on the client and the client could change it. The next lesson, Sessions, moves the value to the server: if the cart lives in $_SESSION, can the same deviation change the result, and once the cookie holds only an ID, who determines that ID?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close