Содержание урока · 11 разделов
- CORS: блокирует не сервер, а браузер
- Preflight: невидимый OPTIONS-запрос
- Простые и непростые запросы
- cors.Options: что настраивать
- Поля cors.Options
- Отладка CORS: смотреть надо на сервер
- Сценарии запросов: что делает браузер
- CORS защищает не сервер, а пользователя
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сцена, которую видел каждый: фронтенд-разработчик запускает приложение с localhost:3000, API на том же ноутбуке слушает :8080, и каждый fetch падает с ошибкой про CORS — при том что в логах сервера все запросы зелёные и со статусом 200. Сервер невиновен, браузер не сломан: это Same-Origin Policy, правило браузера о том, что страница с одного происхождения не может без разрешения читать ответы другого. CORS — Cross-Origin Resource Sharing — и есть формат этого разрешения: сервер объявляет заголовками, кому и что можно. В chi разрешение включается одним middleware из экосистемы роутера. В этом уроке — кто блокирует запросы на самом деле, что такое preflight и почему он не доходит до ваших хендлеров, из чего состоит cors.Options и как отлаживать ошибку, причиной которой обязан сервер, а симптомом — страдает только браузер.
CORS: блокирует не сервер, а браузер#
Same-Origin Policy — защита пользователя: вкладка с сайта банка не должна уметь тихо читать ответы вашего API, потому что вместе с ними уйдут cookies. Совпадение происхождения означает схему, домен и порт — localhost:3000 и localhost:8080 считаются разными; сами термины разобраны в глоссарии. CORS — механизм, которым сервер добровольно ослабляет это правило: в ответе появляется заголовок Access-Control-Allow-Origin, и браузер решает, доверять ли ему. Отсюда главный факт урока: CORS исполняет исключительно браузер. curl, Postman и любой серверный клиент про заголовки согласия ничего не спрашивают и получают ответ как обычно. Если Postman показывает данные, а fetch в браузере — ошибку, значит сервер просто не отдал разрешения, и чинить надо его конфигурацию, а не фронтенд. Модуль go-chi/cors — самый ходовой вариант в cors go-экосистеме, и подключается он как обычный middleware.
cors.Handler в цепочке
GOpackage main
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
)
func main() {
r := chi.NewRouter()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"http://localhost:3000"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
AllowCredentials: true,
MaxAge: 300,
}))
r.Get("/api/notes", listNotes)
r.Post("/api/notes", createNote)
http.ListenAndServe(":8080", r)
}cors.Handler живёт в отдельном модуле github.com/go-chi/cors — добавьте его go get-ом рядом с chi. Регистрация через r.Use до маршрутов, как у любого middleware: слой ставит разрешающие заголовки на каждый ответ и сам отвечает на preflight. Обратите внимание на AllowedOrigins: точный адрес фронтенда, а не звёздочка — вместе с AllowCredentials: true звёздочка не работает, почему — ниже. Для запуска нужен локальный Go: песочница сайта HTTP-сервер не поднимает.
Preflight: невидимый OPTIONS-запрос#
Перед многими запросами браузер делает предварительный: отправляет OPTIONS с заголовком Origin — откуда пришла страница — и Access-Control-Request-Method — какой метод собирается использовать; если запрос несёт кастомные заголовки вроде Authorization, к ним добавляется Access-Control-Request-Headers. Это preflight, предзапрос: браузер сначала спрашивает разрешения, и лишь получив заголовки согласия в ответе, отправляет настоящий запрос. cors.Handler перехватывает preflight сам и отвечает, не доходя до ваших хендлеров — в логах это выглядит как OPTIONS, которого вы не регистрировали. Если источник не в списке разрешённых, preflight уходит без заголовков согласия — в go-chi/cors с кодом 403 — и настоящий запрос не отправляется вовсе: браузер отказывается сам, не тратя трафик на заведомо запрещённый вызов. Посмотреть preflight глазами curl можно в следующем блоке.
Preflight глазами curl
GO# preflight: браузер спрашивает разрешение до отправки POST
$ curl -i -X OPTIONS http://localhost:8080/api/notes \
-H "Origin: http://localhost:3000" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:3000
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 300Запрос OPTIONS на маршрут, которого в коде нет, — это preflight. cors.Handler видит Origin и Access-Control-Request-Method, сверяет их с cors.Options и отвечает сам: разрешение с нужными заголовками, до хендлера дело не доходит. Смените Origin на чужой домен — заголовки согласия исчезнут, и браузер такой preflight забракует. Тем же способом проверяют настройки, когда fetch падает: ответ curl показывает, чего именно не хватает.
Проверка: анатомия preflight
Что браузер отправляет в preflight перед POST с application/json?
Простые и непростые запросы#
Preflight нужен не всем. Простыми браузер считает запросы, которые исторически умели отправлять HTML-формы: GET, HEAD и POST с телом в text/plain, application/x-www-form-urlencoded или multipart/form-data и без кастомных заголовков — их браузер шлёт сразу, а проверяет уже заголовки ответа. Всё остальное требует preflight. И вот тут ошибка номер один при отладке JSON API: POST с Content-Type: application/json — непростой, потому что такой content-type формы не умеют. Разработчик смотрит на обычный POST и не понимает, откуда в логе OPTIONS. Любая авторизация через заголовок, например Authorization, тоже делает запрос непростым — как и большая часть настоящих JSON API. Проверка простая: если fetch падает, а в логе сервера нет входящего запроса — отказал preflight, и до хендлера дело не дошло.
AllowedOrigins: []string{"*"} вместе с AllowCredentials: true браузер не примет: при запросах с cookies безликая звёздочка в Access-Control-Allow-Origin запрещена, разрешение обязано называть точный Origin. Симптом: запрос с куками падает с CORS-ошибкой, хотя звёздочка стоит. Вторая ловушка того же рода: несколько Origin нельзя складывать через запятую в один заголовок — каждое происхождение это отдельное значение, и middleware решает эту задачу за вас.
cors.Options: что настраивать#
cors.Handler принимает структуру cors.Options, и почти вся настройка сводится к четырём полям: AllowedOrigins — какие происхождения пускать, AllowedMethods — какие HTTP-методы, AllowedHeaders — какие заголовки запроса разрешить сверх простых, AllowCredentials — можно ли слать cookies и авторизацию. Поле ExposedHeaders пропускают часто, а зря: без Access-Control-Expose-Headers браузер не даст JavaScript прочитать кастомные заголовки ответа — например Request-ID из урока про встроенные middleware. MaxAge говорит браузеру, сколько секунд кэшировать ответ на preflight: чем больше, тем реже летят лишние OPTIONS. Разрешать стоит узко: конкретный домен фронтенда, а не звёздочку, и только реально используемые методы и заголовки — каждый пункт списка это доступ, который вы выдаёте чужим страницам.
Поля cors.Options#
- AllowedOrigins — список допустимых происхождений или звёздочка
- AllowedMethods — HTTP-методы, которые разрешено отправлять
- AllowedHeaders — кастомные заголовки запроса: Authorization, Content-Type, X-Request-ID
- ExposedHeaders — заголовки ответа, которые JavaScript сможет прочитать
- AllowCredentials — разрешение на cookies и авторизацию в запросе
- MaxAge — на сколько секунд браузер кэширует ответ на preflight
Проверка: что видит JavaScript
Сервер обработал POST и вернул 200, но без Access-Control-Allow-Origin. Что увидит JavaScript в браузере?
Что решит cors.Handler?
Прочитайте код и выберите, что он выведет:
package main
import "fmt"
func main() {
allowed := map[string]bool{
"http://localhost:3000": true,
}
requests := []string{
"http://localhost:3000",
"https://evil.example.net",
}
for _, origin := range requests {
if allowed[origin] {
fmt.Println(origin, "-> заголовки согласия")
} else {
fmt.Println(origin, "-> 403, заголовков нет")
}
}
}Соберите подключение CORS
Впишите имя функции-обёртки и два поля cors.Options: список доменов и разрешение на заголовки авторизации.
Отладка CORS: смотреть надо на сервер#
Ошибка CORS в консоли браузера похожа на приговор без деталей: браузер сознательно скрывает подробности, чтобы не отдавать чужому сайту информацию о вашем ответе. Поэтому отлаживают со стороны сервера. Первым делом повторите preflight curl-ом: OPTIONS с заголовками Origin и Access-Control-Request-Method — и смотрите, какие Access-Control-* заголовки пришли в ответ. Пришли и правильные — дело в самом запросе: проверяйте, совпадает ли Origin посимвольно, включая порт и https. Не пришли — перечитайте AllowedOrigins, AllowedMethods и AllowedHeaders: чаще всего не хватает одного пункта из Access-Control-Request-Headers. Полезно логировать Origin в middleware: в многодоменных проектах разрешение регулярно забывают добавить для нового домена, и лог это покажет сразу.
Сценарии запросов: что делает браузер#
| Сценарий | Браузер | Что нужно серверу |
|---|---|---|
| GET без кастомных заголовков | отправляет сразу, проверяет заголовки ответа | Access-Control-Allow-Origin в ответе |
| POST с application/json | сначала preflight OPTIONS, затем настоящий запрос | согласие на метод и Content-Type в preflight |
| Запрос с Authorization | сначала preflight с Access-Control-Request-Headers | AllowedHeaders, содержащий Authorization |
| Запрос с cookies (credentials) | требует точный Origin вместо звёздочки | точный Origin и AllowCredentials: true |
Соберите сервер с CORS
Разложите строки в правильном порядке: роутер, служебный слой, разрешение источников, страховка от паник, маршрут и запуск сервера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Продакшн-настройка cors.Options
GOr.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://app.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Content-Type", "Authorization", "X-Request-ID"},
ExposedHeaders: []string{"X-Request-ID"},
AllowCredentials: true,
MaxAge: 300,
}))Полновесные настройки для реального домена: ExposedHeaders открывает X-Request-ID для JavaScript, MaxAge на 300 секунд кэширует preflight — пять минут браузер не дёргает сервер повторными OPTIONS. OPTIONS в списке методов можно не перечислять: cors.Handler отвечает на preflight сам, но явный метод не помешает, если preflight где-то проверяется ещё и вручную. От localhost до продакшена — замена одного значения в AllowedOrigins; конфигурацию выносят в переменные окружения, чтобы домен менялся без пересборки. Проверяйте всё локальным Go и двумя вкладками браузера: с разрешённого домена и с чужого.
Пусть сервер кладёт в ответ X-Request-ID или X-RateLimit-Remaining. В браузере response.headers.get("X-Request-ID") вернёт null: по умолчанию JavaScript видит только простые заголовки вроде Content-Type. Добавьте ExposedHeaders: []string{"X-Request-ID"} в cors.Options — и заголовок станет читаемым. Отсюда же правило отладки: в JS пусто, а в curl заголовок виден — не хватает ExposedHeaders, а не Allow-Origin.
CORS защищает не сервер, а пользователя#
Посыл, который стоит проговорить в конце: выключите CORS — и сервер не пострадает ни на бит. Браузерное правило не действует на curl и вредоносные скрипты вне браузера: они и так стучатся в ваш API без всяких разрешений. CORS нужен для другого: чтобы вкладка с чужого сайта не смогла от имени вашего пользователя — с его cookies — читать ответы вашего API. Поэтому честная настройка CORS — вопрос пользовательской безопасности, а не защиты сервера; защита сервера — это токены, авторизация и предохранители вроде таймаутов из прошлого урока. И обратное следствие: CORS не авторизация. Если часть API рассчитана только на ваш фронтенд, надеяться, что отсутствие заголовков её защитит, — ошибка: любой не-браузерный клиент заголовки игнорирует.
Карточки: CORS
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- CORS исполняет браузер: без заголовков согласия он прячет ответ от JavaScript.
- Preflight — OPTIONS с Origin и Access-Control-Request-Method перед непростыми запросами.
- cors.Handler из github.com/go-chi/cors вешается через r.Use и отвечает на preflight сам.
- AllowedOrigins — точные домены: звёздочка несовместима с AllowCredentials.
- ExposedHeaders открывает кастомные заголовки ответа, MaxAge кэширует preflight.
- Ошибка CORS чинится на сервере, хотя видна только в консоли браузера.
- CORS не авторизация: серверный клиент его игнорирует.
Домашнее задание
Подключите cors.Handler к сервису из прошлых уроков: разрешите Origin фронтенда с портом, методы GET и POST и заголовок Content-Type. Проверьте curl-ом preflight на POST с application/json, затем уберите домен из AllowedOrigins и убедитесь, что заголовки согласия исчезли, а fetch в браузере упадёт с ошибкой CORS.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли писать Access-Control-Allow-Origin вручную в хендлерах?
Технически да: w.Header().Set("Access-Control-Allow-Origin", ...) в каждом хендлере. Но preflight-OPTIONS придётся обрабатывать отдельно, а один новый домен превращается в правку десятков мест. cors.Handler делает то же самое централизованно.
Почему в Postman нет ошибок CORS, а в браузере есть?
CORS исполняет только браузер: Postman, curl и серверные клиенты отправляют запросы без проверок. Если Postman возвращает данные, а fetch — ошибку, сервер работает, но не отдал заголовков согласия. Чинить нужно cors.Options.
Нужен ли отдельный маршрут для OPTIONS?
Нет: cors.Handler перехватывает preflight до маршрутизации и отвечает сам. Отдельный r.Options нужен только без middleware или когда на preflight должна быть особая логика — обычно это не требуется.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Централизованные ошибки: errorHandler и NotFound
- Graceful shutdown: выключаемся вежливо
- Валидация параметров и тел запросов
- Порядок middleware: почему он важен
- Timeout и Throttle: защита от перегрузки
- Собственный middleware: таймер, лог, авторизация
- JSON API: парсим и отдаём JSON
- context.Context: прокидываем данные в хендлеры
- Все курсы по Go
Следующая ступень учебного пути
Redis в Go: кэш, сессии и очереди на go-redis
Курс Redis для Go-разработчиков: кэширование запросов, сессии, счётчики, Pub/Sub, Streams и rate limiter. 25 уроков с примерами кода go-redis v9.
- Подключаться к Redis из Go и настраивать пул соединений
- Кэшировать результаты запросов и придумывать TTL
- Работать с хэшами, списками, множествами и ZSET
- Строить сессии, счётчики, лидерборды и rate limiter