Что такое CORS: как работает и как исправить ошибку CORS

Два сервера на столе соединены кабелем, между ними замок: CORS решает, кому можно читать ответ Программирование и разработка

CORS (Cross-Origin Resource Sharing, «совместное использование ресурсов между разными источниками») это механизм на HTTP-заголовках, который позволяет серверу указать браузеру, каким чужим сайтам можно читать его ответы. Если такого разрешения в ответе нет, браузер блокирует доступ вашего JavaScript к данным и пишет в консоль ошибку CORS. Исправляют её на стороне сервера, к которому идёт запрос, а не в браузере.

Примеры ниже проверены на Node.js 24.14, Express 5.2, Flask 3.1 и Vite 8.3. Тексты ошибок сняты на стенде: страница на http://localhost:3000, тестовый API на http://localhost:4000, браузер Edge 154. Сообщения формирует движок Chromium, общий с Chrome.

Источник и политика same-origin

Источник (origin) складывается из трёх частей: схемы (протокола), домена и порта. Путь в него не входит. Если отличается хоть одна часть, источники разные. Возьмём страницу https://example.com:

Адрес запроса Источник тот же? Почему
https://example.com/api/users да отличается только путь
https://example.com:8443/api/users нет другой порт
http://example.com/api/users нет другой протокол
https://api.example.com/users нет другой домен, хотя это поддомен

Политика одинакового источника (same-origin policy) запрещает скрипту читать ответы с чужого источника. Встраивать чужое при этом можно: картинки, скрипты, стили, iframe. Отправить форму на другой домен тоже можно. Под запрет попадает чтение, и CORS используется там, где страница получает чужие данные: в fetch() и XMLHttpRequest, в веб-шрифтах из @font-face, в текстурах WebGL и в картинках, которые рисуют на canvas через drawImage().

CORS ослабляет этот запрет точечно. Владелец API перечисляет источники, которым доверяет, и браузер открывает доступ к ресурсу только им.

Как работает CORS: простой запрос

Браузер различает два типа запросов: простые и с предварительной проверкой. К запросу из fetch или XMLHttpRequest на чужой источник он добавляет заголовок Origin со своим источником. Если сервер согласен, в ответе стоит заголовок Access-Control-Allow-Origin. Браузер сравнивает одно с другим и решает, отдать ли ответ скрипту.

Запрос считается простым, если выполнены три главных условия (ещё два касаются потока ReadableStream в теле и обработчиков на xhr.upload):

  • метод GET, HEAD или POST;
  • вручную заданы только заголовки Accept, Accept-Language, Content-Language, Content-Type и Range;
  • Content-Type один из трёх: application/x-www-form-urlencoded, multipart/form-data или text/plain.

Простой запрос уходит сразу, без предварительной проверки. Отсюда следствие для безопасности: CORS защищает данные в браузере пользователя, сам сервер он не защищает. На стенде страница отправила POST с text/plain на маршрут /no-header отдельного тестового API, где заголовков CORS нет (это не сервер из раздела про Node.js ниже, у него свой формат журнала). Браузер ответ заблокировал, а в журнале тестового API запрос есть:

POST /no-header Origin=http://localhost:3000

Окно терминала с журналом сервера, одна строка подсвечена

Если бы этот обработчик удалял запись, запись бы удалилась. Защиту от CSRF (cross-site request forgery, подделки межсайтовых запросов) и проверку прав CORS не заменяет.

Предварительный запрос (preflight)

Запрос с методом PUT или DELETE, с заголовком Authorization или с Content-Type: application/json простым уже не считается. Перед ним браузер сначала отправляет предварительный запрос (preflight request) методом OPTIONS и спрашивает разрешение:

OPTIONS /api/items HTTP/1.1
Origin: http://localhost:3000
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization

Ответ должен прийти с кодом из диапазона 200-299, например 200 или 204, и перечислить необходимые методы и заголовки. Так ответил сервер из раздела про Node.js ниже, вывод снят с помощью curl (сокращён: убраны Date, Connection и Keep-Alive):

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Credentials: true
Vary: Origin
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 600

Только после такого ответа уходит основной запрос. Если проверка не прошла, браузер его не отправляет. Без Access-Control-Max-Age браузер помнит результат проверки 5 секунд и потом шлёт OPTIONS снова. Большое значение браузеры урезают: Chromium с 76-й версии держит не дольше 2 часов (7200 секунд), Firefox не дольше 24 часов.

