● CWE-942 · OWASP A05:2021

Ошибки настройки 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 добавляет его для динамических источников.

Пошаговый сценарий атаки

Шаг 1

Жертва вошла на сайт API

Её браузер хранит cookie сессии для api.example.com, который отправляется в межсайтовых запросах.

Шаг 2

Она открывает страницу на домене атакующего

Скрипт вызывает fetch('https://api.example.com/api/account', { credentials: 'include' }). Страница на https://attacker-example.com, которая лишь выводит в консоль длину ответа, показывает этот случай.

Шаг 3

API одобряет источник

Проверка по суффиксу принимает attacker-example.com, поэтому ответ несёт этот источник в Access-Control-Allow-Origin вместе с Access-Control-Allow-Credentials: true.

Шаг 4

Скрипт читает личные данные

Браузер передаёт 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));
});

Чек-лист по защите системы для инженеров

Источники

← Весь каталог по безопасности Все руководства по уязвимостям →