3. CORS
Архитектура перекрестного домена: от Same-Origin Policy к HTTP-заголовкам
На заре зарождения Всемирной паутины инженеры Netscape сформулировали один из важнейших принципов безопасности браузера: Same-Origin Policy (SOP — политика одинакового источника). Суть ее предельно проста: скрипт, загруженный со страницы bank.com, имеет право отправлять сетевые запросы и читать данные только и исключительно с домена bank.com. Попытка обратиться к evil.com или даже к поддомену api.bank.com жестко пресекалась браузером.
Однако современная веб-разработка давно переросла эту модель. Типичное веб-приложение представляет собой Single Page Application (SPA), развернутое на CDN (app.example.com), которое обращается к REST/gRPC API на api.example.com и загружает медиафайлы с s3.cloud-storage.com.
Чтобы разрешить легитимное взаимодействие между разными доменами без потери безопасности, консорциум W3C разработал спецификацию CORS (Cross-Origin Resource Sharing — совместное использование ресурсов между разными источниками).
Главный парадокс CORS
Для каждого бэкенд-инженера на Go критически важно усвоить базовый парадокс:
CORS — это не защита вашего сервера, это защита клиента!
Сервер Go не «отклоняет» и не «сбрасывает» сетевые соединения при отсутствии заголовков CORS. Он послушно читает TCP-сокет, парсит JSON, выполняет бизнес-логику и возвращает код 200 OK. Но когда браузер пользователя получает ответ и видит, что сервер не прислал разрешающий заголовок Access-Control-Allow-Origin, именно браузер блокирует доступ JavaScript-кода к телу ответа.
Любые консольные утилиты вроде curl, Postman, Python-скрипты или бэкенд-серверы полностью игнорируют CORS, так как они не являются браузерами и не подчиняются Same-Origin Policy.
flowchart LR
subgraph Browser["Клиентский браузер (Песочница JS)"]
A["Браузер<br/>JS-скрипт"]
end
subgraph PreflightPhase["Фаза 1: Предварительный запрос (Preflight)"]
B["1 - Preflight запрос<br/>OPTIONS"]
D["2 - Проверка Origin<br/>и заголовков"]
E["3 - Ответ с заголовками<br/>ACAO, ACAM, ACAH"]
end
subgraph GoServer["Сервер на Go (net/http)"]
C["Сервер на Go"]
end
subgraph ActualPhase["Фаза 2: Фактический запрос (Actual)"]
F["4 - Реальный запрос<br/>GET, POST"]
G["5 - Ответ с данными<br/>и CORS-заголовками"]
end
A --> B
B --> C
C --> D
C --> E
D --> A
E --> A
A --> F
F --> C
C --> G
G --> A
style Browser fill:#0f172a,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
style GoServer fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#f8fafc
style B fill:#1e293b,stroke:#ffd93d,stroke-width:2px,color:#f8fafc
style E fill:#1e293b,stroke:#4ecdc4,stroke-width:2px,color:#f8fafc
Механика работы и типы запросов
Браузер строго разделяет все кросс-доменные запросы на две фундаментальные категории:
- Простые запросы (Simple Requests):
Запросы с методамиGET,HEADилиPOST, у которых заголовки ограничены стандартными безопасными полями (Accept,Accept-Language,Content-Language), а заголовокContent-Typeпринимает только три значения:text/plainmultipart/form-dataapplication/x-www-form-urlencoded
Такие запросы браузер отправляет на сервер сразу, без предупреждения. Если в ответе сервера отсутствует заголовокAccess-Control-Allow-Originсо значением вызывающего домена, браузер выбрасывает ошибку в консоль и скрывает ответ от скрипта.
- Предварительные запросы (Preflight Requests):
Если запрос использует методыPUT,DELETE,PATCH, кастомные заголовки (например,Authorization,X-Request-ID) или заголовокContent-Type: application/json, браузер не имеет права отправлять его сразу.
Сначала браузер отправляет легковесный зондирующий запрос с методомOPTIONS. Сервер должен ответить, перечислив разрешенные методы (Access-Control-Allow-Methods) и заголовки (Access-Control-Allow-Headers). Только получив одобрение, браузер отправляет основной payload.
Идиоматичная реализация в Go
В стандартной библиотеке Go пакет net/http намеренно не содержит встроенной «магии» CORS. Разработчик обязан реализовать логику через Middleware либо подключить проверенную библиотеку (например, rs/cors).
Паттерн безопасного Middleware без лишних аллокаций:
package cors
import (
"net/http"
"strings"
)
// CORSMiddleware реализует обработку кросс-доменных запросов по строгому белому списку
func CORSMiddleware(allowedOrigins []string) func(http.Handler) http.Handler {
// Pre-compile map for O(1) lookup: предкомпиляция для быстрого поиска без аллокаций
allowed := make(map[string]bool, len(allowedOrigins))
for _, o := range allowedOrigins {
allowed[strings.ToLower(o)] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Если это кросс-доменный запрос (присутствует заголовок Origin)
if origin != "" {
// Проверка белого списка. Не используйте * с Credentials!
if _, ok := allowed[strings.ToLower(origin)]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Кэширование результата Preflight (в секундах)
// Уменьшает количество OPTIONS запросов на клиенте
w.Header().Set("Access-Control-Max-Age", "3600")
// Обработка Preflight запроса
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent) // 204 No Content
return
}
// Для простых запросов заголовки уже установлены,
// продолжаем цепочку обработки
}
next.ServeHTTP(w, r)
})
}
}
Под капотом: влияние на производительность и соединения
Реализация CORS на бэкенде имеет ощутимую цену в терминах ресурсов процессора и сетевого стека:
- Задержка сети (Latency Penalty):
Каждый Preflight-запросOPTIONSдобавляет полный сетевой Round-Trip Time (RTT) к вызову API. В эпоху HTTP/1.1 это парализовало загрузку SPA, так как браузер держал лимит в 6 одновременных TCP-соединений на домен. В протоколах HTTP/2 и HTTP/3 благодаря мультиплексированию фреймов в едином TCP/QUIC-соединении ситуация улучшилась, но задержка RTT на мобильных сетях (50–150 мс) никуда не исчезает. ДирективаAccess-Control-Max-Age: 3600заставляет браузер кэшировать результат preflight на 1 час, снижая трафик. - Парсинг заголовков и аллокации:
Вызовr.Header.Get("Origin")в Go выполняет канонизацию строки ключа черезtextproto.CanonicalMIMEHeaderKeyи поиск в хэш-мапеhttp.Header(map[string][]string). При 50 000 RPS постоянный доступ к мапе создает микроскопическое, но измеримое давление на кэш-линии CPU. - Разгрузка Go-сервера на уровне Ingress:
Поскольку запросOPTIONSне несет бизнес-логики, в высоконагруженных архитектурах обработкуOPTIONSполностью делегируют обратному прокси или балансировщику (Nginx, HAProxy, Envoy). Прокси возвращает статус 204 с CORS-заголовками прямо из памяти веб-сервера, не передавая холостой запрос в Go-приложение и экономя горутины и аллокацииhttp.Request.
Ловушки и уязвимости (Gotchas)
-
Невалидированное отражение Origin (Null / Insecure Echo):
Грубейшая ошибка начинающих разработчиков — автоматическое копирование входящегоOriginв ответ:Go // ❌ КАТАСТРОФИЧЕСКИ ОПАСНО w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))В такой конфигурации любой фишинговый сайт
evil.comможет делать AJAX-запросы к вашему API, читать конфиденциальные ответы и воровать данные пользователей. Всегда проверяйте домен по строгому белому списку (Allowlist). -
Ловушка кэширования браузером:
Если вы изменили конфигурацию разрешенных заголовков или методов на сервере, браузер разработчика может продолжать падать с ошибками из-за старого кэшаAccess-Control-Max-Age. Для чистой отладки сетевых контрактов всегда используйте утилитуcurl -v— она не сохраняет состояние и показывает сырые HTTP-заголовки ответа. - Wildcards в заголовках:
Хотя современные браузеры (начиная с Chrome 76+) поддерживают маскуAccess-Control-Allow-Headers: *для Preflight-запросов, в продакшене рекомендуется явно декларировать список разрешенных заголовков (Authorization, Content-Type, X-Request-ID) для обратной совместимости со старыми корпоративными клиентами.
Итог
- Клиентская природа CORS: CORS — это механизм браузерной изоляции Same-Origin Policy. Сервер не блокирует запросы сам, а лишь декларирует права доступа через заголовки ответа.
- Две фазы взаимодействия: Простые запросы выполняются сразу; запросы с телом JSON, методом PUT/DELETE или кастомными заголовками требуют предварительного
OPTIONSPreflight-запроса. - Безопасность Credentials: Комбинация
Access-Control-Allow-Origin: *иCredentials: trueзапрещена стандартом. Допустимо только явное отражение проверенного Origin из белого списка. - Оптимизация задержки: Заголовок
Access-Control-Max-Ageкэширует результаты preflight-проверки в браузере, устраняя лишние сетевые RTT. Для сверхнагруженных API обработкуOPTIONSвыносят на Nginx или Envoy. - Порядок Middleware: Обработчик CORS обязан находиться на самом входе в пайплайн обработки HTTP, строго до любых проверок аутентификации и сессий.
Как организовать аутентификацию Machine-to-Machine и ограничить доступ к сервису с помощью криптографически стойких API-ключей?
В следующей лекции мы разберем: 4. API keys.