function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } const p = (text) => '

' + text + '

'; const h2 = (text) => '

' + text + '

'; const code = (text) => '
' + escapeHtml(Array.isArray(text) ? text.join('\n') : text) + '
'; const ol = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; const figure = (src, alt, caption) => '
' + alt + '
' + caption + '
'; const table = (caption, headers, rows) => '
' + headers.map((item) => '').join('') + '' + rows.map((row) => '' + row.map((item) => '').join('') + '').join('') + '
' + caption + '
' + item + '
' + item + '
'; const sources = [ { title: 'WHATWG Fetch Standard, commit snapshot 8f109835, 24 марта 2023', url: 'https://fetch.spec.whatwg.org/commit-snapshots/8f109835dcff90d19caed4b551a0da32d9d0f57e/', note: 'зафиксированная версия стандарта, доступная до конца марта 2023; нужна для терминов CORS, credentials и preflight, а не как описание конкретной реализации браузера.', }, { title: 'RFC 6454: The Web Origin Concept, декабрь 2011', url: 'https://www.rfc-editor.org/rfc/rfc6454.html', note: 'стандарт IETF: origin определяется tuple scheme, host и port; документ также описывает заголовок Origin.', }, { title: 'W3C Cross-Origin Resource Sharing, Recommendation 16 января 2014', url: 'https://www.w3.org/TR/2014/REC-cors-20140116/', note: 'стабильная историческая рекомендация, доступная задолго до марта 2023; отдельно описывает credentials, preflight и то, что state-changing simple requests требуют CSRF-защиты.', }, ]; function sourceList() { return ''; } 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>[\s\S]*?(?=

