Skip to content
academia.sh

Lesson 17 / 27

Request Forgery

The attack class born from cookies being attached automatically; the decision table of the SameSite and Secure attributes, a session-bound request token, origin header checks, and method discipline.

Contents

The previous lesson ended on a question: if the same-origin policy blocks reading the response, what happens to the write request? The answer is that it is not blocked. A cross-origin request reaches the server, the server processes it, the side effect happens; only the response body is not delivered to the page that started the request.

This gap is not a problem on its own. The problem arises when the user’s identity is attached to the request automatically. If the North Slope Measurement Station interface carries its session in a cookie, the browser attaches that cookie without looking at where the request was started from. This lesson builds the consequence of that behavior and two independent layers of defense.

Ambient Authority

As defined in the How the Internet Works course, a cookie travels with a request automatically. This is the behavior that keeps a session alive: the user does not restate their identity on every page. The same behavior produces a side effect. Who started the request does not affect whether the cookie gets attached; the browser looks at the target address, not the origin.

This is called ambient authority: the authority is attached not to the code making the request but to where the request is going. Cross-site request forgery is this authority being triggered from another site’s page. While the user is signed into the measurement station, a page opens in another tab; that page starts a write request to an endpoint address on the measurement station. Because the request carries the user’s cookie, it looks legitimate on the server side.

The attack does not require script. A form element can be submitted; an image element can call an address. The response not being readable is not the attacker’s concern; the goal is not to read, it is to make something happen.

The first defense sits in the cookie itself. The SameSite attribute determines whether the cookie is attached to cross-origin requests, and it takes three values. The code below computes the decision table for these values across request types.

// samesite.mjs — cookie attributes produce a decision table by request type
const SAFE_METHODS = new Set(['GET', 'HEAD']);

function isSent(cookie, request) {
  if (cookie.secure && request.scheme !== 'https') return ['no', 'a Secure cookie is not sent over an unencrypted scheme'];
  if (request.sameSite) return ['yes', 'same-site request'];
  switch (cookie.sameSite) {
    case 'Strict':
      return ['no', 'Strict excludes every cross-origin request'];
    case 'Lax':
      return request.topLevelNavigation && SAFE_METHODS.has(request.method)
        ? ['yes', 'Lax passes a safe method on top-level navigation']
        : ['no', 'Lax only passes top-level, safe navigation'];
    case 'None':
      return cookie.secure ? ['yes', 'None sends cross-origin'] : ['no', 'None requires Secure'];
    default:
      return ['no', 'unrecognized value'];
  }
}

const requests = [
  { name: 'same-site call', sameSite: true, topLevelNavigation: false, method: 'GET', scheme: 'https' },
  { name: 'outside link (GET)', sameSite: false, topLevelNavigation: true, method: 'GET', scheme: 'https' },
  { name: 'outside form (POST)', sameSite: false, topLevelNavigation: true, method: 'POST', scheme: 'https' },
  { name: 'outside frame', sameSite: false, topLevelNavigation: false, method: 'GET', scheme: 'https' },
  { name: 'outside script call', sameSite: false, topLevelNavigation: false, method: 'POST', scheme: 'https' },
];

const cookies = [
  { name: 'Strict+Secure', sameSite: 'Strict', secure: true },
  { name: 'Lax+Secure', sameSite: 'Lax', secure: true },
  { name: 'None+Secure', sameSite: 'None', secure: true },
  { name: 'None, no Secure', sameSite: 'None', secure: false },
];

const header = ['request type'.padEnd(24), ...cookies.map((c) => c.name.padEnd(18))].join('').trimEnd();
console.log(header);
for (const request of requests) {
  const cells = cookies.map((cookie) => isSent(cookie, request)[0].padEnd(18));
  console.log(request.name.padEnd(24) + cells.join('').trimEnd());
}

