---
title: "Origin и междоменные соединения"
description: "Websocket Origin на Go: проверка r.Header.Get(\"Origin\") в Upgrader.CheckOrigin, атака cross-site hijacking и allowlist доменов. Код и разбор дефолтов."
course: "WebSocket в Go: real-time чаты и уведомления"
url: "https://golangmaster.ru/courses/websocket/lessons/15-origins/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

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

*Курс «WebSocket в Go: real-time чаты и уведомления», урок 15 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/websocket/lessons/15-origins/*

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

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

> **CORS-заголовки не защищают websocket.** 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

```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 для 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 по умолчанию, если его не задавать?

- Пропускает любой Origin — проверку надо включать вручную
- ✅ Пускает запросы без заголовка Origin и с хостом из Origin, равным хосту запроса; чужие домены получают 403
- Всегда возвращает 403, если Origin не в конфиге
- Проверяет только схему wss

Дефолтная сверка в gorilla есть: пустой Origin или совпадение хоста источника с хостом запроса — пропуск, остальное — 403. Опасность начинается, когда разработчик переопределяет проверку на return true, копируя туториал.

### Проверка: что приедет с чужой страницы

**Вопрос:** Страница evil.com открывает new WebSocket на bank.example, пользователь залогинен в bank.example по cookie. Что браузер приложит к рукопожатию?

- Ничего: кросс-доменные websocket без cookie
- ✅ Cookie домена bank.example — они едут к своему домену автоматически
- Cookie домена evil.com
- Только заголовок Origin и больше ничего

Соединение идёт к bank.example, поэтому браузер прикладывает cookie этого домена — в этом и состоит cross-site hijacking. Заголовок Origin приедет тоже, но именно cookie делают атаку работоспособной: сервер видит залогиненного пользователя.

### Рукопожатие с чужого домена

```go
// Upgrader без CheckOrigin — дефолтное поведение
// сервер слушает на chat.example.com
// страница на evil.com выполняет:
// new WebSocket("wss://chat.example.com/ws")
```

**Что выведет программа?**

- Рукопожатие пройдёт: Origin совпадает с хостом сервера
- ✅ Рукопожатие отклонено: gorilla вернёт 403, потому что host из Origin чужой
- Соединение установится, но кадры ходить не будут
- Браузер заблокирует запрос до отправки

Дефолтная сверка сравнивает host из заголовка с хостом запроса: evil.com против chat.example.com — не совпало, сервер ответит 403, и браузер рапортует ошибку соединения. Тот же код с return true в CheckOrigin пропустил бы соединение молча.

> **Проверьте 403 локально.** Запустите сервер на локальном Go с allowlist из урока и откройте страницу с другого порта — например, статичный html на :8080 против сервера на :4000. new WebSocket получит отказ, а в логе рукопожатие будет отклонено. Уберите пустую ветку из CheckOrigin и увидите, как соединения без Origin начнут проходить.

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

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

```go
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.___]
        },
}
```

Подсказка: Заголовок читается Header.Get, пустой источник — отказ, адрес разбирает url.Parse, а сравнивается host с портом из поля Host.

Правильные ответы в пропусках: `Get`, `false`, `Parse`, `Host`

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

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

Правильный порядок строк:

1. `CheckOrigin: func(r *http.Request) bool {`
2. `origin := r.Header.Get("Origin")`
3. `if origin == "" {`
4. `return false`
5. `}`
6. `u, err := url.Parse(origin)`

### Карточки: Origin

- **Origin** — Заголовок рукопожатия со схемой, доменом и портом страницы, открывшей соединение. Ставится браузером и не подделывается из скрипта на странице.
- **CSWSH** — Cross-site websocket hijacking: чужая страница открывает socket к вашему серверу, cookie едут автоматически, соединение работает от имени пользователя.
- **Дефолтный CheckOrigin** — Gorilla пускает пустой Origin и совпадающие хосты, чужие домены получают 403. Переопределение на return true отключает защиту руками.
- **Allowlist** — Карта разрешённых хостов и сравнение с u.Host после url.Parse. Пустой Origin — отдельная ветка политики для не-браузерных клиентов.
- **CORS против ws** — Access-Control-Allow-Origin не касается websocket: проверка источника живёт только в CheckOrigin.

Origin закрывает вопрос «откуда пришло рукопожатие», и в связке с авторизацией из [тринадцатого урока](/courses/websocket/lessons/13-avtorizaciya-ws/) периметр соединения становится законченным: токен отвечает на вопрос «кто ты», allowlist — на вопрос «откуда ты», лимиты из [четырнадцатого](/courses/websocket/lessons/14-limity/) — на вопрос «как себя ведёшь». Дальше по курсу соединения живут дольше и сложнее: в [шестнадцатом уроке](/courses/websocket/lessons/16-graceful-ws/) мы научимся прощаться с ними при выключении сервера, а в [семнадцатом](/courses/websocket/lessons/17-reconnect/) — возвращать клиентов после обрывов. Настройку заголовков и CORS-мидлварей для обычных HTTP-эндпоинтов, которые живут рядом с ws, разбирал курс про [роутер chi](/courses/chi/). Проверьте свой Upgrader прямо сейчас: если где-то в коде написано return true — вы знаете, что делать. План остальных уроков — на странице [курса WebSocket](/courses/websocket/).

## Коротко: что запомнить

- Рукопожатие 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 существует и вызывается на каждом рукопожатии.