Читайте также:  Строки в PHP: кавычки, echo, функции и работа с UTF-8

Предварительный запрос никогда не несёт куки и токены. Если OPTIONS проходит через проверку авторизации, сервер может вернуть 401, и тогда основной запрос не уйдёт вовсе.

Сетевой коммутатор с подключёнными кабелями и горящими индикаторами портов

Заголовки CORS

Заголовок Кто отправляет Что значит
Origin браузер источник страницы, с которой идёт запрос
Access-Control-Request-Method браузер в preflight каким методом пойдёт основной запрос
Access-Control-Request-Headers браузер в preflight какие заголовки он добавит
Access-Control-Allow-Origin сервер указывает, кому дать доступ к ответу: конкретный источник или *
Access-Control-Allow-Methods сервер в preflight разрешённые HTTP-методы
Access-Control-Allow-Headers сервер в preflight разрешённые заголовки запроса
Access-Control-Max-Age сервер в preflight сообщает, сколько секунд кешировать проверку
Access-Control-Allow-Credentials сервер отдать ли скрипту ответ на запрос с куки
Access-Control-Expose-Headers сервер какие заголовки ответа отдать скрипту
Vary: Origin сервер ответ зависит от Origin, кешировать раздельно

Без Access-Control-Expose-Headers скрипт видит только Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified и Pragma. Свой X-Total-Count для пагинации придётся перечислить явно.

Vary: Origin нужен, когда в ответ подставляется разный источник в зависимости от запроса. Без него браузер или прокси может закешировать ответ без Access-Control-Allow-Origin и потом отдать его на CORS-запрос.

Куки и credentials

Ноутбук со страницей входа и ключ-карта: запросы с куки требуют точного источника в CORS

По умолчанию fetch на чужой домен куки не отправляет. Чтобы отправлял и мог получать ответ с данными пользователя, нужен credentials: "include", и тогда правила строже:

  • Access-Control-Allow-Origin содержит точный источник, звёздочка не работает;
  • Access-Control-Allow-Credentials: true, строго в нижнем регистре: True браузер не примет;
  • звёздочка в Allow-Methods, Allow-Headers и Expose-Headers тоже перестаёт работать, нужны явные списки.

Даже при верных заголовках на куки действуют настройки сторонних куки в браузере.

Мы бы не советовали отражать в ответе любой пришедший Origin вместе с Access-Control-Allow-Credentials: true. Тогда любой сайт сможет прочитать данные пользователя, если браузер отправит его куки. Держите список разрешённых источников.

Настройка CORS на сервере

Node.js без библиотек

Пример на встроенном модуле node:http разрешает доступ двум источникам, отвечает на предварительный запрос и принимает куки:

// server.mjs: API на порту 4000, Node.js 24, без зависимостей
import http from "node:http";

const ALLOWED = new Set(["http://localhost:3000", "https://app.example.com"]);

http.createServer((req, res) => {
  const origin = req.headers.origin;
  console.log(req.method, req.url, "Origin:", origin ?? "нет");

  if (origin && ALLOWED.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
  }
  res.setHeader("Vary", "Origin");

  if (req.method === "OPTIONS") {
    res.writeHead(204, {
      "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
      "Access-Control-Max-Age": "600",
    });
    return res.end();
  }

  res.writeHead(200, { "Content-Type": "application/json" });
  res.end(JSON.stringify({ ok: true, method: req.method }));
}).listen(4000, () => console.log("API: http://localhost:4000"));

Страница с разрешённого источника отправляет PUT с JSON и токеном:

fetch("http://localhost:4000/api/items", {
  method: "PUT",
  credentials: "include",
  headers: { "Content-Type": "application/json", "Authorization": "Bearer test" },
  body: JSON.stringify({ name: "test" }),
})
  .then((r) => r.json())
  .then((data) => console.log("Ответ:", JSON.stringify(data)))
  .catch((e) => console.log("Ошибка:", String(e)));

В консоли браузера и в терминале сервера:

Ответ: {"ok":true,"method":"PUT"}

API: http://localhost:4000
OPTIONS /api/items Origin: http://localhost:3000
PUT /api/items Origin: http://localhost:3000