console.log('\nreasoning example (Lax+Secure):');
for (const request of requests) {
  const [decision, reason] = isSent(cookies[1], request);
  console.log(' ', request.name.padEnd(24), decision.padEnd(6), reason);
}
$ node samesite.mjs
request type            Strict+Secure     Lax+Secure        None+Secure       None, no Secure
same-site call          yes               yes               yes               yes
outside link (GET)      no                yes               yes               no
outside form (POST)     no                no                yes               no
outside frame           no                no                yes               no
outside script call     no                no                yes               no

reasoning example (Lax+Secure):
  same-site call           yes    same-site request
  outside link (GET)       yes    Lax passes a safe method on top-level navigation
  outside form (POST)      no     Lax only passes top-level, safe navigation
  outside frame            no     Lax only passes top-level, safe navigation
  outside script call      no     Lax only passes top-level, safe navigation

The table makes three rules visible. Strict excludes every cross-origin request; its protection is the broadest, but a user who arrives through a link from another site meets the page without a session. Lax passes the cookie only on top-level navigation and only with a safe method; it filters out an outside form’s POST request and a frame load. None sends the cookie in every case and therefore requires the Secure attribute; if the attribute is missing, the cookie is never sent at all.

Alongside this table, two more attributes are an inseparable part of a session cookie. HttpOnly keeps the cookie from being read by script; once the script-execution defense built in the first lesson is bypassed, it makes stealing the session directly harder. Secure keeps the cookie from being sent over an unencrypted connection.

Where SameSite Falls Short

The attribute is a strong mitigation, but it does not stand on its own as a sufficient defense.

First, “same site” and “same origin” are different measures. The same-site measure is computed over the registrable domain; report.example.test and measurements.example.test are different origins but the same site. Losing control of one subdomain means requests sent through that domain count as same-site. To narrow this risk, the cookie name is given a __Host- prefix: this prefix requires the cookie to belong only to the exact host it was set for and keeps its domain scope from being widened.

Second, the behavior applied when the attribute is not declared depends on the implementation. Some implementations treat an undeclared cookie as if it carried a restrictive value; others do not. This is why the attribute is not left to the default; it is written explicitly.

Third, the Lax value passes a top-level GET navigation. If a state-changing operation is done with GET, this defense does not cover that operation. Method discipline is therefore part of the defense.

Second Layer: Session-Bound Token

The second defense proves that the request was really started from the application’s own page. The server generates an unpredictable request token for each session, embeds it in the page, and expects it back on submission. Binding the token to the session is critical: without that binding, an attacker could place a token valid for their own session into another user’s request.

// token.mjs — generation and validation of a session-bound request token
import { createHmac, timingSafeEqual } from 'node:crypto';

const SERVER_SECRET = Buffer.from('7b2a0f5c9d1e4a8b6c3f0d2e5a7b9c1d', 'utf8');

function generateToken(sessionId, salt) {
  const signature = createHmac('sha256', SERVER_SECRET).update(`${sessionId}.${salt}`).digest('base64url');
  return `${salt}.${signature}`;
}

function validateToken(sessionId, token) {
  if (typeof token !== 'string' || !token.includes('.')) return false;
  const [salt] = token.split('.');
  const expected = Buffer.from(generateToken(sessionId, salt));
  const given = Buffer.from(token);
  return expected.length === given.length && timingSafeEqual(expected, given);
}

function originCheck(headers, expected) {
  if (headers['sec-fetch-site'] === 'same-origin') return true;
  if (headers.origin) return headers.origin === expected;
  return false;
}

const SESSION = 'session-4417';
const valid = generateToken(SESSION, 'a1b2c3');
console.log('generated token:', valid);

const attempts = [
  ['correct session, correct token', SESSION, valid],
  ["another session's token", 'session-9002', valid],
  ['signature altered', SESSION, `${valid.slice(0, -1)}X`],
  ['no token', SESSION, undefined],
];

