Ваш чат заработал с токенами, лимитами и heartbeat, а потом кто-то открыл его с чужого сайта — и браузер не возразил. Для fetch и XMLHttpRequest есть CORS, который заставляет сервер явно разрешать чужие домены, но websocket живёт по другим правилам: рукопожатие не подчиняется CORS, и обычный браузер установит соединение с любой страницы. Единственное, что браузер делает добровольно, — прикладывает к рукопожатию заголовок Origin с адресом страницы, открывшей соединение. Проверить его — обязанность сервера, и в gorilla для этого есть поле CheckOrigin у Upgrader. Звучит как мелочь, но цена ошибки конкретная: страница злоумышленника открывает socket к вашему серверу, браузер автоматически прикладывает cookie залогиненного пользователя, и соединение выполняется от его имени — это cross-site websocket hijacking, атака, родственная CSRF, только с постоянным двусторонним каналом.
Websocket Origin: что браузер шлёт при рукопожатии#
При открытии new WebSocket(url) браузер отправляет обычный HTTP-запрос Upgrade, и среди заголовков — Origin со схемой, доменом и портом страницы-инициатора: для чата на https://chat.example это https://chat.example. Websocket Origin проверяется целиком на сервере: gorilla вызывает вашу функцию CheckOrigin на каждом рукопожатии, и если она вернула false, клиент получает HTTP 403, а браузер рапортует об ошибке соединения. Дефолтное поведение, если CheckOrigin не задан, описано в исходниках: запрос пропускается, когда заголовка Origin нет вовсе либо когда host из Origin совпадает с хостом самого запроса. То есть gorilla из коробки не пропускает чужой домен — дыры появляются там, где разработчик явно отключает проверку. А её отключают сплошь и рядом: стек трейса обычно ведёт к копипасте из туториала, где CheckOrigin возвращает true, потому что автору лень было открывать страницу с того же домена.
Дыра из туториала: return true
GOvar upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // встречается в половине гайдов
},
}
// по умолчанию, без CheckOrigin, gorilla сам сверяет
// host из Origin с хостом запроса — и чужой домен
// получает 403. Здесь проверка отключена вручную.Return true означает: соединение принимается с любой страницы интернета. Если авторизация построена на cookie, страница злоумышленника получит соединение от имени залогиненного пользователя — без всякого взлома токенов. Если вы не cookies и токен живёт в query из тринадцатого урока, ущерб меньше, но дефолтную сверку всё равно стоит оставить вместо true.
Механику атаки стоит проговорить по шагам, потому что она неприятнее обычного CSRF. Пользователь залогинен в ваш сервис, cookie живёт в браузере и прикладывается к запросам вашего домена автоматически. Вкладка с вредоносной страницей запускает скрипт, который открывает new WebSocket на ваш адрес — и браузер прикладывает cookie вашего домена, потому что соединение идёт к нему, а не к злоумышленнику. Рукопожатие с проверкой токена на сервере завершается успешно, и злоумышленник получает живой канал от имени пользователя: читает рассылку, пишет в чат, видит presence. Обычная защита от CSRF не сработала бы: проверка заголовка X-Requested-With бесполезна, потому что websocket-клиент его не шлёт, а SameSite-политики cookie частично помогают, но полагаться на них одного недостаточно. Линия обороны — та самая сверка Origin: чужая страница не сможет открыть соединение, потому что её источник не в списке разрешённых.
Access-Control-Allow-Origin относится к fetch и XHR: websocket-рукопожатие его игнорирует, и настроенный CORS не остановит чужую страницу. Проверку надо ставить именно в CheckOrigin — это единственное место, где gorilla спросит про источник. Смешивать эти два механизма в голове опасно: «у нас же CORS настроен» — типичный ответ на вопрос, почему сервер принимает соединения откуда угодно.
Allowlist доменов в CheckOrigin#
Рабочая проверка — это allowlist: список доменов, с которых соединение разрешено, и сравнение хоста из Origin с этим списком. Схему и порт удобно не сравнивать вручную, а доверить url.Parse: она разберёт заголовок на части, и вы сравните поле Host — домен вместе с портом. Пустой Origin — отдельная ветка: его присылают небраузерные клиенты вроде wscat и мобильных SDK, и решение пускает их или нет — это политика вашего API, а не протокола. Строгий вариант требует, чтобы даже они шли с авторизацией из тринадцатого урока, и тогда пустой Origin можно отклонять без сожалений. Дефолтную сверку gorilla полезно знать, чтобы понимать, от чего вы отказываетесь, переопределяя CheckOrigin: она сравнивает host источника с хостом запроса, а вы заменяете её явным списком — поведение становится читаемым в коде, а не спрятанным в библиотеке.
CheckOrigin с allowlist
GOvar allowedOrigins = map[string]bool{
"chat.example.com:443": true,
"localhost:3000": true,
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return false // wscat и SDK — через токен
}
u, err := url.Parse(origin)
if err != nil {
return false
}
return allowedOrigins[u.Host]
},
}Заголовок читается через r.Header.Get, разбирается url.Parse, и на выходе сравнивается только host с портом. Карта со значениями bool — тот же набор, что в комнатах одиннадцатого урока. Обе сомнительные ветки возвращают false: неизвестный домен и неразбираемый заголовок. localhost рядом с продовым доменом — для локальной разработки, в релизной сборке его убирают.
В деталях allowlist прячутся вопросы, которые лучше решить до релиза. Host включает порт, и https://chat.example разбор отдаст как chat.example:443 — если у вас несколько портов или схем, перечислите все или нормализуйте перед сравнением. Поддомены не совпадают с основным доменом автоматически: www.chat.example не пройдёт проверку для chat.example, и если поддоменов много, решение о них — префиксный матч или отдельные записи — остаётся за вами. Регистр букв в доменах браузер нормализует сам, но свой матчинг лучше писать без сюрпризов. И важное честное замечание: Origin подделать с браузера нельзя — это защищаемое браузером поле, — но ничто не мешает скрипту на чужом сервере слать любые заголовки напрямую, минуя браузер. Поэтому Origin — линия против браузерных атак, а не аутентификация: настоящий вопрос «кто ты» по-прежнему решает токен из тринадцатого урока.
Кейс: сервер за прокси#
Как только за сервером появляется nginx или облако с балансировщиком, дефолтная сверка gorilla ломается тихо и без ошибок в логах. Причина: хостом запроса для библиотеки становится то, что прислал прокси, — внутренний адрес или домен балансировщика, — и совпадение с Origin теряется даже для честной страницы с вашего домена. Классические симптомы: всё работает напрямую, а после установки за прокси половина клиентов ловит 403 на рукопожатии. Лечится переносом проверки с дефолта на явный allowlist, который не зависит от того, что прокси написал в Host: домены из списка — ваши публичные адреса, и они не меняются от топологии за ними. Второй вариант — прокси, который прокидывает настоящий заголовок Host, но это лишняя настройка в чужом слое, и полагаться на неё я не люблю: конфигурация прокси обычно принадлежит другой команде. Явный allowlist делает проверку самодостаточной: серверу не интересно, кто и как переписал Host в пути. Проверка топологии — дело одной минуты: поднимите nginx перед своим сервером на локальном Go и увидите оба поведения до того, как они встретятся в проде.
CORS против проверки Origin в ws#
| Критерий | CORS для fetch | CheckOrigin для websocket |
|---|---|---|
| Что защищает | fetch, XHR и ответы на них | рукопожатие websocket |
| Где настраивается | заголовки ответа, middleware | поле CheckOrigin у Upgrader |
| Кто сообщает источник | браузер в заголовке Origin | тот же заголовок Origin |
| Без настройки | браузер блокирует чужие ответы | gorilla сверяет хосты сама |
Спорное мнение: Origin — вторая линия обороны#
Я отношу проверку Origin к обязательным, но не к главным механизмам, и вот почему. Если авторизация построена на токене в query, который злоумышленник не знает, чужая страница откроет socket и упрётся в 401 — атака захлебнётся без всякого CheckOrigin. Cookie меняют расклад: они едут на рукопожатие автоматически, и тогда проверка источника становится обязательной, а не рекомендуемой. Поэтому мой порядок внедрения такой: токен на рукопожатии, потом allowlist в CheckOrigin, потом rate limit и лимиты кадров. Ставить Origin первым, а авторизацию «потом» — значит защищать дверь, у которой пока нет замка. В проектах, где клиент и сервер живут на одном домене за одним прокси, дефолтной сверки gorilla часто достаточно — переопределять её стоит, когда доменов несколько или когда хочется видеть политику явно в коде, а не в документации библиотеки.
Проверка: дефолт gorilla
Что делает CheckOrigin по умолчанию, если его не задавать?
Проверка: что приедет с чужой страницы
Страница evil.com открывает new WebSocket на bank.example, пользователь залогинен в bank.example по cookie. Что браузер приложит к рукопожатию?
Рукопожатие с чужого домена
Прочитайте код и выберите, что он выведет:
// Upgrader без CheckOrigin — дефолтное поведение
// сервер слушает на chat.example.com
// страница на evil.com выполняет:
// new WebSocket("wss://chat.example.com/ws")Запустите сервер на локальном Go с allowlist из урока и откройте страницу с другого порта — например, статичный html на :8080 против сервера на :4000. new WebSocket получит отказ, а в логе рукопожатие будет отклонено. Уберите пустую ветку из CheckOrigin и увидите, как соединения без Origin начнут проходить.
Соберите allowlist-проверку
Впишите четыре пропуска: чтение заголовка, ответ на пустой Origin, разбор адреса и поле для сравнения с списком.
Соберите начало CheckOrigin
Расставьте строки проверки в правильном порядке: чтение заголовка, отказ на пустой Origin и разбор адреса.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: Origin
Origin закрывает вопрос «откуда пришло рукопожатие», и в связке с авторизацией из тринадцатого урока периметр соединения становится законченным: токен отвечает на вопрос «кто ты», allowlist — на вопрос «откуда ты», лимиты из четырнадцатого — на вопрос «как себя ведёшь». Дальше по курсу соединения живут дольше и сложнее: в шестнадцатом уроке мы научимся прощаться с ними при выключении сервера, а в семнадцатом — возвращать клиентов после обрывов. Настройку заголовков и CORS-мидлварей для обычных HTTP-эндпоинтов, которые живут рядом с ws, разбирал курс про роутер chi. Проверьте свой Upgrader прямо сейчас: если где-то в коде написано return true — вы знаете, что делать. План остальных уроков — на странице курса WebSocket.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Рукопожатие websocket не подчиняется CORS: единственная проверка источника живёт в Upgrader.CheckOrigin.
- Дефолт gorilla пускает пустой Origin и совпадающие хосты, чужие домены получают 403.
- Return true из туториала отключает защиту: вместе с cookie это дорога к cross-site hijacking.
- Allowlist: r.Header.Get("Origin"), url.Parse и сравнение u.Host с картой разрешённых доменов.
- Пустой Origin у wscat и SDK — отдельная ветка политики, обычно вместе с токеном из тринадцатого урока.
- Origin подделать из браузера нельзя, но не-браузерный клиент шлёт любые заголовки: это вторая линия после токена.
Домашнее задание
Уберите return true из всех Upgrader-ов своего проекта и соберите allowlist из вашего домена и localhost с портом для разработки. Проверьте три сценария локально: своя страница проходит, страница с другого порта получает 403, а клиент без Origin ведёт себя согласно выбранной политике. Затем напишите в лог отклонённые рукопожатия с их Origin — через неделю посмотрите, кто стучался.
Личные заметкиПусто
Вопросы и ответы по уроку
Origin проверять обязательно, если авторизация по токену в query?
Формально нет: злоумышленник не знает токен, и его соединение отклонит проверка авторизации. Но allowlist стоит копейки, защищает будущие cookie-сценарии и делает политику видимой в коде. Исключение, когда можно обойтись дефолтом, — один домен за одним прокси и никакого cookie.
Как пустить соединения с нескольких доменов?
Перечислите их хосты в карте allowlist, включая порты. Для множества поддоменов добавьте матчинг по суффиксу, но держите его отдельной функцией с тестами — префиксный сравнение строк на живых доменах регулярно пропускает chat.evil-example.com вместо chat.example.com.
Почему браузер сам не блокирует кросс-доменные websocket?
Потому что соединение — двусторонний канал к серверу, а не чтение ответа чужого сайта: модель безопасности у него другая, и браузер ограничивается передачей Origin. Решение о допуске принимает сервер, поэтому функция CheckOrigin существует и вызывается на каждом рукопожатии.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами