MEASUREMENT
bench/bff/measure.mjs
The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.
- Cited in
- /en/system-design/api-design/bff
- How to run it
node bench/bff/measure.mjs > bench/bff/measurements.json node bench/bff/storage.mjs > bench/bff/storage.json node bench/bff/latency.mjs > bench/bff/latency.json
The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.
Record of the run
Замеры для статьи «Backend for Frontend»
Не симуляция. Каждое число статьи взято из прогона этих файлов против
next start перед настоящим Go API перед PostgreSQL — кроме одной величины,
которая задана намеренно (см. ниже).
| Файл | Что меряет |
|---|---|
measure.mjs |
что скрипт из собственного источника страницы может и чего не может достать, когда сессия живёт в HttpOnly-cookie за BFF |
storage.mjs |
две другие колонки той же таблицы: что достаёт тот же скрипт, когда токен лежит в localStorage и когда — в куке без HttpOnly |
latency.mjs |
как цена экрана растёт с числом обходов, которые делает браузер |
measurements.json, storage.json, latency.json |
своды, из которых взяты числа статьи |
node bench/bff/measure.mjs > bench/bff/measurements.json
node bench/bff/storage.mjs > bench/bff/storage.json
node bench/bff/latency.mjs > bench/bff/latency.json
Одно число снято не этими скриптами. Поле serverLegLoopback в
latency.json (0,71 мс) получено двадцатью запросами curl к
http://localhost:8091/health с той же машины, а не из браузера, — это
записано в самом файле, в поле tool. Статья обязана называть его так же:
обращение к соседнему сервису с машины, а не «внутри датацентра».
Что здесь измерено, а что задано
Задержка на стороне браузера вводится намеренно
(Network.emulateNetworkConditions через CDP), потому что замер по петле
обратной связи сказал бы, что обход бесплатен, — а это ровно та иллюзия,
которую замер призван сломать. Введённая величина названа в тексте статьи;
измеряется не она, а то, как от неё растёт цена экрана.
Почему этот каталог появился поздно
Скрипты лежали в tmp/, а tmp/ стоит в .gitignore — в репозитории их не
было, и числа статьи нельзя было перезапустить. Правило «числа в статьях
берутся отсюда, а не из головы» для уроков выполнялось, для статей — нет.
Script
128 lines/**
* Measures, against the running stack, exactly what a script executing in the
* page's own origin can and cannot reach when the session lives in an
* HttpOnly cookie behind a BFF.
*
* Not a simulation. Every number the article prints comes from one run of
* this file against `next start` in front of the real Go API in front of
* PostgreSQL. Run:
*
* node bench/bff/measure.mjs > bench/bff/measurements.json
*/
import { chromium } from "@playwright/test";
import { randomUUID } from "node:crypto";
const BASE = process.env.BASE_URL ?? "http://localhost:3417";
const API = process.env.DE_API_URL ?? "http://localhost:8091";
const PASSWORD = "correct horse battery staple";
const out = { base: BASE, api: API, steps: [] };
function record(step) {
out.steps.push(step);
}
const browser = await chromium.launch({
executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH || undefined,
});
const context = await browser.newContext();
const page = await context.newPage();
const name = `bff-${randomUUID().slice(0, 8)}`;
const email = `${name}@example.dev`;
await page.goto(`${BASE}/register`);
await page.getByLabel("Имя пользователя").fill(name);
await page.getByLabel("Email").fill(email);
await page.getByLabel("Пароль").fill(PASSWORD);
await page.getByRole("button", { name: "Создать аккаунт" }).click();
await page.getByTestId("account-name").waitFor();
// Give the account something private to steal, so "the attacker read the
// user's data" is a real read of real rows rather than an empty list.
await page.goto(`${BASE}/python/data-structures/dict`);
await page.getByRole("button", { name: "☆ В закладки" }).click();
await page.getByRole("button", { name: "★ В закладках" }).waitFor();
await page.waitForTimeout(500);
// --- 1. What the browser actually stores -----------------------------------
const cookies = await context.cookies();
record({
id: "cookie-jar",
what: "Куки, которые браузер получил от BFF",
value: cookies
.filter((c) => c.name.startsWith("de_"))
.map((c) => ({
name: c.name,
httpOnly: c.httpOnly,
sameSite: c.sameSite,
secure: c.secure,
valueLength: c.value.length,
})),
});
// --- 2. What a script in the page can read ---------------------------------
const scriptView = await page.evaluate(() => ({
documentCookie: document.cookie,
localStorageKeys: Object.keys(localStorage),
sessionStorageKeys: Object.keys(sessionStorage),
}));
record({
id: "script-view",
what: "Что видит скрипт, выполняющийся на странице",
value: scriptView,
});
// --- 3. What that same script CAN still do (session riding) ----------------
// This is the attack the BFF does not stop: the code runs in the page's own
// origin, so the browser attaches the cookie for it.
const ridden = await page.evaluate(async () => {
const res = await fetch("/api/bookmarks", { credentials: "same-origin" });
const body = await res.json();
return { status: res.status, body };
});
record({
id: "session-riding",
what: "Тот же скрипт читает личные данные через BFF",
value: ridden,
});
// --- 4. The same script talking to the upstream API directly ---------------
const directUpstream = await page.evaluate(async (apiUrl) => {
try {
const res = await fetch(`${apiUrl}/api/v1/bookmarks`, { credentials: "include" });
return { status: res.status, body: await res.text() };
} catch (err) {
return { status: null, error: String(err) };
}
}, API);
record({
id: "direct-upstream",
what: "Тот же скрипт обращается к Go-сервису напрямую",
value: directUpstream,
});
// --- 5. A state-changing call without the CSRF header ----------------------
const noCsrf = await page.evaluate(async () => {
const res = await fetch("/api/bookmarks", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ article_slug: "dict-internals" }),
});
return { status: res.status, body: await res.json().catch(() => null) };
});
record({ id: "no-csrf", what: "Изменяющий запрос без CSRF-заголовка", value: noCsrf });
// --- 6. Response shapes: BFF vs upstream ----------------------------------
const bffMe = await page.evaluate(async () => {
const res = await fetch("/api/auth/me", { credentials: "same-origin" });
const text = await res.text();
return { status: res.status, bytes: new TextEncoder().encode(text).length, text };
});
record({ id: "bff-me", what: "Ответ BFF на /api/auth/me", value: bffMe });
await browser.close();
out.recordedAt = new Date().toISOString();
process.stdout.write(JSON.stringify(out, null, 2));