Изменение данных и защита от CSRF
Редакторский кабинет теперь закрыт ролью, но чтение страницы и изменение данных требуют разных правил. Добавим учебную заявку на публикацию урока. Анна сможет отправить название, а сервер сохранит его в памяти процесса. Для операции нужны роль editor, метод POST, допустимый Origin и CSRF-токен текущей сессии. Новый результат показывает, что единый вход не защищает автоматически каждую форму: подтверждённая личность является только одной частью решения о конкретном действии.
В lesson-08 появляется body.mjs, а /editor выводит форму. Поле title ограничено ста двадцатью символами, csrf берётся из серверной записи. Отправка идёт на /publish. Несмотря на название, route не публикует настоящий ProfessorWeb и не меняет Markdown сайта. Это отдельный вымышленный редакторский запрос лаборатории. Заявки хранятся в массиве и исчезают при рестарте. Такие границы не следует скрывать ради эффектного результата «теперь у нас CMS».
Почему POST недостаточно
Вредоносная страница может отправить форму в другой сайт, а браузер в некоторых условиях приложит cookies посетителя. Поэтому наличие cookie и POST само по себе не доказывает, что человек действовал из нашего интерфейса. CSRF связывает операцию с ожидаемым контекстом приложения. Мы проверяем Origin и случайный токен, который хранится в локальной сессии и включён в свою форму. SameSite=Lax дополнительно ограничивает поведение cookie, но не должен быть единственным объяснением безопасности операции.
Origin сравнивается с trusted origin, заданным config. Мы не вычисляем его из Host, который прислал клиент. Запрос без ожидаемого Origin в этой лаборатории отвергается. Это строгая политика для обычного браузерного POST формы; отдельный API-клиент потребует другого принятого способа аутентификации и защиты. Не следует добавлять обход «если заголовка нет, считаем своим» только потому, что ручная команда не проходит. Сервер должен знать, какие типы клиентов он обслуживает, а не угадывать доверие по отсутствию сведений.
CSRF-токен создан вместе с новой локальной сессией. Он не равен id cookie и не выводится в URL. Функция equal сравнивает два буфера через timingSafeEqual после проверки типа и длины. Такой способ не делает весь сервер идеальным, но сохраняет аккуратное сравнение секретного значения. При новом входе создаётся новый csrf, поэтому старая форма может получить отказ. Это ожидаемое следствие жизненного цикла; человеку нужно обновить страницу, а не заставлять сервер принимать токены всех прошлых сессий.
Порядок проверок
Сначала проверяем сессию, затем роль editor. Только после этого читаем допустимое тело, проверяем Origin и csrf, валидируем название и изменяем массив. Такой порядок удерживает авторизацию перед действием. В форме может быть любое значение hidden-поля: клиент способен изменить его. Но сервер сравнивает с независимым значением Map, которое браузер не назначает. Аналогично наличие ссылки или класса editor в HTML не является разрешением; route повторяет решение сам для каждого POST.
Функция form принимает только content type application/x-www-form-urlencoded и ограничивает тело 8192 байтами. Она разбирает данные через URLSearchParams, а не ищет строки title= вручную. Это сохраняет нормальное кодирование пробелов и специальных символов. На недоверенном публичном сервере полезны более точные ограничения и обработка потоков; наш небольшой код показывает базовую границу. Слишком большое тело заканчивает операцию ошибкой, а не обрезается в произвольной точке с возможной потерей csrf или названия.
После получения title удаляем внешние пробелы и проверяем непустое значение и длину. Атрибут maxlength на input является удобством браузера, не доверенной проверкой. Посетитель может отправить HTTP-запрос напрямую. В массив записываем только проверенное название и sub текущего пользователя. Автор не приходит из формы: поле author=anna не позволило бы посетителю выбрать чужую идентичность. Этот маленький пример показывает, как уже принятое состояние входа должно связываться с изменяемыми бизнес-данными.
Сохранение и переход после формы
После записи сервер отправляет 303 на /editor. Такой переход помогает избежать обычного повторного POST при обновлении страницы после успешного действия. Однако это не полноценная идемпотентность. Двойной клик или два параллельных запроса всё ещё могут добавить две заявки. Для реальной операции с платежом или публикацией нужен отдельный idempotency key либо уникальное бизнес-ограничение. Redirect улучшает пользовательский маршрут, но не должен выдавать себя за гарантию однократного изменения данных.
Массив ограничен ста элементами: при переполнении удаляется самый старый. Это памятьный предел лаборатории, не архив заявок. Рестарт удалит всё состояние, а второй Node-процесс будет иметь другой массив. Если нужна настоящая редакционная история, необходимы база, транзакция, связь с issuer/sub, дата и согласованный аудит. Пользовательская роль сама по себе не сохраняет эти данные. Курс остаётся про механизм входа и серверное решение, поэтому устойчивое хранение не прячется в случайном файле или несуществующем сервисе.
Вывод названия безопасным текстом
На странице каждое название проходит esc. Редактор — это полномочие приложения, а не гарантия отсутствия специальных символов в данных. Если title содержит <script>, оно должно отображаться буквами, а не выполнять код. CSRF-токен также экранируется в атрибуте. Такие меры не конкурируют с ролью: авторизация решает, можно ли добавить запись, экранирование решает, как безопасно показать её потом. Смешение этих обязанностей часто приводит к ошибке «это же доверенный администратор, ему можно вставить любой HTML».
Полный body.mjs и сервер ниже соответствуют самостоятельному состоянию. При собственном входе Анны и отправке формы ожидается новая заявка и redirect. Борис должен получить отказ по роли ещё до изменения. POST с неверным Origin или csrf также не должен добавить запись. Эти сценарии здесь не выполнялись. Проверяя их самостоятельно, сопоставляйте конкретное условие, а не только внешний текст страницы: общий catch может давать одинаковое сообщение для нескольких различных технических причин.
Следующий урок добавит второе приложение reader по другому порту и отдельному clientId. Оно будет использовать SSO-сессию того же Keycloak, но собственную cookie и локальную Map. Это поможет увидеть, почему защита формы «Пропуска» не переносится магически на соседний сервис. Каждое приложение принимает свой OIDC-результат, создаёт свой контекст и самостоятельно проверяет полномочия и изменение данных.
Полный файл body.mjs из состояния lesson-08:
export async function form(req) {
const type = req.headers['content-type'] || '';
if (!type.startsWith('application/x-www-form-urlencoded')) throw new Error('Unsupported body');
let body = '';
for await (const chunk of req) {
body += chunk.toString('utf8');
if (Buffer.byteLength(body) > 8192) throw new Error('Body too large');
}
return new URLSearchParams(body);
}
Полный файл server.mjs из состояния lesson-08:
import { createServer } from 'node:http';
import { config, origin, port } from './config.mjs';
import { begin, finish } from './oidc.mjs';
import { sessions, cookies, cookie, current, random, equal } from './sessions.mjs';
import { form } from './body.mjs';
const name = `propusk_${port}`;
const pendingName = `${name}_pending`;
const requests = [];
const esc = value => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"');
function send(res, status, body, extra = {}) {
const payload = Buffer.from(body);
res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
'Content-Security-Policy': "default-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
'Content-Length': payload.length, ...extra });
res.end(payload);
}
function page(body) { return '<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Пропуск</title><main><h1>Пропуск</h1>' + body + '</main></html>'; }
const server = createServer(async (req, res) => {
try {
const url = new URL(req.url, origin); // Ignore an untrusted Host header.
if (req.method === 'GET' && url.pathname === '/metadata') {
send(res, 200, page('<pre>' + esc(JSON.stringify(config.serverMetadata(), null, 2)) + '</pre>'));
return;
}
let { id, value: session } = current(req, name);
if (req.method === 'GET' && url.pathname === '/login') {
const attempt = await begin();
send(res, 303, '', { Location: attempt.url.href, 'Set-Cookie': cookie(pendingName, attempt.id, 300) });
return;
}
if (req.method === 'GET' && url.pathname === '/callback') {
const result = await finish(url, cookies(req)[pendingName]);
if (id) sessions.delete(id);
const newId = random();
sessions.set(newId, { ...result, csrf: random(), until: Date.now() + 900_000,
tokenUntil: Date.now() + Math.max(0, Number(result.tokens.expires_in || 0)) * 1000 });
send(res, 303, '', { Location: '/', 'Set-Cookie': [cookie(name, newId), cookie(pendingName, '', 0)] });
return;
}
const editor = session && Array.isArray(session.claims.realm_roles) && session.claims.realm_roles.includes('editor');
if (req.method === 'GET' && url.pathname === '/editor') {
if (!session) { send(res, 401, page('<p>Требуется вход.</p>')); return; }
if (!editor) { send(res, 403, page('<p>Нужна роль editor.</p>')); return; }
send(res, 200, page('<h2>Редакторская заявка</h2><form method="post" action="/publish"><input type="hidden" name="csrf" value="' + esc(session.csrf) + '"><label>Название <input name="title" maxlength="120" required></label><button>Добавить заявку</button></form><ul>' + requests.map(x => '<li>' + esc(x.title) + '</li>').join('') + '</ul>'));
return;
}
if (req.method === 'POST' && url.pathname === '/publish') {
if (!session) { send(res, 401, page('<p>Войдите.</p>')); return; }
if (!editor) { send(res, 403, page('<p>Нет права editor.</p>')); return; }
const data = await form(req);
if (req.headers.origin !== origin || !equal(data.get('csrf'), session.csrf)) { send(res, 403, page('<p>Недействительное происхождение или CSRF-токен.</p>')); return; }
const title = (data.get('title') || '').trim();
if (!title || title.length > 120) { send(res, 400, page('<p>Нужно название до 120 символов.</p>')); return; }
requests.push({ title, author: session.claims.sub });
if (requests.length > 100) requests.shift();
send(res, 303, '', { Location: '/editor' });
return;
}
if (req.method !== 'GET' || url.pathname !== '/') { send(res, 404, page('<p>Страница не найдена.</p>')); return; }
let body = '<p>Локальная лаборатория единого входа.</p>';
body += session ? '<p>Вы вошли: ' + esc(session.claims.preferred_username || session.claims.sub) + '</p>' : '<p>Вы не вошли.</p>';
body += '<p><a href="/login">Войти через Keycloak</a></p>';
body += '<p><a href="/editor">Редакторский кабинет</a></p>';
send(res, 200, page(body));
} catch {
// Callback URLs, tokens and password values are deliberately not logged.
send(res, 400, page('<p>Операция входа не завершена. Начните новый вход.</p>'));
}
});
server.requestTimeout = 10_000;
server.listen(port, '127.0.0.1', () => console.log(`Local application: ${origin}`));
Оглавление серии · Исходники всех шагов · Учебные маршруты
Пример остаётся локальным черновиком. При подготовке он не выполнялся; все описанные эффекты являются ожидаемыми, а не результатами запущенных проверок.