Ошибки настройки CORS (CWE-942): как отражение Origin раскрывает данные и как это исправить
API, которое на любой Origin отвечает Access-Control-Allow-Origin, равным этому источнику, и Access-Control-Allow-Credentials: true, позволяет любому сайту читать его ответы с cookie посетителя. Как проверки по суффиксу, источник null и отражённые источники ломают политику одного источника и какой список разрешённых с точным совпадением это исправляет, на примере Express и cors.
Простыми словами (ELI5)
Кассир банка зачитывает ваш баланс любому, кого вы пришлёте, если посыльный покажет письмо с именем того, на кого он работает, и это имя одобрено в списке кассира. Правило одного кассира: «одобрять любое имя, которое заканчивается на слово Иванов». Незнакомец присылает посыльного с письмом от «Петров-Иванов», кассир его одобряет, и ваш баланс зачитывают человеку, которого вы никогда не посылали. Отражение Origin — ленивый вариант, когда кассир одобряет любое имя. Исправление — короткий точный список имён, сверяемых буква в букву.
Ключевые понятия и термины
- Политика одного источника
- Браузеры позволяют скрипту читать ответы только своего источника (схема, хост и порт). Заголовки ответа CORS — способ, которым сервер намеренно ослабляет это правило для названных источников.
- Access-Control-Allow-Origin с учётными данными
- Если ответ называет источник вызывающего в
Access-Control-Allow-Originи отправляетAccess-Control-Allow-Credentials: true, скрипт этого источника может читать ответы на запросы, отправленные с cookie пользователя. Браузеры отвергают*вместе с учётными данными, поэтому неверно настроенные серверы возвращают источник эхом. - Отражение Origin и проверка по суффиксу
- Копирование заголовка
Originв ответ доверяет всем сайтам. Проверки вродеendsWith('example.com'), поиск подстроки или регулярные выражения без якорей и с неэкранированными точками тоже принимаютhttps://attacker-example.com. - Источник null
- Изолированные iframe, URL
data:и некоторые перенаправления отправляютOrigin: null. Его может получить любой атакующий, поэтому разрешитьnull— всё равно что разрешить всех. - Vary: Origin
- Когда
Access-Control-Allow-Originзависит от запроса, ответ должен содержатьVary: Origin, чтобы общие кеши не отдавали CORS-заголовки одного источника другому. Пакетcorsдобавляет его для динамических источников.
Пошаговый сценарий атаки
Жертва вошла на сайт API
Её браузер хранит cookie сессии для api.example.com, который отправляется в межсайтовых запросах.
Она открывает страницу на домене атакующего
Скрипт вызывает fetch('https://api.example.com/api/account', { credentials: 'include' }). Страница на https://attacker-example.com, которая лишь выводит в консоль длину ответа, показывает этот случай.
API одобряет источник
Проверка по суффиксу принимает attacker-example.com, поэтому ответ несёт этот источник в Access-Control-Allow-Origin вместе с Access-Control-Allow-Credentials: true.
Скрипт читает личные данные
Браузер передаёт JSON аккаунта скрипту атакующего, который может отправить его куда угодно. В 2016 году Джеймс Кеттл нашёл такую схему у нескольких биткоин-бирж, где она раскрывала API-ключи и данные кошельков.
Исходный код: Уязвимый vs Защищённый вариант
// server.js: любой источник на example.com и null могут читать с cookie
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';
const app = express();
// true заставляет cors отражать Origin вызывающего вместе с учётными данными
app.use(cors({
origin: (origin, callback) => callback(null, !origin || origin === 'null' || origin.endsWith('example.com')),
credentials: true,
}));
// личные данные аккаунта доступны одобренным источникам
app.get('/api/account', requireSession, (req, res) => {
res.json(accountFor(req.session.userId));
});
// server.js: два точных источника и больше ничего
import express from 'express';
import cors from 'cors';
import { accountFor, requireSession } from './account.js';
const app = express();
// полные источники со схемой и хостом, сравнение один в один
const ALLOWED_ORIGINS = new Set(['https://app.example.com', 'https://admin.example.com']);
// неизвестный или отсутствующий Origin не получает CORS-заголовков; cors добавляет Vary: Origin
app.use(cors({
origin: (origin, callback) => callback(null, origin !== undefined && ALLOWED_ORIGINS.has(origin)),
credentials: true,
methods: ['GET', 'POST'],
maxAge: 600,
}));
// персональные данные никогда не попадают в общие кеши
app.get('/api/account', requireSession, (req, res) => {
res.set('Cache-Control', 'no-store');
res.json(accountFor(req.session.userId));
});
Чек-лист по защите системы для инженеров
- Ведите список разрешённых полных источников (схема, хост и порт) с точным совпадением и сверяйте через поиск в
Set; никогда не отражайтеOriginи не используйте проверки по суффиксу, подстроке или регулярные выражения без якорей. - Никогда не разрешайте источник
nullи не разрешайте учётные данные для источника, который вы не контролируете. - Отправляйте
Vary: Origin, когдаAccess-Control-Allow-Originвыбирается для каждого запроса, иCache-Control: no-storeв ответах с персональными данными. - Задайте cookie сессии
SameSite=LaxилиStrict, чтобы межсайтовые запросы их не несли. - Добавьте в CI тест, который отправляет
Origin: https://attacker.exampleиOrigin: nullна маршруты с аутентификацией и падает, если возвращаетсяAccess-Control-Allow-Origin.