Сначала пришёл OPTIONS, потом основной PUT. Как устроены then и await вокруг fetch, мы разбирали в статье про async/await в JavaScript.

Тот же сервер через curl с чужим источником (вывод сокращён так же):

$ curl -i http://localhost:4000/api/items -H "Origin: https://evil.example"
HTTP/1.1 200 OK
Vary: Origin
Content-Type: application/json
Transfer-Encoding: chunked

{"ok":true,"method":"GET"}

Данные пришли: curl правила CORS не проверяет, это делает только браузер.

Читайте также:  Язык программирования C++: история, стандарты и первые программы

Два монитора: слева терминал с запущенным сервером Node.js, справа браузер с консолью

Express

Пакет cors делает то же короче (Express 5.2, cors 2.8):

const express = require("express");
const cors = require("cors");

const app = express();
app.use(cors({
  origin: ["http://localhost:3000", "https://app.example.com"],
  credentials: true,
  maxAge: 600,
}));
app.use(express.json());

app.put("/api/items", (req, res) => res.json({ ok: true, name: req.body.name }));
app.listen(4000);

Если allowedHeaders не задан, пакет повторяет в Access-Control-Allow-Headers те заголовки, о которых спросил браузер. Подключайте cors() до маршрутов и до проверки авторизации, иначе OPTIONS до него не дойдёт.

Flask

Flask-CORS это расширение Flask, которое добавляет те же заголовки (Flask 3.1, Flask-CORS 6.0):

from flask import Flask, jsonify, request
from flask_cors import CORS

app = Flask(__name__)
CORS(app, origins=["http://localhost:3000"], supports_credentials=True, max_age=600)

@app.put("/api/items")
def put_item():
    return jsonify(ok=True, name=request.json["name"])

if __name__ == "__main__":
    app.run(port=4000)

На предварительный запрос расширение на стенде вернуло 200 с нашим источником в Access-Control-Allow-Origin и с Access-Control-Allow-Credentials. На запрос с чужого домена заголовок Access-Control-Allow-Origin не пришёл.

Прокси на время разработки

Доска со схемой из трёх блоков, соединённых стрелками, без надписей

Если фронтенд и API расходятся по портам только во время разработки, на Vite хватит встроенного прокси: браузер обращается к своему же источнику, и CORS не участвует.

// vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  server: {
    proxy: {
      "/api": "http://localhost:4000",
    },
  },
});

Теперь fetch("/api/items") со страницы на порту 5173 уходит на порт 4000. На проде такой прокси не работает: там его роль берёт nginx или бэкенд.

Ошибка CORS: что значит сообщение в консоли и как исправить

JavaScript о причине не узнаёт: в Chromium fetch падает с TypeError: Failed to fetch, и всё. Та же ошибка бывает и без CORS: если рядом в консоли net::ERR_CONNECTION_REFUSED, сервер API не запущен или слушает другой порт. Как найти его процесс, описано в статье про список процессов в Linux. Определить причину ошибки CORS можно по консоли браузера и вкладке Network. Chrome и Edge пишут Access to fetch at '...' from origin '...' has been blocked by CORS policy: и дальше причину. Firefox пишет Cross-Origin Request Blocked и причину в скобках после Reason:. Ниже сообщения Chromium, каждое воспроизведено на стенде.

No ‘Access-Control-Allow-Origin’ header is present

Access to fetch at 'http://localhost:4000/no-header' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present
on the requested resource.

Причины: сервер вообще не ставит заголовок, ваш источник не попал в список разрешённых или обработчик упал (на стенде ответ 500 без заголовка дал ровно это сообщение). Поэтому сначала откройте вкладку Network и посмотрите код ответа и журнал приложения.

В настройках nginx add_header по умолчанию срабатывает только на кодах 200, 201, 204, 206, 301, 302, 303, 304, 307 и 308. На 401, 404 и 500 заголовка не будет, пока не добавить always:

add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Vary Origin always;

Если в location есть хоть один свой add_header, заголовки из внешнего блока туда не наследуются.

Консоль браузера с красными строками ошибки CORS крупным планом

Response to preflight request doesn’t pass access control check

Response to preflight request doesn't pass access control check: It does not have HTTP ok status.

