Перейти к основному содержанию
Блог

Веб

Неправильная настройка CORS: отражение Origin, wildcard и null

CORS — не защита, а разрешение для браузера. При ошибке чужой сайт читает данные из вашего API от имени пользователя. Проверка через curl и правильная настройка.

· 9 мин. чтения

Фото: Nenea hartia, CC BY-SA 4.0 · Wikimedia Commons

О CORS (Cross-Origin Resource Sharing) разработчики чаще всего вспоминают в одной ситуации: фронтенд обращается к API, и в консоли браузера появляется красная ошибка. В качестве быстрого решения ставится найденная в интернете настройка «разрешить всё», и ошибка исчезает. Эта настройка уходит в production, и к ней больше никто не возвращается.

В этой статье покажем, что на самом деле делает CORS, какие настройки опасны, как проверить их через curl и как правильно настроить в Laravel, Node и nginx.

Что такое CORS и чем он не является

В браузере действует базовое правило — Same-Origin Policy: JavaScript на странице https://evil.example может отправить запрос на https://api.your-domain.uz, но не может прочитать ответ. Origin — это сочетание схемы, домена и порта: https://app.your-domain.uz и https://api.your-domain.uz — разные origin.

CORS — механизм осознанного исключения из этого правила. Добавляя в ответ заголовки, сервер говорит браузеру: «разреши этому origin прочитать ответ»:

Access-Control-Allow-Origin: https://app.your-domain.uz
Access-Control-Allow-Credentials: true

Два важных вывода:

  • CORS — не слой защиты, а слой разрешений. Он ослабляет ограничение, а не усиливает его. Каждое неверное значение — ещё одна открытая дверь.
  • CORS работает только в браузере. curl, сервер или мобильное приложение CORS игнорируют. Поэтому CORS не заменяет авторизацию: API должен сам проверять пользователя в каждом запросе.

Почему это важно для экспертизы

Опасность ошибки CORS в том, что браузер пользователя становится инструментом атаки. Пользователь, будучи авторизованным в вашей системе, открывает чужую страницу. Если API работает с cookie, а CORS доверяет любому origin, скрипт на этой странице отправляет запрос от имени пользователя, читает ответ — профиль, отчёты, токены — и пересылает на свой сервер.

На экспертизе это оценивается как «Security Misconfiguration» или раскрытие информации. Найти такую ошибку просто: хватит нескольких запросов curl. Поэтому этот пункт входит в число настроек, которые обязательно нужно проверить до экспертизы.

Опасный случай 1: отражение Origin + credentials

Самая серьёзная ошибка. Сервер берёт заголовок Origin из запроса, без изменений возвращает его в ответе и разрешает cookie:

Access-Control-Allow-Origin: https://evil.example
Access-Control-Allow-Credentials: true

Фактически это означает «любой сайт может читать API от имени пользователя». Часто это результат такого кода:

// ОШИБКА
app.use(cors({ origin: true, credentials: true }));

origin: true в пакете cors означает «отражать пришедший Origin».

Опасный случай 2: wildcard

Access-Control-Allow-Origin: * — «читать ответ может любой origin». Браузеры не принимают * вместе с Allow-Credentials: true, поэтому данные, защищённые cookie, напрямую не прочитать. Но * всё равно может быть опасен:

  • если API без аутентификации отдаёт ресурсы из внутренней сети или ограниченные по IP;
  • если доступ даётся не через cookie, а другим «фоновым» способом (например, разрешением по IP);
  • если разработчик позже, раз * «не работает», заменит его на отражение Origin — а это ведёт к случаю 1.

Для публичных, действительно открытых данных (например, курсов валют) * допустим. Для API личного кабинета — нет.

Опасный случай 3: origin null

В некоторых настройках в списке разрешённых стоит null — обычно его добавляют для тестирования локальных файлов. Но Origin: null может отправить и атакующий: с этим значением уходят запросы из sandbox-iframe, data: URL или страниц file://. Никогда не указывайте null как разрешённый origin.

Опасный случай 4: неверная проверка (regex и endsWith)

Если в коде проверки разрешённых origin есть ошибка, список обходится, даже если он существует:

// ОШИБКА: проходят и evilyour-domain.uz, и your-domain.uz.evil.example
if (origin.endsWith('your-domain.uz') || origin.includes('your-domain.uz')) { ... }

// ОШИБКА: точка не экранирована, нет якорей
/https:\/\/.*.your-domain.uz/

Правильный способ — точное сравнение по списку или строго заякоренный regex:

/^https:\/\/([a-z0-9-]+\.)?your-domain\.uz$/

И ещё: не добавляйте в production-список origin с http:// (без шифрования) — атакующий в сети может внедрять контент от их имени.

Как проверить через curl

Отправьте запрос с чужим origin и посмотрите заголовки Access-Control-* в ответе:

curl -si https://api.your-domain.uz/v1/me \
  -H "Origin: https://evil.example" | grep -i access-control

