Deep Engineering

ЗАМЕР

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));