На OPTIONS пришёл код вне 200-299, на стенде это был 405. Обычно это значит: маршрут описан только для POST или PUT, а OPTIONS никто не обрабатывает, или предварительный запрос отсекла авторизация. Отвечайте на OPTIONS кодом 204 до проверки токена. Если вместо этого в строке стоит No 'Access-Control-Allow-Origin' header is present, на OPTIONS не пришёл заголовок, и код ответа может быть любым.

Value that is not equal to the supplied origin

The 'Access-Control-Allow-Origin' header has a value 'http://localhost:3000/'
that is not equal to the supplied origin.

На стенде хватило косой черты в конце. По спецификации браузер сравнивает значение заголовка с источником страницы побайтно, поэтому не совпадут и https вместо http, другой порт или лишний www..

Contains multiple values

The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed.

Заголовок добавили дважды: например, и nginx, и приложение. Оставьте CORS в одном месте.

Читайте также:  Циклы с условием: while, for и do while на примерах

Wildcard ‘*’ и credentials

The value of the 'Access-Control-Allow-Origin' header in the response must not be the
wildcard '*' when the request's credentials mode is 'include'.

Запрос идёт с credentials: "include", а в ответе звёздочка. Нужен точный источник. Если источник верный, но нет второго заголовка, сообщение другое: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'.

Not allowed by Access-Control-Allow-Headers или Allow-Methods

Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.
Method DELETE is not allowed by Access-Control-Allow-Methods in preflight response.

Заголовка или метода нет в списке ответа на preflight. Проблема решается одной строкой: добавьте его в Access-Control-Allow-Headers или Access-Control-Allow-Methods.

Redirect is not allowed for a preflight request

Ответ на OPTIONS оказался редиректом: на стенде это был 301. Так бывает, например, когда http перенаправляет на https или /api/items на /api/items/. Запрашивайте конечный адрес сразу.

Страница открыта как файл

Access to fetch at 'file:///.../data.json' from origin 'null' has been blocked by CORS policy:
Cross origin requests are only supported for protocol schemes: ... http, https ...

HTML открыли двойным щелчком, и у страницы нет источника, отсюда origin 'null'. Запустите любой локальный сервер, например python -m http.server 8000, и откройте http://localhost:8000.

Мини-компьютер с сетевым кабелем на столе: страницу открывают через локальный сервер

Почему «отключить CORS» не решение

Расширение браузера или Chrome с флагом --disable-web-security (он работает только вместе с --user-data-dir) снимают ошибку только на вашем компьютере. У пользователей сайт так и останется сломанным.

mode: "no-cors" ошибку убирает, но и данных не даёт. На стенде такой запрос вернул status 0, type: "opaque" и пустое тело. Прочитать ответ нельзя, это режим для запросов, где ответ не нужен.

Публичные CORS-прокси пропускают через чужой сервер ваши запросы, а с ними токены. Их мы бы не использовали даже для учебного проекта. Если API чужой и настроить его нельзя, ходите к нему со своего бэкенда.

Звёздочка в Access-Control-Allow-Origin без куки безопасна для открытых данных, которые и так можно скачать curl из любой точки сети: курсы валют, публичный каталог. Для личного кабинета, админки и всего, что стоит за входом или во внутренней сети, нужен список источников.

Частые вопросы

CORS policy: что это значит?

Это правила, которые сервер передаёт браузеру заголовками Access-Control-*: каким источникам, методам и заголовкам разрешён доступ к ресурсу. Фраза blocked by CORS policy в консоли означает, что ответ этим правилам не соответствует и браузер скрыл его от скрипта.

Почему в Postman и curl всё работает, а в браузере ошибка?

Postman, curl и серверный код политику одинакового источника не применяют. Заголовки CORS читает только браузер, поэтому они нужны лишь для запросов со страниц.

Почему OPTIONS уходит перед каждым запросом?

В ответе нет Access-Control-Max-Age, и браузер помнит проверку 5 секунд. Поставьте, например, 600: проверка будет повторяться раз в 10 минут. Кеш хранится отдельно для каждого URL, так что первый запрос к новому адресу снова начнётся с OPTIONS. С галочкой Disable cache во вкладке Network кеш не работает: на стенде два PUT подряд дали два OPTIONS, без галочки один.

Нужен ли CORS, если фронтенд и бэкенд на одном домене?

Нет, если совпадают схема, хост и порт. site.ru и api.site.ru уже разные источники, и для них CORS настраивать нужно.

Оцените статью
bestprogrammer.ru
Добавить комментарий