|$)/, '')); } 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 }; } const DEFAULT_PORT = Object.freeze({ 'http:': '80', 'https:': '443' }); const SAFELISTED_METHODS = new Set(['GET', 'HEAD', 'POST']); const SAFELISTED_CONTENT_TYPES = new Set(['application/x-www-form-urlencoded', 'multipart/form-data', 'text/plain']); const SAFELISTED_HEADER_NAMES = new Set(['accept', 'accept-language', 'content-language', 'content-type']); const STATE_CHANGING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); function canonicalOrigin(value) { try { const parsed = new URL(value); if (!DEFAULT_PORT[parsed.protocol] || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash) return null; return parsed.protocol + '//' + parsed.hostname + ':' + (parsed.port || DEFAULT_PORT[parsed.protocol]); } catch { return null; } } function declaredOriginList(values) { if (!Array.isArray(values)) return []; return values.map((value) => value === '*' ? '*' : canonicalOrigin(value)).filter(Boolean); } function classifyDeclaredCorsShape(request) { const method = String(request?.method || '').toUpperCase(); const contentType = String(request?.contentType || '').split(';', 1)[0].trim().toLowerCase(); const headerNames = Array.isArray(request?.headerNames) ? request.headerNames.map((name) => String(name).toLowerCase()) : []; const nonSafelistedHeader = headerNames.find((name) => !SAFELISTED_HEADER_NAMES.has(name)); const reasons = []; if (!SAFELISTED_METHODS.has(method)) reasons.push('method-not-safelisted'); if (method === 'POST' && contentType && !SAFELISTED_CONTENT_TYPES.has(contentType)) reasons.push('content-type-not-safelisted'); if (nonSafelistedHeader) reasons.push('header-not-safelisted:' + nonSafelistedHeader); return Object.freeze({ kind: 'declared-cors-shape-v1', status: reasons.length === 0 ? 'safelisted-shape' : 'non-safelisted-shape', reasons: Object.freeze(reasons), }); } function decideCorsContract(policy, request, sourceOrigin, targetOrigin) { if (sourceOrigin === targetOrigin) { return Object.freeze({ decision: 'not-applicable-same-origin', reason: 'cors-is-not-the-declared-boundary' }); } const allowedOrigins = declaredOriginList(policy?.corsAllowedOrigins); const credentialsIncluded = request?.credentialsMode === 'include'; if (credentialsIncluded && allowedOrigins.includes('*')) { return Object.freeze({ decision: 'reject-wildcard-with-credentials', reason: 'credentialed-cors-needs-an-exact-origin' }); } if (!allowedOrigins.includes('*') && !allowedOrigins.includes(sourceOrigin)) { return Object.freeze({ decision: 'deny-origin', reason: 'origin-not-in-cors-allow-list' }); } if (credentialsIncluded && policy?.allowCredentials !== true) { return Object.freeze({ decision: 'deny-credentials', reason: 'allow-credentials-is-not-enabled' }); } return Object.freeze({ decision: credentialsIncluded ? 'allow-exact-origin-and-credentials' : 'allow-origin-without-credentials', reason: credentialsIncluded ? 'exact-origin-and-credentials-declared' : 'origin-declared', }); } function decideCsrfContract(policy, request, sourceOrigin) { const method = String(request?.method || '').toUpperCase(); const cookieSessionMutation = request?.authentication === 'session-cookie' && STATE_CHANGING_METHODS.has(method); if (!cookieSessionMutation) { return Object.freeze({ decision: 'not-required-by-this-contract', reason: 'not-a-declared-cookie-session-mutation' }); } const trustedOrigins = declaredOriginList(policy?.csrfTrustedOrigins); if (!trustedOrigins.includes(sourceOrigin)) { return Object.freeze({ decision: 'deny-origin', reason: 'origin-not-trusted-for-csrf' }); } if (typeof policy?.csrfToken !== 'string' || !policy.csrfToken) { return Object.freeze({ decision: 'reject-policy', reason: 'missing-fixture-csrf-token' }); } if (request?.csrfToken !== policy.csrfToken) { return Object.freeze({ decision: 'deny-token', reason: 'csrf-token-mismatch' }); } return Object.freeze({ decision: 'accept-origin-and-token', reason: 'declared-csrf-proof-matches' }); } /** * Узкий учебный контракт в памяти. Он не вызывает Fetch, не открывает сеть, * не хранит и не отправляет cookie, не создаёт OPTIONS и не контактирует с API. * Поля request — заданные тестом labels, а не наблюдённый browser trace. */ export function evaluateInMemoryCsrfCorsContract(policy, request) { const targetOrigin = canonicalOrigin(policy?.targetOrigin); const sourceOrigin = canonicalOrigin(request?.declaredOrigin); const boundary = Object.freeze({ kind: 'in-memory-csrf-cors-contract-v1', browser: 'not-run', fetch: 'not-called', network: 'not-opened', cookies: 'not-read-or-sent', preflight: 'not-created-or-sent', server: 'not-contacted', }); if (!targetOrigin || !sourceOrigin) { return Object.freeze({ boundary, accepted: false, reason: 'invalid-declared-origin', cors: Object.freeze({ decision: 'not-evaluated', reason: 'invalid-declared-origin' }), csrf: Object.freeze({ decision: 'not-evaluated', reason: 'invalid-declared-origin' }), shape: Object.freeze({ kind: 'declared-cors-shape-v1', status: 'not-evaluated', reasons: Object.freeze([]) }), }); } const shape = classifyDeclaredCorsShape(request); const cors = decideCorsContract(policy, request, sourceOrigin, targetOrigin); const csrf = decideCsrfContract(policy, request, sourceOrigin); return Object.freeze({ boundary, accepted: true, originRelation: sourceOrigin === targetOrigin ? 'same-origin' : 'cross-origin', sourceOrigin, targetOrigin, shape, cors, csrf, mutationDecision: csrf.decision === 'accept-origin-and-token' || csrf.decision === 'not-required-by-this-contract' ? 'accepted-by-declared-server-contract' : 'rejected-by-declared-server-contract', }); } export function runCsrfCorsFixture() { const policy = Object.freeze({ targetOrigin: 'https://api.example.test', corsAllowedOrigins: Object.freeze(['https://app.example.test']), allowCredentials: true, csrfTrustedOrigins: Object.freeze(['https://api.example.test', 'https://app.example.test']), csrfToken: 'fixture-token-2023', }); const credentialedPatch = Object.freeze({ declaredOrigin: 'https://app.example.test', method: 'PATCH', contentType: 'application/json', headerNames: Object.freeze(['content-type', 'x-csrf-token']), credentialsMode: 'include', authentication: 'session-cookie', csrfToken: 'fixture-token-2023', }); const trustedCredentialed = evaluateInMemoryCsrfCorsContract(policy, credentialedPatch); const wildcardWithCredentials = evaluateInMemoryCsrfCorsContract( { ...policy, corsAllowedOrigins: ['*'] }, credentialedPatch, ); const formWithoutToken = evaluateInMemoryCsrfCorsContract(policy, { declaredOrigin: 'https://app.example.test', method: 'POST', contentType: 'application/x-www-form-urlencoded', headerNames: [], credentialsMode: 'include', authentication: 'session-cookie', csrfToken: '', }); const sameOriginWithoutToken = evaluateInMemoryCsrfCorsContract(policy, { declaredOrigin: 'https://api.example.test', method: 'POST', contentType: 'application/json', headerNames: ['content-type'], credentialsMode: 'same-origin', authentication: 'session-cookie', csrfToken: '', }); const foreignOriginWithToken = evaluateInMemoryCsrfCorsContract(policy, { ...credentialedPatch, declaredOrigin: 'https://evil.example.test', }); const differentPort = evaluateInMemoryCsrfCorsContract(policy, { ...credentialedPatch, declaredOrigin: 'https://app.example.test:8443', }); const invalidOrigin = evaluateInMemoryCsrfCorsContract(policy, { ...credentialedPatch, declaredOrigin: 'not an origin', }); const originWithPath = evaluateInMemoryCsrfCorsContract(policy, { ...credentialedPatch, declaredOrigin: 'https://app.example.test/profile', }); return Object.freeze({ assertions: Object.freeze({ exactOriginWithCredentialsAccepted: trustedCredentialed.cors.decision === 'allow-exact-origin-and-credentials', tokenAndTrustedOriginAcceptMutation: trustedCredentialed.csrf.decision === 'accept-origin-and-token' && trustedCredentialed.mutationDecision === 'accepted-by-declared-server-contract', wildcardWithCredentialsRejected: wildcardWithCredentials.cors.decision === 'reject-wildcard-with-credentials', customHeaderOnlyClassifiedAsDeclaredShape: trustedCredentialed.shape.status === 'non-safelisted-shape' && trustedCredentialed.shape.reasons.includes('header-not-safelisted:x-csrf-token'), formShapeStillNeedsCsrfToken: formWithoutToken.shape.status === 'safelisted-shape' && formWithoutToken.csrf.decision === 'deny-token', sameOriginDoesNotTurnOffCsrf: sameOriginWithoutToken.cors.decision === 'not-applicable-same-origin' && sameOriginWithoutToken.csrf.decision === 'deny-token', untrustedOriginRejectedByCsrf: foreignOriginWithToken.csrf.decision === 'deny-origin', portMakesDifferentOrigin: differentPort.originRelation === 'cross-origin' && differentPort.csrf.decision === 'deny-origin', corsAndCsrfAreSeparateFields: trustedCredentialed.cors.decision !== trustedCredentialed.csrf.decision && Object.hasOwn(trustedCredentialed, 'cors') && Object.hasOwn(trustedCredentialed, 'csrf'), invalidOriginIsRejectedBeforeDecisions: invalidOrigin.accepted === false && invalidOrigin.reason === 'invalid-declared-origin', declaredOriginCannotContainPath: originWithPath.accepted === false && originWithPath.reason === 'invalid-declared-origin', fixtureClaimsNoBrowserOrNetwork: trustedCredentialed.boundary.fetch === 'not-called' && trustedCredentialed.boundary.network === 'not-opened' && trustedCredentialed.boundary.preflight === 'not-created-or-sent', }), samples: Object.freeze({ trustedCredentialed, wildcardWithCredentials, formWithoutToken, sameOriginWithoutToken, foreignOriginWithToken, differentPort, invalidOrigin, originWithPath }), }); } const practiceExample = `const policy = { targetOrigin: 'https://api.example.test', corsAllowedOrigins: ['https://app.example.test'], allowCredentials: true, csrfTrustedOrigins: ['https://api.example.test', 'https://app.example.test'], csrfToken: 'fixture-token-2023', }; const result = evaluateInMemoryCsrfCorsContract(policy, { declaredOrigin: 'https://app.example.test', method: 'PATCH', contentType: 'application/json', headerNames: ['content-type', 'x-csrf-token'], credentialsMode: 'include', authentication: 'session-cookie', csrfToken: 'fixture-token-2023', }); console.log(result.cors.decision); // allow-exact-origin-and-credentials console.log(result.csrf.decision); // accept-origin-and-token console.log(result.boundary.network); // not-opened`; const mechanismExample = `const formWithoutToken = evaluateInMemoryCsrfCorsContract(policy, { declaredOrigin: 'https://app.example.test', method: 'POST', contentType: 'application/x-www-form-urlencoded', headerNames: [], credentialsMode: 'include', authentication: 'session-cookie', csrfToken: '', }); console.log(formWithoutToken.shape.status); // safelisted-shape console.log(formWithoutToken.csrf.decision); // deny-token // Это contract labels, а не отправленный form или browser trace.`; const fieldExample = `node web/scripts/upgrade-2023-03.mjs --verify-fixture # PASS означает только следующее: # входные labels прошли 12 assertions in-memory contract. # Команда не открывала сеть, не вызывала Fetch, не посылала cookie, # не создавала OPTIONS и не проверяла реальный API или браузер.`; const practice = revision({ slug: 'editorial-2023-03-practice-csrf-cors', title: 'Cookie API и виджет: как не перепутать CORS с CSRF', categories: ['Безопасность', 'HTTP'], cover: '/assets/editorial/2023/csrf-cors-2023-practice-contract.svg', excerpt: 'Практический контракт для cookie API: CORS ограничивает доступ к ответу, а CSRF-проверка на сервере решает, принимать ли изменение состояния.', readingMinutes: 12, }, [ p(`Ошибка обычно начинается с рабочего виджета на https://app.example.test и cookie API на https://api.example.test. После релиза браузер показывает CORS error, а в конфигурации появляется соблазн поставить Access-Control-Allow-Origin: * и выключить проверку токена. Цена двойная: credentialed response всё равно останется недоступным по CORS, а серверная граница mutation может ослабнуть. Сначала надо разделить два решения, а не подбирать один заголовок.`), p(`В этой статье есть один учебный контракт для обсуждения конфигурации. Он принимает заданные строки origin, method, headers и token, возвращает отдельные поля cors и csrf и проверяется в Node. Контракт намеренно не запускает браузер: он не вызывает fetch, не поднимает два host, не отправляет cookie и не делает OPTIONS. Поэтому PASS полезен как регрессия смысла в коде статьи, но не как воспроизведение сетевого обмена.`), h2('Один запрос, три владельца решения'), p(`Origin — это не название продукта и не часть пути URL. Для веб-модели это tuple scheme, host и port. Поэтому https://app.example.test и https://api.example.test уже находятся по разные стороны origin, хотя оба host могут принадлежать одной команде. Путь /profile или общий registrable domain этого не меняют. Это первый факт, который надо записать в тикет до разговора о cookie и заголовках.`), p(`Дальше владельцы расходятся. Браузер сверяет CORS response и решает, откроет ли JavaScript доступ к ответу cross-origin запроса. Сервер CORS-политикой описывает, какой чужой origin может получить это представление. Отдельно обработчик state-changing endpoint проверяет аутентификацию, права и CSRF-доказательство. Cookie отвечает только на вопрос, какую сессию браузер может попытаться приложить; она не заменяет ни permission, ни доказательство намерения.`), table('Минимальный контракт для cookie API', ['Вопрос', 'Кто принимает решение', 'Наблюдаемый критерий', 'Чего критерий не доказывает'], [ ['Что считается другим origin?', 'браузерная модель URI', 'scheme + host + port не совпали', 'доверие между сервисами или право пользователя'], ['Может ли JS прочитать response?', 'браузер после CORS response', 'Access-Control-Allow-Origin соответствует origin', 'что mutation разрешён сервером'], ['Можно ли использовать credentials?', 'клиентская декларация и CORS response', 'exact origin плюс Access-Control-Allow-Credentials: true', 'что cookie реально будет приложена во всех браузерах'], ['Нужно ли сначала проверить shape?', 'браузерный CORS алгоритм', 'method, content type или custom header не safelisted', 'что actual request безопасен или уже отправлен'], ['Можно ли изменить данные?', 'серверный обработчик', 'origin policy и CSRF token прошли проверку', 'что у пользователя есть нужное бизнес-право'], ]), h2('Сначала зафиксируйте контракт, затем правьте proxy'), p(`Для credentialed API allow-list должен состоять из точных origin. Если политика отражает пришедший Origin, сначала она должна сравнить нормализованное значение с собственным списком; отражать любую строку нельзя. Пара Access-Control-Allow-Origin: * и credentials не является совместимым договором: историческая рекомендация CORS прямо запрещает wildcard для ресурса, который поддерживает credentials. Значит, такой ответ не лечит легитимный виджет и не должен быть обходом 403.`), p(`У request со значением credentials: 'include' есть две границы. На стороне клиентского кода это намерение участвовать в credentialed обмене. На стороне сервера нужны exact origin и Access-Control-Allow-Credentials: true, если ответ должен стать доступным браузерному коду. При этом cookie policy конкретного браузера, атрибуты cookie и пользовательские настройки могут не дать cookie пройти. Не превращайте успешный заголовок CORS в утверждение, что сервер увидел сессию.`), h2('Учебный fixture: договор, а не CORS repro'), p(`Ниже объект описывает ожидаемую конфигурацию: API доверяет одному origin для CORS и двум origin для CSRF, потому что same-origin форма API тоже должна пройти серверную проверку. Строка fixture-token-2023 не является секретом, токеном реальной сессии или примером генерации. Она нужна только для отрицательной ветки deny-token. В функции нет HTTP-клиента, cookie jar, времени, CORS cache и доступа к файловой системе.`), code(practiceExample), p(`Результат содержит два независимых ответа. Поле cors.decision говорит о том, совместима ли объявленная CORS-политика с заданным origin и credentials. Поле csrf.decision говорит о том, допустил бы учебный серверный договор mutation при заданных origin и token. Их нельзя склеить в один boolean. Например, same-origin request вообще не нуждается в CORS, но cookie-session POST без CSRF token остаётся deny-token. Это и есть нужная проверка против привычной ошибки «у нас same-origin, значит токен можно убрать».`), figure('/assets/editorial/2023/csrf-cors-2023-practice-contract.svg', 'Код страницы с origin app.example.test обращается к API на api.example.test. Внутри API CORS разрешает чтение response только exact origin с credentials, а CSRF отдельно требует origin и token до mutation.', 'CORS и CSRF показаны как два независимых решения сервера и браузера. Схема не изображает реальный браузерный запуск, передачу cookie, OPTIONS или лог конкретного API.'), h2('CSRF-контроль остаётся на сервере'), p(`CORS не запрещает серверу принять любой HTTP request; его основная роль — ограничить, какой browser code получит доступ к response при cross-origin API-вызове. В старой CORS Recommendation это разделение названо прямо: simple cross-origin requests могут иметь user credentials, а ресурс с действием, отличным от получения данных, должен защищаться от CSRF явно переданным непредсказуемым значением. Практический вывод уже не зависит от названия frontend framework: mutation на cookie session требует серверного условия отказа.`), p(`Для stateful приложения типичный базовый вариант — synchronizer token: сервер связывает значение с сессией, клиент возвращает его в form field или custom header, обработчик сравнивает значение до побочного эффекта. Origin или Referer check может быть дополнительной защитой, если команда описала нормальное отсутствие этих заголовков и режим отказа. Ни token, ни origin check не заменяют authorization: после CSRF-проверки пользователь всё ещё может не иметь права менять чужой ресурс.`), h2('Preflight полезен, но не становится CSRF-защитой'), p(`Custom header вроде X-CSRF-Token и JSON PATCH дают request shape, для которого browser CORS алгоритм может потребовать preflight. Это полезный сигнал: чужая страница не может просто добавить произвольный header без CORS-проверки. Но preflight отвечает на другой вопрос — допускает ли ресурс такой method и header для указанного origin. Он не подтверждает сессию, значение token, пользователя или смысл операции.`), p(`Обратный пример важнее. Обычный HTML form может отправить cross-origin POST с application/x-www-form-urlencoded без такого этапа. Если API принимает этот content type и меняет состояние только по cookie, защищаться надеждой на preflight нельзя. Поэтому fixture специально проверяет form-shaped POST: он получает статус safelisted-shape, но без token возвращает deny-token. Название статуса принадлежит учебному контракту; он не говорит, что браузер уже сделал или сделает в сети.`), h2('Маршрут: симптом → причина → проверка → действие'), ol([ 'Симптом. В консоли виден CORS error или endpoint вернул 403. Сохраните origin страницы, URL API, method, content type, имена request headers и server status до изменения конфигурации.', 'Причина. Сначала сравните scheme, host и port. Разные subdomain или port — это другой origin, даже когда продукт называет их одним сайтом.', 'Проверка CORS. Для credentialed API сверьте exact allow-list, Access-Control-Allow-Credentials: true и ответ на нужный method/header. Не заменяйте список на wildcard.', 'Проверка CSRF. На сервере отдельно назовите state-changing methods, источник token, момент сравнения и результат mismatch. Проверка должна стоять до mutation и не зависеть от того, прочитает ли JS response.', 'Действие. Исправьте только отсутствующее условие: allow-list для доверенного frontend origin, узкий preflight contract или выдачу и передачу token. Не отключайте middleware целиком ради одного route.', 'Регрессия. Выполните node web/scripts/upgrade-2023-03.mjs --verify-fixture. PASS подтверждает 12 assertions объекта в памяти; для настоящего браузера и API нужен отдельный интеграционный сценарий.', ]), h2('Границы утверждений и следующий шаг'), p(`Fixture не проверяет, как конкретный браузер применяет SameSite, third-party cookie policy, redirect, cache или version-specific CORS details. Он не создаёт реальную сессию, не генерирует криптографический token, не сравнивает token constant-time и не проверяет proxy. Входной origin также не является прочитанным HTTP header: это label, заданный test. Если в вашей системе origin может отсутствовать, быть null или проходить через несколько proxy, это отдельное правило сервера и отдельный набор интеграционных проверок.`), p(`Следующий безопасный шаг — выбрать один state-changing route с cookie authentication и записать четыре вещи рядом с его тестом: exact client origin, CORS response для этого origin, CSRF proof и ожидаемый 403 при mismatch. Затем отдельно проверить реальный browser flow в разрешённом окружении с его DevTools и server logs. Не называйте результат fixture доказательством этого прогона: разные артефакты отвечают на разные вопросы.`), h2('Историческая граница марта 2023'), p(`В статье используются RFC 6454 от декабря 2011 года, W3C CORS Recommendation от 16 января 2014 года и commit snapshot WHATWG Fetch Standard от 24 марта 2023 года. Все три источника доступны не позднее марта 2023. Они задают модель и протокол, но не дают готовой конфигурации для доменов, cookie policy или framework middleware конкретного проекта.`), ]); const mechanism = revision({ slug: 'editorial-2023-03-mechanism-csrf-cors', title: 'Origin, credentials и preflight: где заканчивается модель браузера', categories: ['Безопасность', 'HTTP'], cover: '/assets/editorial/2023/csrf-cors-2023-mechanism-layers.svg', excerpt: 'Разбор трёх слоёв cross-origin запроса: tuple origin, CORS-доступ к response с credentials и серверный CSRF-контроль mutation.', readingMinutes: 12, }, [ p(`Проблема появляется, когда в одном code review смешивают три фразы: «origin наш», «fetch идёт с credentials» и «preflight нас защищает». Тогда CORS header принимают за permission на изменение данных, а 403 от CSRF middleware — за повод расширить allow-list. Цена — потеря диагностики: по CORS error нельзя понять, дошёл ли запрос, был ли response скрыт от JavaScript или сервер отверг mutation по token.`), p(`Ниже — модель для конкретного endpoint. Она не обещает одинаковый network trace в Chrome, Safari и webview. Вместо этого мы разложим термины и проверим их на in-memory contract: request values заданы тестом, а browser, Fetch, cookie и OPTIONS отсутствуют. Реальную сетевую проверку нужно делать отдельным browser/integration test с двумя controllable origin и server evidence.`), h2('Origin: короткий tuple вместо слова «сайт»'), p(`RFC 6454 описывает origin через scheme, host и port. У https://app.example.test и https://app.example.test:8443 совпадает host, но port различается; это cross-origin пара. У http://app.example.test и HTTPS-версии различается scheme. URL path, query и fragment не входят в tuple. Эта механика кажется простой, но именно здесь ломаются allow-list, когда разработчик сравнивает только suffix домена или считает, что два приложения под одним registrable domain автоматически равноправны.`), p(`Origin не является моделью бизнес-доверия. Два сервиса могут быть на одном origin и иметь разные роли, а один доверенный frontend может жить на другом origin. Поэтому origin check полезно трактовать как одно условие в серверной политике, а не как готовый authorization model. В частности, origin https://admin.example.test не получает право вызывать user API только из-за общего слова example.test; он должен быть внесён в отдельный точный contract и пройти review как новый principal.`), table('Три слова, которые нельзя подменять друг другом', ['Термин', 'Точное значение в разборе', 'Проверка', 'Опасная подмена'], [ ['Origin', 'tuple scheme, host, port', 'сравнить canonical tuple source и target', '«это тот же домен»'], ['Credentials mode', 'намерение client API участвовать в credentialed request', 'посмотреть вызов и CORS response contract', '«у сервера точно есть сессия»'], ['CORS permission', 'браузерный доступ origin к response representation', 'exact Access-Control-Allow-Origin и условия credentials', '«endpoint авторизовал mutation»'], ['Preflight', 'CORS gate для заявленных method/header/content type', 'OPTIONS contract для узкого набора', '«сервер уже проверил CSRF»'], ['CSRF proof', 'серверное доказательство допустимого intent для cookie mutation', 'token/origin policy до side effect', '«cookie означает intent»'], ]), h2('Credentials: декларация клиента и ответ сервера'), p(`В Fetch credentials mode описывает, как request относится к credentials. Для cross-origin API этого недостаточно: server response должен согласовать доступ к representation. Когда нужен credentialed response, wildcard Access-Control-Allow-Origin: * не подходит; CORS Recommendation требует exact origin и Access-Control-Allow-Credentials: true. Это ограничение полезно читать буквально: заголовки отвечают за видимость response для кода другого origin, а не за безопасную схему аутентификации сами по себе.`), p(`Порядок настройки простой. Сначала решите, нужен ли browser client cookie-authenticated API. Если да, назовите доверенные origin и endpoints, которые они читают, затем включайте credentials только на этих response. Явный bearer или capability token с узким scope — другая architecture choice; он требует отдельного анализа хранения token, XSS и rollout.`), h2('Preflight: gate формы запроса, не доказательство intent'), p(`Preflight появляется не потому, что endpoint опасный, а потому что browser видит cross-origin request shape вне CORS safelist: например, PATCH, JSON content type или X-CSRF-Token. Он проверяет, готов ли ресурс обслужить такой method и такие header names для указанного origin. Успешный OPTIONS позволяет перейти к следующему этапу CORS algorithm, но не сообщает API, что token совпал с сессией или что пользователь может выполнить операцию.`), p(`Технический контрпример — обычная form submission. POST с application/x-www-form-urlencoded принадлежит safelisted shape, хотя может менять состояние. Поэтому сервер, который защищает POST только тем, что JSON endpoint обычно preflighted, оставляет другую поверхность. Надёжная формулировка для review: preflight может быть дополнительным барьером для custom-header flow, но CSRF control обязан покрыть state-changing cookie routes независимо от наличия OPTIONS. Отдельно проверьте, что GET и HEAD не делают side effect; иначе даже корректный token flow не спасает дизайн метода.`), figure('/assets/editorial/2023/csrf-cors-2023-mechanism-layers.svg', 'Три слоя cross-origin запроса: origin как tuple scheme host port; CORS как условие доступа к response, включая credentials и preflight; CSRF как server-side проверка origin и token перед state-changing request.', 'Диаграмма задаёт порядок вопросов для ревью. Она не является трассой Fetch, не показывает cookie delivery и не утверждает, что preflight происходит для каждого нарисованного запроса.'), h2('Проверьте разные решения на одном объекте'), p(`Учебный код не реализует Fetch Standard. Его классификатор намеренно уже спецификации: он отмечает method, content type и header names, которые мы задали в fixture, и пишет safelisted-shape или non-safelisted-shape. Он не разбирает все byte-level ограничения заголовков, не поддерживает CORS cache и не должен использоваться как production middleware. Такая узость важна: компактный test остаётся честным, пока не делает вид, что заменил браузер.`), code(mechanismExample), p(`В примере form-shaped POST получает safelisted-shape, однако CSRF-ветка всё равно возвращает deny-token. Это не прогноз HTTP traffic. Это проверяемое правило нашего договора: session-cookie mutation нельзя принять без совпадающего заданного token и trusted origin. Рядом fixture проверяет другой случай: port 8443 делает origin отличным, даже если host тот же. Такая отрицательная ветка полезнее happy path, потому что именно она защищает review от скрытого допущения о домене.`), h2('Где проходит ответственность сервера'), p(`CORS configuration обычно живёт в proxy, API gateway или framework middleware. CSRF rule может жить в application framework, route guard или отдельном endpoint policy. Эти места могут быть разными, но контракт должен быть единым: proxy не разрешает лишний origin ради обхода 403, а приложение не рассчитывает, что proxy остановит form POST. Если у одного endpoint особое исключение, назовите его вход, owner, expiration и test. Глобальное disableCors или disableCsrf превращает локальную проблему в новый неявный baseline.`), p(`Права пользователя остаются третьим серверным решением. Допустим, origin и token прошли, но пользователь пытается изменить entity другого account. CSRF не обязан и не может решить это правило: он защищает от поддельного запроса в контексте текущей сессии, а authorization сравнивает actor с объектом и действием. В ответах не смешивайте причины. 403 может означать token mismatch, untrusted origin или missing permission; для клиента можно дать безопасный общий ответ, а в server log оставить корреляционный reason без секретного token.`), h2('Маршрут: симптом → причина → проверка → действие'), ol([ 'Симптом. В PR появился credentials: \'include\', custom header или новый frontend host. Выпишите source origin и target origin целиком, включая scheme и port.', 'Причина. Определите, что не совпало: tuple origin, CORS allow-list, credentials response, preflight method/header либо server-side CSRF proof. Не называйте все причины одним CORS error.', 'Проверка tuple. Добавьте отрицательный пример с другим scheme, subdomain или port. Он должен не попасть в trusted allow-list без явного решения команды.', 'Проверка CORS. На credentialed route ожидайте exact origin и Access-Control-Allow-Credentials: true; wildcard означает неверный contract, а не широкую совместимость.', 'Проверка CSRF. Отдельно отправьте в настоящем test environment invalid token или foreign origin и убедитесь, что mutation не выполняется. Не делайте вывод из OPTIONS response.', 'Действие. Уменьшите allow-list до нужного origin, оставьте CSRF middleware включённым и добавьте test на отклонённую ветку. Если меняется authentication architecture, остановите этот PR и оформите отдельное решение.', ]), h2('Ограничения модели и следующий шаг'), p(`Статья не утверждает, что каждая cookie автоматически отправляется cross-origin: это зависит от атрибутов cookie, browser policy, режима приватности и контекста. Она также не утверждает, что Origin header всегда доступен в каждом конкретном запросе или что один token format подходит для любого framework. Для synchronizer token нужны выдача, хранение, rotation, comparison и обработка back navigation; для double-submit pattern — отдельная защита от cookie injection. Эти варианты надо сверять с документацией используемого framework и threat model конкретного приложения.`), p(`Следующий шаг — провести небольшой review одного endpoint: нарисовать source и target origin, назвать credentialed response contract, перечислить form-shaped и custom-header paths, затем зафиксировать server-side proof до mutation. После этого fixture можно оставить как охрану терминов, а реальный browser test — как evidence интеграции. Если один из двух артефактов отсутствует, не заменяйте его вторым: in-memory PASS не заменяет DevTools и logs, а успешный ручной запрос не документирует правило на следующий refactor.`), h2('Историческая граница марта 2023'), p(`RFC 6454, W3C CORS Recommendation и WHATWG Fetch snapshot по ссылкам были опубликованы или зафиксированы до конца марта 2023. W3C Recommendation здесь используется как датированный нормативный источник CORS-терминов; Fetch snapshot фиксирует доступную на тот момент редакцию living standard. Никакой из документов не описывает ваши cookie attributes, reverse proxy или route permissions без дополнительной конфигурации и теста.`), ]); const field = revision({ slug: 'editorial-2023-03-field-csrf-cors', title: 'CORS error у cookie API: диагностика без отключения CSRF', categories: ['Безопасность', 'HTTP'], cover: '/assets/editorial/2023/csrf-cors-2023-field-diagnosis.svg', excerpt: 'Полевой маршрут для CORS error, preflight и CSRF 403: собрать факты, разделить границы, исправить один контракт и сохранить отрицательный тест.', readingMinutes: 12, }, [ p(`После выката frontend на новый host интерфейс не получает данные или mutation заканчивается ошибкой. Самое рискованное действие — сделать CORS глобально permissive или выключить CSRF «для проверки». Такой change может пережить инцидент и расширить доступ для origin без review. Диагностика начинается с наблюдаемого request contract, а не с флага middleware.`), p(`Маршрут разделяет CORS error, OPTIONS failure и CSRF rejection. Локальный fixture не изображает сеть: его inputs — заданные labels, выход — решения учебного контракта. Он не сообщает response proxy, cookie delivery или access log. Для этого нужен реальный browser evidence в контролируемой среде, без переноса production cookie и secrets в заметку.`), h2('Соберите факты до первого исправления'), p(`Начните с пяти значений: полный origin страницы, URL target, method, content type и имена request headers. Потом добавьте status и response headers, которые видны в browser DevTools или на boundary proxy, а также application reason, если он не раскрывает token. Разница между https://app.example.test и https://app.example.test:8443 существенна; разница между POST form и PATCH JSON тоже существенна. Лог «CORS failed» без этих полей — не доказательство причины.`), p(`Не переиспользуйте пользовательскую production cookie в диагностике. Для реального browser check создайте разрешённую test session, заранее определите тестовую запись и ожидаемый безопасный side effect, а затем удалите или изолируйте данные согласно правилам среды. Если такой стенд пока не готов, зафиксируйте contract review и in-memory fixture как подготовку, но не пишите, что CORS или CSRF уже проверены. Отсутствие evidence — полезный результат: он показывает, какой артефакт нужен до релиза.`), table('Симптом → причина → проверка → действие', ['Симптом', 'Вероятная причина', 'Проверка', 'Первое действие'], [ ['JS не читает response', 'origin отсутствует в CORS allow-list', 'сверить full tuple с exact response header', 'добавить только нужный origin'], ['Credentials response заблокирован', 'wildcard или нет Allow-Credentials: true', 'смотреть пару response headers вместе', 'вернуть exact origin, не расширять wildcard'], ['OPTIONS не проходит', 'method или custom header не разрешён', 'сверить declared method/header с preflight response', 'разрешить узкий нужный набор'], ['POST form получил 403', 'CSRF token отсутствует или не совпал', 'проверить server reason до mutation', 'починить выдачу/передачу token'], ['Mutation прошёл, UI увидел error', 'CORS visibility и server action различаются', 'сверить server evidence и response policy', 'исправить CORS без снятия CSRF'], ['403 после token check', 'у пользователя нет business permission', 'разделить reason CSRF и authorization', 'чинить policy ресурса, не token'], ]), h2('Не выводите server action из console message'), p(`CORS error сообщает о том, что browser code не получил ожидаемый cross-origin response по правилам модели. Он не является универсальным сообщением «сервер не видел запрос». Для некоторых request shape сервер мог уже принять HTTP traffic, а JavaScript всё ещё не сможет прочитать representation. Для других, требующих preflight, actual request может не начаться после отказа OPTIONS. Эти варианты нельзя различить по одной строке консоли, поэтому в проверке должны быть и browser evidence, и proxy/application log с безопасным корреляционным идентификатором.`), p(`Не делайте из этого аргумент «CORS бесполезен». Он ограничивает, какие foreign origin получают доступ к данным через browser API, и этим уменьшает поверхность чтения. Просто у него другая ответственность, чем у CSRF. Для cookie-backed mutation сервер должен решить, почему request разрешён: token совпал с привязанной к сессии записью, origin прошёл узкую policy, а пользователь имеет нужное право. CORS response header не содержит это доказательство и не должен заменять его в code review.`), figure('/assets/editorial/2023/csrf-cors-2023-field-diagnosis.svg', 'Маршрут диагностики начинает с симптома и сбора origin, method, headers и server status. Затем он разделяет untrusted origin, credentials response, preflight failure и CSRF 403, у каждого — отдельная проверка и действие.', 'Схема — чек-лист для разбора evidence. Она не является реальным trace, не утверждает, что browser отправил OPTIONS, и не показывает настоящие cookie или данные пользователя.'), h2('Разберите credentials и preflight по отдельности'), p(`Если endpoint действительно нужен cookie-authenticated frontend на другом origin, проверить надо две пары значений. Первая — client credentials mode и server Access-Control-Allow-Credentials: true. Вторая — exact source origin и Access-Control-Allow-Origin. Для credentials wildcard запрещён исторической CORS Recommendation. Нельзя исправить одну половину и считать contract готовым: response может всё ещё быть скрыт от JavaScript, а добавление произвольного origin создаст новый trusted reader без явной причины.`), p(`Для preflight соберите actual method и header names из client code, затем сверяйте их с узким OPTIONS contract. Например, X-CSRF-Token может требовать разрешения как custom header, а PATCH — как method. Не отвечайте «все методы, все headers» ради скорости: это затрудняет review и превращает следующую ошибку клиента в неявно допустимый API path. Когда endpoint поддерживает form POST, обязательно выполните отдельный CSRF test, потому что form-shaped request не обязан проходить через тот же preflight путь.`), h2('Проверьте терминологию локально, не подменяя стенд'), p(`В скрипте рядом со статьёй fixture держит несколько отрицательных веток: credentialed wildcard отклоняется договором; port 8443 создаёт другой origin; same-origin POST без token всё равно получает deny-token; form-shaped POST тоже не проходит серверное CSRF-условие. Эти assertions полезны перед рефакторингом middleware, потому что запрещают склеить CORS и CSRF в одну переменную. Но они не запускают gateway, framework, browser или HTTP server.`), code(fieldExample), p(`После PASS не пишите «сетевой repro завершён». Верная запись короче: «in-memory contract подтвердил 12 утверждений о заданных labels; реальный browser/API evidence требуется отдельно». Такой текст не выглядит слабее; он показывает, где заканчиваются данные. В качестве следующего artifact приложите к PR скрин или HAR из test environment, server status, выбранный exact origin и отрицательный case без token. Секреты, сами token values и production account identifiers в evidence не нужны.`), h2('CSRF-проверка должна пережить CORS-исправление'), p(`Для token-based flow найдите место, где сервер выдаёт token, место передачи и точку сравнения до side effect. Если frontend получает token из HTML, проверьте, что route, который рендерит страницу, не кэширует чужую сессию. Если token передаётся custom header, проверьте его имя в narrow preflight allow-list. Если framework уже даёт CSRF middleware, сначала изучите его contract и тесты вместо замены собственной краткой функцией. Самодельная проверка чаще всего забывает rotation, logout, error handling или исключения route.`), p(`Origin check удобен как дополнительная защита, но его политика должна быть строгой. Не сравнивайте endsWith('example.test'), не делайте null trusted по умолчанию и не принимайте отсутствующий header молча, если это не описанная compatibility ветка. Если API принимает webhooks, native clients или service-to-service traffic, не пытайтесь заставить их выглядеть как browser CSRF flow. У каждого типа caller должен быть свой явный authentication contract, а исключение из CSRF middleware — ограниченный route, а не отключение на всём приложении.`), h2('Маршрут: симптом → причина → проверка → действие'), ol([ 'Симптом. Зафиксируйте одну failing операцию и её цену: UI не читает данные, mutation отклонён или action мог выполниться без видимого response. Не объединяйте несколько endpoints в один кейс.', 'Причина. Отделите origin tuple, CORS response, request shape/preflight, CSRF proof и business permission. У каждого пункта должен быть свой owner и log source.', 'Проверка evidence. В test environment сопоставьте DevTools или HAR с proxy/application status. Если actual request неизвестен, запишите это неизвестное, а не вывод из console text.', 'Проверка контракта. Запустите fixture и прочитайте отрицательные assertions. Он защищает только смысл статейного договора, не сеть и не session implementation.', 'Действие. Добавьте exact origin, один method/header в OPTIONS response либо недостающую передачу token. После change повторите только исходную операцию и её отрицательную ветку.', 'Закрепление. Оставьте автоматический route test на missing/mismatch token и reviewable CORS config. Для нового frontend origin потребуйте отдельный security review, а не копирование существующей строки.', ]), h2('Ограничения и критерий готовности'), p(`Этот маршрут не заменяет penetration test, cookie audit, CSP review, XSS defense или authorization test. XSS особенно меняет картину: script на доверенном origin может использовать доступные ему token и API, поэтому CORS и CSRF не являются защитой от выполнения чужого JavaScript в собственном origin. Статья также не назначает TTL token, набор SameSite attributes, format error response или универсальный список trusted origins. Эти параметры зависят от framework, browser support, session model и threat model.`), p(`Критерий готовности для одного endpoint узкий и проверяемый: reviewer видит точный source origin, разрешённый credentialed CORS response, нужный preflight contract при наличии custom header, server-side rejection без CSRF proof и отдельную permission check. Если есть только CORS header, работа не готова. Если есть только token test, frontend может не прочитать expected response. Если есть только fixture PASS, нет evidence реальной интеграции. Соберите все три слоя, но не объявляйте один из них заменой другого.`), h2('Историческая граница марта 2023'), p(`Нормативные ссылки этой партии датированы до конца марта 2023: RFC 6454 опубликован в 2011 году, W3C CORS Recommendation — в 2014 году, а WHATWG Fetch snapshot — 24 марта 2023 года. Они достаточны, чтобы проверить модель origin, CORS credentials и preflight. Конфигурацию конкретного proxy, browser quirks и middleware version всё равно нужно сверять в документации используемой платформы и в test environment.`), ]); export const revisions = [practice, mechanism, field].map(({ proseLength, ...revision }) => revision); function verifyFixture() { const report = runCsrfCorsFixture(); const failed = Object.entries(report.assertions) .filter(([, value]) => value !== true) .map(([key]) => key); if (failed.length > 0) { process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n'); process.exitCode = 1; return; } const total = Object.keys(report.assertions).length; process.stdout.write('PASS fixture: ' + total + '/' + total + ' assertions; in-memory contract only\n'); } if (process.argv.includes('--verify-fixture')) verifyFixture(); if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');