console.log('\ntoken validation:');
for (const [name, session, value] of attempts) {
  console.log(' ', name.padEnd(32), validateToken(session, value) ? 'valid' : 'rejected');
}

const EXPECTED_ORIGIN = 'https://measurements.example.test';
const headerSets = [
  ['from own page', { origin: EXPECTED_ORIGIN, 'sec-fetch-site': 'same-origin' }],
  ['no header, signal present', { 'sec-fetch-site': 'same-origin' }],
  ['sent from outside', { origin: 'https://other.example.test', 'sec-fetch-site': 'cross-site' }],
  ['neither present', {}],
];

console.log('\norigin check:');
for (const [name, headers] of headerSets) {
  console.log(' ', name.padEnd(30), originCheck(headers, EXPECTED_ORIGIN) ? 'accepted' : 'rejected');
}
$ node token.mjs
generated token: a1b2c3.aGkJnbGB2GshtSxoyRjHtnj6VB-7HH0c9Fm7K5ARq_I

token validation:
  correct session, correct token   valid
  another session's token          rejected
  signature altered                rejected
  no token                         rejected

origin check:
  from own page                  accepted
  no header, signal present      accepted
  sent from outside              rejected
  neither present                rejected

The token carries a signature generated with the server secret, and the signature covers the session ID; when another session’s token is presented, the signature does not match. The comparison is done with timingSafeEqual: a character-by-character short-circuiting comparison turns the length of a correct prefix into a measurable timing difference.

How the token is carried is also part of the rule. If it is carried only in a cookie, it has no value against the attack, because the cookie is also attached automatically. The token has to live somewhere other than a cookie: a hidden form field, or a request header the application adds explicitly. The Authentication in the Browser lesson in the Application Architecture course built the other side of this distinction; in a design that carries the session in memory with a token rather than a cookie, and attaches it to the request explicitly, ambient authority never arises in the first place, so this attack class disappears on its own. The cost is that the session has to be re-established on every page reload.

Third Layer: Origin Check

The last section of the output shows the third layer. The server checks the origin of every state-changing request. If the Origin header is present, it is compared against the expected origin. For cases where this header is missing, the fetch metadata the browser adds is used: the Sec-Fetch-Site header states whether the request was started from the same origin, the same site, or from outside.

The last line of the check matters: a request where neither header is present is not accepted. Treating the absence as “no problem” makes the check something that can be bypassed by deleting a header. These are headers the browser writes, not application code, and the browser cannot be made to not write them; there is no reason to interpret their absence as safe.

Method Discipline

Underneath all these layers sits one assumption: state-changing operations are not done with safe methods. The safe method concept defined in the How the Internet Works course turns into a security requirement here. If an endpoint that deletes a measurement record can be called with GET, even an image element triggers that endpoint; the Lax cookie behavior and most origin checks do not cover this case.

As a rule, write operations are done with the POST, PUT, PATCH, or DELETE methods, and the server explicitly enforces method checking on write endpoints.

Summary

  • Request forgery arises from a cookie being attached without regard to who started the request; the response not being readable does not stop the side effect from happening.
  • The SameSite attribute is the first layer: Strict excludes every cross-origin request, Lax excludes every request outside top-level, safe navigation, and None requires Secure.
  • The same-site measure is broader than the same-origin measure; the subdomain risk is narrowed with the __Host- prefix and a second layer.
  • The request token is bound to the session, carried somewhere other than a cookie, and validated with a constant-time comparison.
  • The Origin and Sec-Fetch-Site headers are the third layer; a request where neither is present is rejected, and state-changing operations are not exposed to safe methods.

Next Step

This lesson’s defenses were built one at a time around specific headers and attributes. On the surface of that same response, there is one more group that is not specific to any single attack class but reduces the impact of all of them: headers that keep the connection from staying unencrypted, that limit whether the document can be framed by another page, and that narrow the address information that leaks out. The next lesson takes up this group, its gaps, and a configuration check that finds them.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close