439 lines
54 KiB
JavaScript
439 lines
54 KiB
JavaScript
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
const p = (text) => '<p>' + text + '</p>';
|
||
const h2 = (text) => '<h2>' + text + '</h2>';
|
||
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
|
||
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
|
||
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
|
||
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
|
||
|
||
function plainText(content) {
|
||
return content
|
||
.replace(/<[^>]+>/g, ' ')
|
||
.replaceAll(' ', ' ')
|
||
.replaceAll('"', '"')
|
||
.replaceAll(''', "'")
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('&', '&')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function bodyText(content) {
|
||
return plainText(content.replace(/<h2>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, ''));
|
||
}
|
||
|
||
const sources = [
|
||
{
|
||
title: 'IETF RFC 6265: HTTP State Management Mechanism, April 2011',
|
||
url: 'https://datatracker.ietf.org/doc/html/rfc6265',
|
||
note: 'стандарт IETF, опубликованный в 2011 году. Он задаёт передачу Cookie/Set-Cookie, scope, Secure и HttpOnly; он не задаёт прикладной срок валидности серверной сессии.',
|
||
},
|
||
{
|
||
title: 'IETF draft-ietf-httpbis-rfc6265bis-11: Cookies: HTTP State Management Mechanism, November 2022',
|
||
url: 'https://datatracker.ietf.org/doc/html/draft-ietf-httpbis-rfc6265bis-11',
|
||
note: 'датированный Internet-Draft, опубликованный 7 ноября 2022 года и доступный к февралю 2023. Это work in progress, а не финальный RFC; здесь он нужен для SameSite и префикса __Host-.',
|
||
},
|
||
{
|
||
title: 'NIST SP 800-63B: Digital Identity Guidelines — Authentication and Lifecycle Management, June 2017 with updates through 2 March 2020',
|
||
url: 'https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63b.pdf',
|
||
note: 'официальный датированный документ NIST, доступный к февралю 2023. Его требования относятся к заданному NIST контексту; он помогает отделить cookie от серверного timeout, logout и reauthentication.',
|
||
},
|
||
];
|
||
|
||
function sourceList() {
|
||
return '<ul>' + sources.map((item) => '<li><a href="' + item.url + '" target="_blank" rel="noopener noreferrer">' + item.title + '</a> — ' + item.note + '</li>').join('') + '</ul>';
|
||
}
|
||
|
||
function revision(meta, parts) {
|
||
const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + '\n' + sourceList();
|
||
const proseLength = bodyText(contentHtml).length;
|
||
if (proseLength < 8000 || proseLength > 10000) {
|
||
throw new Error(meta.slug + ': основной текст вне целевого диапазона 8 000–10 000 знаков: ' + proseLength);
|
||
}
|
||
return { ...meta, contentHtml, proseLength };
|
||
}
|
||
|
||
/**
|
||
* Детерминированная учебная модель одной сессии.
|
||
* Она не создаёт криптографические идентификаторы, не читает cookie jar,
|
||
* не отправляет HTTP, не моделирует вкладки и не проверяет конфигурацию.
|
||
* Все идентификаторы, время и политика намеренно заданы строками в памяти.
|
||
*/
|
||
const COOKIE_POLICY = Object.freeze({
|
||
name: '__Host-session',
|
||
path: '/',
|
||
domain: null,
|
||
secure: true,
|
||
httpOnly: true,
|
||
sameSite: 'Lax',
|
||
maxAgeSeconds: 900,
|
||
});
|
||
|
||
function freezeRecord(record) {
|
||
return Object.freeze({ ...record, cookie: COOKIE_POLICY });
|
||
}
|
||
|
||
function freezeState(snapshot) {
|
||
const records = Object.fromEntries(
|
||
Object.entries(snapshot.records).map(([id, record]) => [id, freezeRecord(record)]),
|
||
);
|
||
return Object.freeze({
|
||
kind: 'in-memory-session-fixture-v1',
|
||
modelLimit: 'no-browser-no-network-no-crypto-no-clock',
|
||
records: Object.freeze(records),
|
||
currentByLineage: Object.freeze({ ...snapshot.currentByLineage }),
|
||
nextId: snapshot.nextId,
|
||
});
|
||
}
|
||
|
||
function serializeTeachingCookie(value, maxAgeSeconds) {
|
||
const base = COOKIE_POLICY.name + '=' + value
|
||
+ '; Path=' + COOKIE_POLICY.path
|
||
+ '; Secure; HttpOnly; SameSite=' + COOKIE_POLICY.sameSite;
|
||
if (maxAgeSeconds === 0) return base + '; Expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
||
return base + '; Max-Age=' + maxAgeSeconds;
|
||
}
|
||
|
||
function stale(state, reason) {
|
||
return Object.freeze({ accepted: false, reason, state });
|
||
}
|
||
|
||
function currentRecord(state, sessionId) {
|
||
const record = state?.records?.[sessionId];
|
||
if (!record) return { reason: 'unknown-session' };
|
||
if (record.status !== 'active' || state.currentByLineage[record.lineage] !== sessionId) {
|
||
return { reason: 'stale-session' };
|
||
}
|
||
return { record };
|
||
}
|
||
|
||
export function createTeachingSessionState() {
|
||
const first = {
|
||
id: 'fixture-s-1',
|
||
lineage: 'fixture-lineage-a',
|
||
generation: 1,
|
||
status: 'active',
|
||
issuedAt: 'fixture-t0',
|
||
};
|
||
return freezeState({
|
||
records: { [first.id]: first },
|
||
currentByLineage: { [first.lineage]: first.id },
|
||
nextId: 2,
|
||
});
|
||
}
|
||
|
||
export function rotateTeachingSession(state, sessionId) {
|
||
const current = currentRecord(state, sessionId);
|
||
if (!current.record) return stale(state, current.reason);
|
||
|
||
const successorId = 'fixture-s-' + state.nextId;
|
||
const retired = {
|
||
...current.record,
|
||
status: 'rotated',
|
||
successorId,
|
||
};
|
||
const successor = {
|
||
id: successorId,
|
||
lineage: current.record.lineage,
|
||
generation: current.record.generation + 1,
|
||
status: 'active',
|
||
issuedAt: 'fixture-t' + state.nextId,
|
||
issuedFrom: current.record.id,
|
||
};
|
||
const next = freezeState({
|
||
records: {
|
||
...state.records,
|
||
[retired.id]: retired,
|
||
[successor.id]: successor,
|
||
},
|
||
currentByLineage: {
|
||
...state.currentByLineage,
|
||
[successor.lineage]: successor.id,
|
||
},
|
||
nextId: state.nextId + 1,
|
||
});
|
||
return Object.freeze({
|
||
accepted: true,
|
||
reason: 'rotated',
|
||
state: next,
|
||
setCookie: serializeTeachingCookie(successor.id, COOKIE_POLICY.maxAgeSeconds),
|
||
});
|
||
}
|
||
|
||
export function logoutTeachingSession(state, sessionId) {
|
||
const current = currentRecord(state, sessionId);
|
||
if (!current.record) return stale(state, current.reason);
|
||
|
||
const revoked = { ...current.record, status: 'revoked', revokedAt: 'fixture-logout' };
|
||
const { [current.record.lineage]: removedCurrent, ...remainingCurrent } = state.currentByLineage;
|
||
void removedCurrent;
|
||
const next = freezeState({
|
||
records: { ...state.records, [revoked.id]: revoked },
|
||
currentByLineage: remainingCurrent,
|
||
nextId: state.nextId,
|
||
});
|
||
return Object.freeze({
|
||
accepted: true,
|
||
reason: 'logged-out',
|
||
state: next,
|
||
clearCookie: serializeTeachingCookie('', 0),
|
||
});
|
||
}
|
||
|
||
export function runSessionFixture() {
|
||
const initial = createTeachingSessionState();
|
||
const rotated = rotateTeachingSession(initial, 'fixture-s-1');
|
||
const staleRotation = rotateTeachingSession(rotated.state, 'fixture-s-1');
|
||
const staleLogout = logoutTeachingSession(rotated.state, 'fixture-s-1');
|
||
const currentLogout = logoutTeachingSession(rotated.state, 'fixture-s-2');
|
||
const rotateAfterLogout = rotateTeachingSession(currentLogout.state, 'fixture-s-2');
|
||
|
||
return Object.freeze({
|
||
assertions: Object.freeze({
|
||
startsWithOneActiveSession: initial.currentByLineage['fixture-lineage-a'] === 'fixture-s-1',
|
||
cookieUsesHostPrefixAndNoDomain: initial.records['fixture-s-1'].cookie.name === '__Host-session' && initial.records['fixture-s-1'].cookie.domain === null,
|
||
cookieHasTransportScriptAndSiteFlags: initial.records['fixture-s-1'].cookie.secure && initial.records['fixture-s-1'].cookie.httpOnly && initial.records['fixture-s-1'].cookie.sameSite === 'Lax',
|
||
rotationIsAccepted: rotated.accepted === true && rotated.reason === 'rotated',
|
||
rotationRetiresPriorIdentifier: rotated.state.records['fixture-s-1'].status === 'rotated' && rotated.state.records['fixture-s-1'].successorId === 'fixture-s-2',
|
||
rotationMovesLineagePointer: rotated.state.currentByLineage['fixture-lineage-a'] === 'fixture-s-2' && rotated.state.records['fixture-s-2'].generation === 2,
|
||
rotationSetsOnlySuccessorCookie: rotated.setCookie.includes('__Host-session=fixture-s-2') && rotated.setCookie.includes('Max-Age=900'),
|
||
staleRotationIsRejected: staleRotation.accepted === false && staleRotation.reason === 'stale-session',
|
||
staleRotationDoesNotCreateAnotherSession: staleRotation.state === rotated.state && !Object.hasOwn(staleRotation.state.records, 'fixture-s-3'),
|
||
staleLogoutIsRejected: staleLogout.accepted === false && staleLogout.reason === 'stale-session',
|
||
staleLogoutDoesNotRevokeSuccessor: staleLogout.state === rotated.state && staleLogout.state.records['fixture-s-2'].status === 'active',
|
||
currentLogoutRevokesServerRecord: currentLogout.accepted === true && currentLogout.state.records['fixture-s-2'].status === 'revoked' && !Object.hasOwn(currentLogout.state.currentByLineage, 'fixture-lineage-a'),
|
||
currentLogoutClearsMatchingCookieShape: currentLogout.clearCookie === '__Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT',
|
||
loggedOutSessionCannotRotate: rotateAfterLogout.accepted === false && rotateAfterLogout.reason === 'stale-session',
|
||
fixtureDeclaresItsBoundary: initial.modelLimit === 'no-browser-no-network-no-crypto-no-clock',
|
||
}),
|
||
samples: Object.freeze({ initial, rotated, staleRotation, staleLogout, currentLogout, rotateAfterLogout }),
|
||
});
|
||
}
|
||
|
||
const fixtureExample = `import {
|
||
createTeachingSessionState,
|
||
rotateTeachingSession,
|
||
logoutTeachingSession,
|
||
} from './upgrade-2023-02.mjs';
|
||
|
||
const initial = createTeachingSessionState();
|
||
const rotated = rotateTeachingSession(initial, 'fixture-s-1');
|
||
const staleLogout = logoutTeachingSession(rotated.state, 'fixture-s-1');
|
||
const currentLogout = logoutTeachingSession(rotated.state, 'fixture-s-2');
|
||
|
||
console.log(rotated.accepted); // true
|
||
console.log(staleLogout.reason); // stale-session
|
||
console.log(currentLogout.clearCookie);
|
||
// __Host-session=; Path=/; Secure; HttpOnly; SameSite=Lax; Expires=Thu, 01 Jan 1970 00:00:00 GMT`;
|
||
|
||
const practice = revision({
|
||
slug: 'editorial-2023-02-practice-sessions-auth',
|
||
title: 'Одна сессия от входа до logout: короткий контракт cookie и сервера',
|
||
categories: ['Безопасность', 'Аутентификация'],
|
||
cover: '/assets/editorial/2023/sessions-auth-2023-lifecycle.svg',
|
||
excerpt: 'Учебный контракт одной сессии: cookie выбирает идентификатор, сервер хранит состояние, rotation выводит старый ID из обращения, logout завершает оба слоя.',
|
||
readingMinutes: 12,
|
||
}, [
|
||
p('В обработчике входа часто видны четыре независимые строчки: выдать cookie, поставить Secure, обновить ID и сделать logout. Проблема начинается, когда каждая строчка живёт по своей логике. Старый идентификатор продолжает проходить после rotation, cookie очищается без серверного revoke или срок в браузере принимают за срок доступа. Цена ошибки конкретна: пользователь считает сессию закрытой, а сервер ещё принимает её; либо новый вход ломается поздним ответом со старым состоянием.'),
|
||
p('Ниже — один контракт учебной сессии: браузер хранит непрозрачный ID, сервер хранит статус, и оба слоя меняются одним решением. После входа запись active; rotation выпускает successor и выводит predecessor из обращения; logout revoke-ит current ID и просит клиент удалить cookie. Это не browser trace и не запуск приложения, а in-memory fixture с явно заданными состояниями и идентификаторами.'),
|
||
h2('Сначала разделите носитель и источник истины'),
|
||
p('Cookie не является сессией. Это способ вернуть серверу строку в заголовке Cookie, если браузер решил, что её scope подходит запросу. RFC 6265 описывает именно такую передачу и допускает, что user agent удалит cookie раньше срока. Поэтому Max-Age полезен как клиентский предел хранения, но не должен быть единственной проверкой timeout. В контракте active, rotated и revoked принадлежат серверной записи; cookie только выбирает запись, которую сервер ещё обязан проверить.'),
|
||
p('Это разделение меняет и формулировку logout. Удалить cookie недостаточно: другой клиент, сохранённый запрос или уже отправленный заголовок может всё ещё предъявить идентификатор. Сервер должен решить, принимает ли он его. В учебной модели после logout запись получает статус revoked и исчезает из currentByLineage. Отдельный Set-Cookie с Expires в прошлом просит браузер убрать прежнее значение. Оба действия нужны, но они доказывают разное: первое закрывает доступ в модели, второе убирает удобный носитель на клиенте.'),
|
||
table('Контракт одной сессии: кто владеет решением', ['Объект', 'Минимальное поле', 'Кто проверяет', 'Что происходит при logout', 'Чего этого недостаточно'], [
|
||
['Cookie', '__Host-session=ID; Path=/; Secure; HttpOnly; SameSite=Lax', 'user agent решает, приложить ли его к запросу', 'получает Set-Cookie с Expires в прошлом той же формы', 'доказать, что сервер уже revoke-ил ID'],
|
||
['Session record', 'id, lineage, generation, status', 'сервер перед обработкой защищённого действия', 'current ID становится revoked', 'принять решение за другой сервис без общей политики'],
|
||
['Lineage pointer', 'lineage → active ID', 'операция rotation/logout', 'ссылка на active ID удаляется', 'описать logout всех устройств'],
|
||
['Set-Cookie при rotation', 'successor ID и та же политика flags', 'сервер формирует ответ', 'не выдаётся при stale reference', 'синхронизировать вкладки или порядок сетевых ответов'],
|
||
]),
|
||
h2('Минимальный fixture: проверить переходы, не притворяясь браузером'),
|
||
p('Скрипт этой партии экспортирует createTeachingSessionState, rotateTeachingSession и logoutTeachingSession. Первый вызов создаёт fixture-s-1 с состоянием active. Rotation принимает только текущий ID для линии fixture-lineage-a, помечает его rotated и создаёт fixture-s-2. Поздняя операция со старым ID возвращает stale-session и получает тот же объект state. Это намеренно узкая проверка: нет HTTP, хранилища браузера, случайных чисел, конкурентных запросов, clock skew, пользователя или настоящего сервера.'),
|
||
code(fixtureExample),
|
||
p('Здесь stale logout для fixture-s-1 не revoke-ит fixture-s-2: это политика logout только предъявленной current-сессии. Другой продукт может выбрать logout всей lineage или всех устройств, но это должна быть отдельная операция с собственным scope и проверкой. Иначе поздний запрос молча закроет другую активную сессию.'),
|
||
h2('Rotation — замена права, а не продление строки'),
|
||
p('Rotation начинается с проверки текущей записи, а не с Set-Cookie. Предшественник остаётся rotated для диагностики, но уже не current; successor получает новый ID, generation и ту же lineage. Так сервер различает unknown и уже устаревший ID. В production это изменение требует реальной гарантии хранилища; синхронный fixture не называет себя atomic.'),
|
||
p('Для двух одновременных запросов заранее определите один исход: один создаёт successor, другой получает stale/rejected. Без этого появятся два successor. Fixture не моделирует гонку, но фиксирует инвариант для реализации: после rotation один current ID, а повторный старый ID не создаёт fixture-s-3.'),
|
||
figure('/assets/editorial/2023/sessions-auth-2023-lifecycle.svg', 'Жизненный цикл учебной сессии: после входа fixture-s-1 active, rotation переводит его в rotated и выпускает fixture-s-2 active, stale операции со старым ID отклоняются, logout текущего ID делает запись revoked и очищает cookie.', 'Схема показывает контракт одного идентификатора и одной lineage в памяти. Это не trace браузера, не последовательность настоящих HTTP-ответов и не модель нескольких устройств.'),
|
||
h2('Cookie flags описывают область доставки, не авторизацию'),
|
||
p('Для учебного ID выбран __Host-session. В draft-ietf-httpbis-rfc6265bis-11 префикс __Host- требует Secure, Path=/ и отсутствия Domain, то есть фиксирует host-only область. Он не обещает пользователя, права, logout или серверный срок. В феврале 2023 это был Internet-Draft ноября 2022, не финальный RFC. __Host- не годится, если cookie обязана жить на нескольких поддоменах.'),
|
||
p('Secure ограничивает канал доставки, HttpOnly — доступ из browser API, SameSite=Lax — часть cross-site доставок. Ни один не заменяет серверную проверку ID, прав и запроса. Path задаёт маршрут, но не является security boundary. Поэтому каждый flag записывают с его узкой причиной, а не как список безопасности.'),
|
||
h2('Logout: две синхронные обязанности'),
|
||
p('Logout имеет серверную и клиентскую ветви. Сервер revoke-ит только current active ID и снимает pointer; клиент получает то же имя и Path=/ с пустым значением и Expires в прошлом. RFC 6265 связывает замену/удаление с name, Domain и Path: если issue содержал Domain, а clear нет, браузер может оставить другую cookie. Учебный контракт исключает Domain с самого начала.'),
|
||
p('Удаление cookie не доказывает, что logout дошёл до сервера: ответ мог не примениться, а другой запрос уже нести ID. Критерий доступа — server reject после revoke. Очищение остаётся нужно для UX. CSRF-защита logout, если она есть, — отдельный контракт; SameSite не отменяет его.'),
|
||
h2('Маршрут: симптом → причина → проверка → действие'),
|
||
ol([
|
||
'<strong>Симптом.</strong> В выписке change найдите одно из трёх наблюдений: старый ID принимается после renewal, logout меняет только интерфейс или cookie живёт дольше серверной записи. Не называйте это инцидентом, пока нет проверяемых данных.',
|
||
'<strong>Причина.</strong> Для одного ID нарисуйте record, lineage pointer, Set-Cookie при issue/rotation и Set-Cookie при clear. Если один из объектов не имеет владельца, контракт уже разорван.',
|
||
'<strong>Проверка fixture.</strong> Запустите <code>node web/scripts/upgrade-2023-02.mjs --verify-fixture</code>. Он проверяет только 15 предзаданных assertions учебной модели, включая stale rotation и stale logout; он не проверяет браузер, endpoint или конфигурацию.',
|
||
'<strong>Проверка реализации.</strong> Отдельно выберите разрешённую среду и проверьте, что сервер отклоняет rotated/revoked ID, а clear cookie повторяет name, Domain при наличии и Path исходной cookie. Не переносите в отчёт реальные session ID.',
|
||
'<strong>Действие.</strong> Сделайте server-side status обязательным до защищённой операции; rotation обновляет current pointer, logout revoke-ит current pointer. Добавьте явное отдельное действие, если бизнесу нужен logout всех сессий.',
|
||
'<strong>Откат.</strong> Не используйте fixture как план отката. Для production заранее опишите совместимость старых ID, миграцию хранилища, ожидаемый reject и способ вернуть change без повторной легитимации stale идентификатора.',
|
||
]),
|
||
h2('Границы и следующий проверяемый шаг'),
|
||
p('Модель не генерирует секреты: fixture-s-1 и fixture-s-2 нельзя копировать как production ID. В ней нет шифрования, CSRF, пользователя, reauthentication, browser Network/Storage и реальной гонки. NIST SP 800-63B помогает отделить session secret, минимальный cookie scope и server timeout; это контекст документа NIST, а не готовая политика продукта.'),
|
||
p('Следующий шаг — зафиксировать для команды status, lineage, reject rotated/revoked ID и отдельный план logout всех устройств. Затем провести разрешённую интеграционную проверку вне fixture. Если нельзя назвать current ID после rotation, cookie-конфигурации ещё рано доверять.'),
|
||
h2('Историческая граница февраля 2023'),
|
||
p('К февралю 2023 уже существовали RFC 6265 от апреля 2011, NIST SP 800-63B от июня 2017 с обновлениями до 2 марта 2020 и draft-ietf-httpbis-rfc6265bis-11 от 7 ноября 2022. Последний в статье назван work in progress, а не стандартом. Источники объясняют свойства cookie и сессий, но не подтверждают состояние конкретного браузера, балансировщика, базы данных или auth-платформы.'),
|
||
]);
|
||
|
||
const mechanism = revision({
|
||
slug: 'editorial-2023-02-mechanism-sessions-auth',
|
||
title: 'Cookie, rotation и logout: модель одной сессии без скрытого источника истины',
|
||
categories: ['Безопасность', 'Аутентификация'],
|
||
cover: '/assets/editorial/2023/sessions-auth-2023-cookie-contract.svg',
|
||
excerpt: 'Разбор границы между cookie и серверной записью: что проверяют Secure, HttpOnly и SameSite, почему rotation меняет current ID, а logout не заканчивается удалением строки в браузере.',
|
||
readingMinutes: 12,
|
||
}, [
|
||
p('Ошибка в сессиях часто выглядит спором о flag: добавить SameSite, HttpOnly или Max-Age. Проблема глубже, если непонятно, где решается валидность ID после rotation/logout. Тогда один handler верит cookie, другой TTL в базе, третий очищает только клиент. Цена — противоречивый доступ или потеря новой сессии поздней операцией со старым ID.'),
|
||
p('Достаточно выбрать один поток и владельца каждого факта. Здесь cookie переносит непрозрачный session ID; server record хранит status и generation; lineage указывает на один active ID. Rotation меняет record и выдаёт cookie, logout revoke-ит record и возвращает clear cookie. Fixture проверяет это в памяти, без браузера, storage/network trace и вывода о реальной платформе.'),
|
||
h2('Пять состояний, которые не стоит склеивать'),
|
||
p('Фраза «сессия истекла» скрывает разные факты: cookie нет, но record ещё жив; cookie есть, но record revoked; ID известен, но уже не current; доступ отклонён по правам при active сессии. Поздний Set-Cookie тоже не доказывает порядок его применения браузером. Эти состояния нельзя склеивать без риска случайно изменить authorization.'),
|
||
p('Модель не выбирает SQL, cache, signed token или framework store. Она требует лишь отличить current от rotated/revoked до защищённого эффекта. Порядок Cookie и похожее имя не должны решать доступ: RFC 6265 не советует полагаться на сериализацию одинаковых cookie.'),
|
||
table('Слои одной сессии и вопрос, на который отвечает каждый', ['Слой', 'Вопрос', 'Данные в учебной модели', 'Нормальный переход', 'Неверный вывод'], [
|
||
['Аутентификация', 'кто прошёл вход?', 'в fixture не представлена', 'создаёт initial session record', 'cookie сама доказывает личность'],
|
||
['Cookie delivery', 'какую строку браузер приложит?', '__Host-session и flags', 'issue, rotate, clear', 'Max-Age решает серверный timeout'],
|
||
['Server record', 'принимать ли этот ID сейчас?', 'status и generation', 'active → rotated или revoked', 'наличие ID равно доступу'],
|
||
['Lineage', 'какой ID является current?', 'fixture-lineage-a → fixture-s-2', 'pointer сдвигается при rotation', 'старый ID можно повторно продлить'],
|
||
['Authorization', 'можно ли выполнить действие?', 'в fixture не представлена', 'проверяется отдельно после ID', 'active session даёт любое право'],
|
||
]),
|
||
h2('Flags: точные границы вместо списка галочек'),
|
||
p('Secure ограничивает канал доставки с точки зрения user agent, но не подписывает server record. HttpOnly закрывает значение от не-HTTP API, но не лечит XSS и не отменяет запросы браузера с cookie. Оба flag описывают рядом с их узким эффектом.'),
|
||
p('SameSite=Lax ограничивает часть cross-site доставок. Draft-11 различает Strict, Lax и None; Lax допускает safe top-level navigation помимо same-site запросов. Это защита в глубину, не доказательство корректности mutation endpoint. Embed или federation могут потребовать другой контекст и отдельную проверку server request.'),
|
||
p('Max-Age/Expires задают максимум browser хранения, но user agent может удалить cookie раньше. В draft-11 Max-Age приоритетнее Expires; сервер не обязан принимать ID до того же момента. NIST SP 800-63B не предлагает полагаться на expiry cookie для timeout: browser lifetime и server expiry хранят отдельно.'),
|
||
h2('Почему __Host- полезен только при подходящем scope'),
|
||
p('Имя __Host-session означает не расширять cookie на поддомены. Для conformant user agent префикс требует Secure, Path=/ и отсутствия Domain, поэтому issue и clear имеют один scope. Это не namespace безопасности и не замена ревью hostnames. Если cookie нужна на нескольких поддоменах, __Host- не подходит: Domain и его владельцев надо описать явно.'),
|
||
p('Path=/ даёт cookie доступ на всём host и выполняет условие префикса, но не делает путь security boundary. Разделение административной области обеспечивает server authz и архитектура hostnames.'),
|
||
figure('/assets/editorial/2023/sessions-auth-2023-cookie-contract.svg', 'Контракт учебной cookie __Host-session: Secure ограничивает отправку защищённым каналом, HttpOnly скрывает значение от JavaScript API, SameSite=Lax ограничивает часть cross-site доставок, Path=/ и отсутствие Domain нужны для __Host-, а серверная запись отдельно проверяет active status.', 'Рисунок разделяет browser delivery и server validation. Он не показывает совместимость конкретного браузера, реальный заголовок ответа или достаточность защиты от CSRF.'),
|
||
h2('Rotation: сохранить lineage, заменить предъявляемый ID'),
|
||
p('Rotation меняет значение, которое сервер считает current. Fixture оставляет fixture-s-1 rotated, добавляет fixture-s-2 generation=2 и переписывает pointer. Старый ID получает stale-session, а не новый TTL: сервер не смешивает известный, но заменённый ID с действующим.'),
|
||
p('Реализация должна определить линейзацию: транзакцию, conditional update, compare-and-swap или другую гарантию хранилища. Иначе два запроса выпустят два successor. Fixture не моделирует параллелизм, но его staleRotationDoesNotCreateAnotherSession — требование к реальному механизму.'),
|
||
h2('Logout: явный scope важнее красивого 204'),
|
||
p('Scope logout называют до кода. Logout-current revoke-ит один record; logout-all ищет все records identity; logout-lineage лежит между ними. Их нельзя скрыть за единым endpoint без явного правила: stale запрос иначе либо оставит доступ, либо выключит новую сессию.'),
|
||
p('Fixture выбирает logout-current: fixture-s-1 после rotation получает stale-session и не трогает fixture-s-2. Это инвариант выбранного contract, не правило для любого сервиса. Если нужен другой эффект, создают logout-lineage/logout-all и проверяют его последствия отдельно.'),
|
||
h2('Проверяемый код и ожидаемые границы'),
|
||
p('Пример воспроизводим, потому что не зависит от времени, сети или secret store. В нём нет пользователя, браузерных API и настоящего Set-Cookie parser. Он проверяет только переходы: rotation меняет pointer, stale операции не изменяют successor, current logout revoke-ит record и формирует clear cookie.'),
|
||
code(`import {
|
||
createTeachingSessionState,
|
||
rotateTeachingSession,
|
||
logoutTeachingSession,
|
||
} from './upgrade-2023-02.mjs';
|
||
|
||
const before = createTeachingSessionState();
|
||
const rotation = rotateTeachingSession(before, 'fixture-s-1');
|
||
const oldIdLogout = logoutTeachingSession(rotation.state, 'fixture-s-1');
|
||
const newIdLogout = logoutTeachingSession(rotation.state, 'fixture-s-2');
|
||
|
||
if (oldIdLogout.accepted) throw new Error('stale logout changed successor');
|
||
if (newIdLogout.state.records['fixture-s-2'].status !== 'revoked') {
|
||
throw new Error('current logout did not revoke record');
|
||
}`),
|
||
p('Для полного набора assertions используется команда <code>node web/scripts/upgrade-2023-02.mjs --verify-fixture</code>. До запуска в проекте прочитайте сами assertions: fixture не умеет обнаружить неверный reverse proxy, заголовки framework, маршрут logout, race в базе или поведение browser cookie jar. Он удобен как короткий договор на review, но не как тест, который заменяет интеграционный сценарий. Производственный тест должен иметь разрешённую среду, безопасные тестовые данные и отдельно названные наблюдения.'),
|
||
h2('Маршрут: симптом → причина → проверка → действие'),
|
||
ol([
|
||
'<strong>Симптом.</strong> Найдите место, где код заключает «cookie есть — сессия валидна», или «Max-Age истёк — сервер уже всё закрыл». Это наблюдаемый пробел контракта, а не диагноз всей системы.',
|
||
'<strong>Причина.</strong> Разделите browser delivery, server record, lineage и authorization. Для каждого напишите один владелец и один момент изменения.',
|
||
'<strong>Проверка flags.</strong> Сверьте name, Secure, HttpOnly, SameSite, Path, Domain и client lifetime с нужным scope. Отдельно запишите, что каждый флаг не решает.',
|
||
'<strong>Проверка rotation.</strong> В разрешённом тесте отправьте старый ID после successful replacement и убедитесь, что он не становится current. Для маленького договора сначала выполните fixture.',
|
||
'<strong>Действие.</strong> Сделайте серверную проверку status/current обязательной перед защищённым эффектом; определите один явный механизм линейзации rotation и отдельные операции logout-current, logout-lineage или logout-all.',
|
||
'<strong>Откат.</strong> Не продлевайте retired ID ради быстрого отката. Подготовьте совместимую ветку или migration plan, которые сохраняют reject старого ID и позволяют объяснить состояние сессии пользователю.',
|
||
]),
|
||
h2('Ограничения модели и следующий шаг'),
|
||
p('Статья не утверждает, что __Host- или SameSite=Lax подходят каждому приложению. В ней нет оценки AAL, криптографии, устройств, distributed store, CORS/CSRF, browser trace и измерения. fixture-s-1, fixture-t0 и 900 секунд учебные. IETF/NIST задают словарь и ограничения, не принимают за команду решение о scope, UX или гонках.'),
|
||
p('Следующий шаг — выписать для одного protected handler допустимые status, смысл rotated, владельца lineage и logout scope, затем добавить отдельный интеграционный сценарий с безопасными тестовыми данными. Если client expiry смешан с server revocation, сначала исправляют эту границу.'),
|
||
h2('Историческая граница февраля 2023'),
|
||
p('Все три источника в конце существовали не позднее февраля 2023: RFC 6265 опубликован в 2011, NIST SP 800-63B — в 2017 с обновлениями 2020 года, а draft-ietf-httpbis-rfc6265bis-11 — 7 ноября 2022. В частности, draft-11 остаётся work in progress и приведён именно с этим статусом. Ссылки нужны для проверки описанной модели, а не как свидетельство запуска или конфигурации неизвестного сервиса.'),
|
||
]);
|
||
|
||
const field = revision({
|
||
slug: 'editorial-2023-02-field-sessions-auth',
|
||
title: 'Старый session ID после rotation: маршрут проверки logout без выдуманного trace',
|
||
categories: ['Безопасность', 'Аутентификация', 'Практика'],
|
||
cover: '/assets/editorial/2023/sessions-auth-2023-stale-events.svg',
|
||
excerpt: 'Диагностический маршрут для случая, когда logout, renewal и cookie выглядят как разные события: как собрать факты, отделить stale ID от текущего и не объявить fixture browser trace.',
|
||
readingMinutes: 12,
|
||
}, [
|
||
p('Симптом часто звучит так: «в одной вкладке вышли, в другой ещё есть доступ» или «после renewal разлогинило». Из этого не видно, какой ID пришёл на сервер, какой был current и какой logout scope выбран. Цена правки наугад — старая сессия остаётся принятой либо поздний logout закрывает новую.'),
|
||
p('Ниже — маршрут, не отчёт об инциденте: нет пользователя, вкладок, Network/Storage или HTTP-запуска. Есть fixture одной lineage: fixture-s-1 становится rotated, fixture-s-2 active, старый ID stale, logout нового ID revoke-ит его. Это задаёт вопросы для реального расследования, не выдумывает trace.'),
|
||
h2('Соберите четыре факта до изменения флага'),
|
||
p('Начинать стоит с формата записи: безопасный surrogate ID, server-side status в момент обработки, lineage/current ID и тип операции. Полный bearer secret в лог не попадает. Нужна корреляция, по которой reviewer сопоставит request с решением, но не повторит сессию.'),
|
||
p('Отделяйте факт от интерпретации. «Cookie не удалена» требует разрешённой проверки клиента; «logout не дошёл» — evidence запроса или server record; «старый ID прошёл» — статус именно старого ID. Если данных нет, причина не подтверждена: fixture подсказывает инвариант, не историю браузера.'),
|
||
table('Симптом → причина → проверка → действие для одной lineage', ['Симптом', 'Вероятная причина', 'Проверка без секрета', 'Действие'], [
|
||
['После renewal старый ID ещё выполняет защищённое действие', 'record не стал rotated или handler не проверяет current', 'сопоставить безопасный ID записи, status и lineage pointer в разрешённой среде', 'отклонять rotated ID до эффекта и линейризовать rotation'],
|
||
['Logout меняет UI, но следующий запрос принят', 'очищена только cookie или revoke не дошёл до server record', 'проверить audit event logout и status того же record', 'revoke server record и вернуть clear cookie одинакового scope'],
|
||
['Поздний logout отключает новую сессию', 'не определён scope, stale ID трактуется как current', 'сравнить предъявленный ID с currentByLineage на момент обработки', 'выбрать явный logout-current или logout-lineage и добавить invariant'],
|
||
['После clear cookie видна другая cookie того же имени', 'issue/clear расходятся в Path или Domain', 'сравнить атрибуты Set-Cookie без значения bearer ID', 'удалять cookie теми же name, Path и Domain при наличии'],
|
||
['Проблема есть только в одном браузере', 'политика хранения или порядок ответов отличается', 'повторить разрешённый минимальный сценарий на конкретной версии', 'не менять server contract до подтверждения различия'],
|
||
]),
|
||
h2('Старый ID — это не обязательно неизвестный ID'),
|
||
p('Различайте unknown, rotated и revoked. Unknown — store не нашёл ID; rotated — запись известна, но не current и имеет successor; revoked — сессия завершена. Внешне они могут дать один 401, но внутри должны отличаться, иначе нельзя доказать, что rotation вывела старый ID из обращения.'),
|
||
p('Fixture хранит predecessor ради различия, не как совет хранить records вечно. На период возможных повторов сервер должен отклонять старый ID. RFC 6265 задаёт browser scope доставки; прикладную семантику Cookie определяет сервер.'),
|
||
h2('Воспроизводимый fixture и invariants stale событий'),
|
||
p('Fixture sidecar создаёт current record, делает accepted rotation, затем повторяет rotate/logout со старым ID. Обе stale операции возвращают accepted=false и оставляют successor active. Logout current ID делает record revoked, удаляет pointer и формирует clear cookie. Есть и положительная, и отрицательная ветви.'),
|
||
code(`import {
|
||
createTeachingSessionState,
|
||
rotateTeachingSession,
|
||
logoutTeachingSession,
|
||
} from './upgrade-2023-02.mjs';
|
||
|
||
const first = createTeachingSessionState();
|
||
const replacement = rotateTeachingSession(first, 'fixture-s-1');
|
||
const staleRotation = rotateTeachingSession(replacement.state, 'fixture-s-1');
|
||
const staleLogout = logoutTeachingSession(replacement.state, 'fixture-s-1');
|
||
|
||
if (staleRotation.accepted || staleLogout.accepted) {
|
||
throw new Error('old ID changed current session');
|
||
}
|
||
if (replacement.state.records['fixture-s-2'].status !== 'active') {
|
||
throw new Error('successor is not active');
|
||
}`),
|
||
p('Команда <code>node web/scripts/upgrade-2023-02.mjs --verify-fixture</code> запускает полный набор assertions скрипта. Он воспроизводим только потому, что значения и порядок вызовов фиксированы. Его PASS не подтверждает, что web server выдал Set-Cookie, что браузер применил ответ, что база сделала compare-and-swap или что logout endpoint защищён от подделки. В review укажите этот предел рядом с результатом. Иначе маленький тест станет ложным evidence для нескольких неиспытанных уровней системы.'),
|
||
figure('/assets/editorial/2023/sessions-auth-2023-stale-events.svg', 'Маршрут stale событий в учебной lineage: rotation заменяет fixture-s-1 на fixture-s-2, повторная rotation и поздний logout со старым ID получают reject и не меняют successor, logout текущего ID revoke-ит fixture-s-2 и выдаёт clear cookie.', 'Схема задаёт проектное правило для одного ID и синхронных вызовов в памяти. Она не является browser trace, журналом реального инцидента или доказательством порядка HTTP-ответов.'),
|
||
h2('Порядок ответа и порядок решения — разные задачи'),
|
||
p('Даже с правильным server reject клиент может получить ответы в неудобном порядке. Два запроса ушли со старым ID, один принёс новый Set-Cookie, другой ошибку. Browser cookie jar и fetch/navigation имеют свои правила; fixture их не моделирует. Реальный дефект требует отдельного разрешённого repro с версиями, шагами, маскированными значениями и HTTP-метаданными.'),
|
||
p('Серверный инвариант остаётся: принимается только current active ID; stale событие не создаёт successor и не revoke-ит нового при logout-current. UX после reject выбирает продукт. Не прячьте его бесконечным retry без определения перехода пользователя.'),
|
||
h2('Проверьте clear cookie как договор, а не как строку в шаблоне'),
|
||
p('Удаление cookie требует того же имени и scope, что issue. В модели clear cookie: __Host-session, Path=/, Secure, HttpOnly, SameSite=Lax и Expires в прошлом. __Host- исключает Domain, поэтому issue/clear не расходятся по поддоменному scope. Если Domain нужен в другом контракте, он обязан быть в документе и проверке очистки; не добавляйте его только на logout.'),
|
||
p('Flags clear cookie не отменяют server revoke. RFC 6265 связывает замену с name, domain и path и не советует полагаться на порядок одинаковых Cookie. При нескольких Set-Cookie нужен один owner, один scope и проверка отсутствия дубликата в разрешённом сценарии.'),
|
||
h2('Маршрут: симптом → причина → проверка → действие'),
|
||
ol([
|
||
'<strong>Симптом.</strong> Запишите наблюдаемое последствие без вывода: какой экран, какой внешний status, какая безопасная корреляция операции. Не называйте браузерный trace, если его не собирали.',
|
||
'<strong>Причина.</strong> Проверьте гипотезы по очереди: server record не изменился, handler не сверил current, stale logout имеет неясный scope или issue/clear cookies различаются по атрибутам.',
|
||
'<strong>Проверка модели.</strong> Выполните fixture и прочитайте assertions staleRotationDoesNotCreateAnotherSession и staleLogoutDoesNotRevokeSuccessor. Это проверка выбранного правила, не production доказательство.',
|
||
'<strong>Проверка реализации.</strong> В разрешённой среде соберите ID surrogate, record status, lineage pointer и Set-Cookie attributes без bearer значения. Сверьте момент обработки, а не только время на клиенте.',
|
||
'<strong>Действие.</strong> Сделайте status/current обязательным перед защищённым side effect, выберите понятный logout scope и проверяйте clear cookie на совпадение с issue policy.',
|
||
'<strong>Откат.</strong> При проблеме UX не возвращайте старый ID в active. Сначала сохраните reject stale ID, затем примените отдельный совместимый rollback для экрана, маршрута или новой политики cookie.',
|
||
]),
|
||
h2('Ограничения и безопасный результат расследования'),
|
||
p('Fixture не хранит секреты, не реализует random ID/TLS/часы и не знает browser API, пользователей, устройств, cache, CSRF или конкурентности. Она не доказывает stale logout в продукте и не выбирает HTTP-ответ. Она фиксирует лишь правило logout-current: старый ID не оживляет и не отменяет successor. Другой scope требует другой fixture.'),
|
||
p('Следующий шаг — добавить audit contract: корреляция без bearer secret, server status до protected effect, logout scope и причина reject. Затем проводить отдельный repro только в разрешённой среде. Несобранный факт остаётся неизвестным; это безопаснее правдоподобной, но неповторяемой истории про вкладки.'),
|
||
h2('Историческая граница февраля 2023'),
|
||
p('Этот маршрут опирается на документы, которые существовали к февралю 2023: RFC 6265 от 2011 года, NIST SP 800-63B от 2017 года с обновлениями до 2020 и draft-ietf-httpbis-rfc6265bis-11 от ноября 2022. Draft не объявляется финальной спецификацией. Ни один из источников не даёт trace конкретного браузера и не заменяет целевую проверку реального logout flow.'),
|
||
]);
|
||
|
||
export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item);
|
||
|
||
function verifyFixture() {
|
||
const report = runSessionFixture();
|
||
const failed = Object.entries(report.assertions)
|
||
.filter(([, value]) => value !== true)
|
||
.map(([key]) => key);
|
||
if (failed.length) {
|
||
process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n');
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
const count = Object.keys(report.assertions).length;
|
||
process.stdout.write('PASS fixture: ' + count + '/' + count + ' assertions\n');
|
||
}
|
||
|
||
if (process.argv.includes('--verify-fixture')) verifyFixture();
|
||
if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');
|