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
  

Механика работы и типы запросов

Браузер строго разделяет все кросс-доменные запросы на две фундаментальные категории:

  1. Простые запросы (Simple Requests):
    Запросы с методами GET, HEAD или POST, у которых заголовки ограничены стандартными безопасными полями (Accept, Accept-Language, Content-Language), а заголовок Content-Type принимает только три значения:
    • text/plain
    • multipart/form-data
    • application/x-www-form-urlencoded
      Такие запросы браузер отправляет на сервер сразу, без предупреждения. Если в ответе сервера отсутствует заголовок Access-Control-Allow-Origin со значением вызывающего домена, браузер выбрасывает ошибку в консоль и скрывает ответ от скрипта.
  2. Предварительные запросы (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 без лишних аллокаций:

Go
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 на бэкенде имеет ощутимую цену в терминах ресурсов процессора и сетевого стека:

  1. Задержка сети (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 час, снижая трафик.
  2. Парсинг заголовков и аллокации:
    Вызов r.Header.Get("Origin") в Go выполняет канонизацию строки ключа через textproto.CanonicalMIMEHeaderKey и поиск в хэш-мапе http.Header (map[string][]string). При 50 000 RPS постоянный доступ к мапе создает микроскопическое, но измеримое давление на кэш-линии CPU.
  3. Разгрузка Go-сервера на уровне Ingress:
    Поскольку запрос OPTIONS не несет бизнес-логики, в высоконагруженных архитектурах обработку OPTIONS полностью делегируют обратному прокси или балансировщику (Nginx, HAProxy, Envoy). Прокси возвращает статус 204 с CORS-заголовками прямо из памяти веб-сервера, не передавая холостой запрос в Go-приложение и экономя горутины и аллокации http.Request.


Ловушки и уязвимости (Gotchas)

  1. Невалидированное отражение Origin (Null / Insecure Echo):
    Грубейшая ошибка начинающих разработчиков — автоматическое копирование входящего Origin в ответ:

    Go
    // ❌ КАТАСТРОФИЧЕСКИ ОПАСНО
    w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
    

    В такой конфигурации любой фишинговый сайт evil.com может делать AJAX-запросы к вашему API, читать конфиденциальные ответы и воровать данные пользователей. Всегда проверяйте домен по строгому белому списку (Allowlist).

  2. Ловушка кэширования браузером:
    Если вы изменили конфигурацию разрешенных заголовков или методов на сервере, браузер разработчика может продолжать падать с ошибками из-за старого кэша Access-Control-Max-Age. Для чистой отладки сетевых контрактов всегда используйте утилиту curl -v — она не сохраняет состояние и показывает сырые HTTP-заголовки ответа.

  3. Wildcards в заголовках:
    Хотя современные браузеры (начиная с Chrome 76+) поддерживают маску Access-Control-Allow-Headers: * для Preflight-запросов, в продакшене рекомендуется явно декларировать список разрешенных заголовков (Authorization, Content-Type, X-Request-ID) для обратной совместимости со старыми корпоративными клиентами.



Итог

  1. Клиентская природа CORS: CORS — это механизм браузерной изоляции Same-Origin Policy. Сервер не блокирует запросы сам, а лишь декларирует права доступа через заголовки ответа.
  2. Две фазы взаимодействия: Простые запросы выполняются сразу; запросы с телом JSON, методом PUT/DELETE или кастомными заголовками требуют предварительного OPTIONS Preflight-запроса.
  3. Безопасность Credentials: Комбинация Access-Control-Allow-Origin: * и Credentials: true запрещена стандартом. Допустимо только явное отражение проверенного Origin из белого списка.
  4. Оптимизация задержки: Заголовок Access-Control-Max-Age кэширует результаты preflight-проверки в браузере, устраняя лишние сетевые RTT. Для сверхнагруженных API обработку OPTIONS выносят на Nginx или Envoy.
  5. Порядок Middleware: Обработчик CORS обязан находиться на самом входе в пайплайн обработки HTTP, строго до любых проверок аутентификации и сессий.

Как организовать аутентификацию Machine-to-Machine и ограничить доступ к сервису с помощью криптографически стойких API-ключей?
В следующей лекции мы разберем: 4. API keys.