Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 15 из 25 ~16 мин +20 XP

Origin и междоменные соединения

Проверка Origin — недостающая половина защиты websocket: браузер честно сообщает в заголовке, с какой страницы открыто соединение, а сервер на Go решает в Upgrader.CheckOrigin, пускать ли его. В этом уроке разберём, почему CORS не защищает websocket, как работает атака cross-site hijacking и почему return true из туториалов стоит вырезать.

Содержание урока · 8 разделов

Ваш чат заработал с токенами, лимитами и 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

GO
var 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: чужая страница не сможет открыть соединение, потому что её источник не в списке разрешённых.

Allowlist доменов в CheckOrigin#

Рабочая проверка — это allowlist: список доменов, с которых соединение разрешено, и сравнение хоста из Origin с этим списком. Схему и порт удобно не сравнивать вручную, а доверить url.Parse: она разберёт заголовок на части, и вы сравните поле Host — домен вместе с портом. Пустой Origin — отдельная ветка: его присылают небраузерные клиенты вроде wscat и мобильных SDK, и решение пускает их или нет — это политика вашего API, а не протокола. Строгий вариант требует, чтобы даже они шли с авторизацией из тринадцатого урока, и тогда пустой Origin можно отклонять без сожалений. Дефолтную сверку gorilla полезно знать, чтобы понимать, от чего вы отказываетесь, переопределяя CheckOrigin: она сравнивает host источника с хостом запроса, а вы заменяете её явным списком — поведение становится читаемым в коде, а не спрятанным в библиотеке.

CheckOrigin с allowlist

GO
var 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 для fetchCheckOrigin для 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")

Соберите allowlist-проверку

Впишите четыре пропуска: чтение заголовка, ответ на пустой Origin, разбор адреса и поле для сравнения с списком.

var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { origin := r.Header.("Origin") if origin == "" { return } u, err := url.(origin) if err != nil { return false } return allowedOrigins[u.] }, }

Соберите начало CheckOrigin

Расставьте строки проверки в правильном порядке: чтение заголовка, отказ на пустой Origin и разбор адреса.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: Origin

Термин

Origin

нажмите, чтобы перевернуть
Определение

Заголовок рукопожатия со схемой, доменом и портом страницы, открывшей соединение. Ставится браузером и не подделывается из скрипта на странице.

1 / 5

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 — через неделю посмотрите, кто стучался.

Личные заметкиПусто
Хранятся только в вашем браузере (WebSocket · Origin и междоменные соединения)

Вопросы и ответы по уроку

Origin проверять обязательно, если авторизация по токену в query?

Формально нет: злоумышленник не знает токен, и его соединение отклонит проверка авторизации. Но allowlist стоит копейки, защищает будущие cookie-сценарии и делает политику видимой в коде. Исключение, когда можно обойтись дефолтом, — один домен за одним прокси и никакого cookie.

Как пустить соединения с нескольких доменов?

Перечислите их хосты в карте allowlist, включая порты. Для множества поддоменов добавьте матчинг по суффиксу, но держите его отдельной функцией с тестами — префиксный сравнение строк на живых доменах регулярно пропускает chat.evil-example.com вместо chat.example.com.

Почему браузер сам не блокирует кросс-доменные websocket?

Потому что соединение — двусторонний канал к серверу, а не чтение ответа чужого сайта: модель безопасности у него другая, и браузер ограничивается передачей Origin. Решение о допуске принимает сервер, поэтому функция CheckOrigin существует и вызывается на каждом рукопожатии.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

RabbitMQ25 уроков~11 чпрактика25 уроков уже доступны

RabbitMQ в Go: очереди сообщений на amqp091-go

Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.

  • Подключаться к RabbitMQ из Go и управлять connection и channel
  • Объявлять очереди, обменники и привязки с нужными свойствами
  • Публиковать и потреблять сообщения с ручными ack
  • Настраивать prefetch и справедливое распределение между воркерами