ЗАМЕР
bench/bff/measure.mjs
Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.
- Цитируется в статье
- /ru/system-design/api-design/bff
- Как запустить
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
Запись прогона
Замеры для статьи «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 — в репозитории их не
было, и числа статьи нельзя было перезапустить. Правило «числа в статьях
берутся отсюда, а не из головы» для уроков выполнялось, для статей — нет.
Скрипт
128 строк/**
* 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));