curl -si https://api.your-domain.uz/v1/me \
  -H "Origin: null" | grep -i access-control

curl -si https://api.your-domain.uz/v1/me \
  -H "Origin: https://your-domain.uz.evil.example" | grep -i access-control

Проверьте и preflight-запрос (OPTIONS) — именно его браузер отправляет перед PUT, DELETE или запросами с особыми заголовками:

curl -si -X OPTIONS https://api.your-domain.uz/v1/profile \
  -H "Origin: https://evil.example" \
  -H "Access-Control-Request-Method: DELETE" \
  -H "Access-Control-Request-Headers: authorization" | grep -i access-control

Как читать результат:

  • Чужой origin вернулся в ответе и есть Allow-Credentials: true — серьёзная находка.
  • Вернулся Allow-Origin: null — находка.
  • Вернулся * — оценивайте по тому, какие данные отдаёт эндпоинт.
  • Для чужого origin Access-Control-Allow-Origin нет вовсе — правильно.

Проверьте все основные эндпоинты, включая вход, профиль, скачивание файлов и админский API. Иногда CORS прописан не в общем middleware, а вручную в отдельном контроллере.

Правильная настройка в Laravel

Laravel управляет CORS через встроенный middleware HandleCors и config/cors.php (в Laravel 11+ файл можно опубликовать командой php artisan config:publish cors):

return [
    'paths' => ['api/*', 'sanctum/csrf-cookie'],
    'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
    'allowed_origins' => [
        'https://app.your-domain.uz',
        'https://admin.your-domain.uz',
    ],
    'allowed_origins_patterns' => [],
    'allowed_headers' => ['Content-Type', 'Authorization', 'X-Requested-With', 'X-XSRF-TOKEN'],
    'exposed_headers' => [],
    'max_age' => 600,
    'supports_credentials' => true,
];

Обратите внимание:

  • '*' в allowed_origins и supports_credentials => true не должны стоять вместе.
  • Удобно читать origin из .env в зависимости от окружения, но production-значение должно быть точным списком доменов.
  • Если используете allowed_origins_patterns, regex должен быть заякорен ^ и $, а точки — экранированы.

Правильная настройка в Node (Express)

const allowed = new Set([
  'https://app.your-domain.uz',
  'https://admin.your-domain.uz',
]);

app.use(cors({
  origin(origin, cb) {
    // Нет Origin — запрос не из браузера (curl, сервер); CORS-заголовок не нужен
    if (!origin) return cb(null, false);
    cb(null, allowed.has(origin));
  },
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
  maxAge: 600,
}));

При динамическом origin пакет cors добавляет заголовок Vary: Origin. Это важно: иначе прокси или CDN может закэшировать ответ для одного origin и отдать его другому.

На уровне nginx

Если CORS-заголовки добавляет nginx, не возвращайте $http_origin напрямую — сначала проверьте его через map:

map $http_origin $cors_origin {
    default "";
    "https://app.your-domain.uz"   $http_origin;
    "https://admin.your-domain.uz" $http_origin;
}

server {
    location /api/ {
        add_header Access-Control-Allow-Origin $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Vary Origin always;
        # ...
    }
}

nginx не отправляет add_header с пустым значением, поэтому origin не из списка заголовок не получит. Настраивайте в одном месте: если CORS добавляют и приложение, и nginx, появляются дублирующиеся заголовки, и браузер отклоняет ответ.

Связанные темы

В API, работающих с cookie, CORS тесно связан с CSRF: CORS управляет чтением ответа, но не останавливает отправку запроса. Поэтому запросы, меняющие состояние, нужно отдельно защищать CSRF-токеном или cookie с SameSite. О том, где хранить токен, мы писали в статье про JWT, а общий список по API — в статье про OWASP API Top 10.

Список CORS — это решение «кому мы доверяем». Он должен быть коротким, точным, и каждая строка в нём — обоснованной.

Чек-лист

  • Разрешённые origin — точный список; Origin не отражается.
  • * и Allow-Credentials: true не используются вместе; в личном API нет *.
  • В списке нет null и origin с http://.
  • Regex-проверки заякорены, точки экранированы; endsWith/includes не используются.
  • При динамическом origin отправляется Vary: Origin.
  • Разрешённые методы и заголовки минимальны.
  • CORS настроен в одном месте (приложение или nginx).
  • API проверяет авторизацию в каждом запросе независимо от CORS.
  • Проведена проверка curl с чужим, null и похожим доменом.

Итог

Ошибка CORS живёт не в коде, а в одной строке конфигурации, и часто её поставили «временно». Найти и исправить её — дело нескольких минут, просто сделать это нужно до экспертизы.

Хотите проверить, каким origin доверяет ваш API? Оставьте заявку на сканирование — Claude AI агент просмотрит настройки и код, а наш специалист подтвердит находки и поможет их закрыть.