Начнём с неприятного факта о протоколе. Заголовок каждого кадра WebSocket несёт длину полезной нагрузки, и по спецификации она кодируется числом до шестидесяти четырёх бит — протокол честно допускает кадры на эксабайты, вопрос только в воле отправителя. ReadMessage верит заголовку: чтобы собрать сообщение, библиотеке нужно выделить буфер объявленного размера, и злонамеренный клиент способен попросить гигабайт одной строкой. Сервер без лимита — это сервис, который меняет память на чужие пакеты, причём на каждый кадр, а не один раз: отправитель может повторять запрос вечно. Защита websocket от этой версии DoS помещается в одну строку — потолок на размер сообщения, после которого соединение закрывается. Здесь важна системность: лимит ставится в readPump до цикла чтения, а не в обработчиках протокола, потому что кадр нужно обрезать до того, как он стал сообщением.
Websocket SetReadLimit: один вызов до цикла чтения#
Метод SetReadLimit задаёт максимальный размер сообщения в байтах и вызывается один раз на соединение, до входа в цикл чтения — мы уже видели его в скелете readPump девятого урока, а теперь разберём по-настоящему. Websocket SetReadLimit считается на сообщение, а не на кадр: если клиент шлёт длинное сообщение, разбитое на продолжения, библиотека соберёт его целиком и только потом сверит сумму с лимитом. Превышение — фатально для соединения: gorilla отправляет клиенту close-кадр с кодом 1009 Message Too Big и возвращает ошибку, а все последующие чтения вернут ту же ошибку, потому что соединение уже мертво. ReadPump после этого должен выйти — продолжать читать бессмысленно, состояние соединения не восстановится. Служебные кадры под лимит не попадают: у ping, pong и close нагрузка ограничена самой спецификацией в сто двадцать пять байт, и heartbeat не отвалится из-за сурового лимита на сообщения.
readPump с лимитом и честным логом
GOconst maxMessageSize = 4096
func (c *Client) readPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
log.Println("read:", c.userID, err)
break
}
c.hub.broadcast <- message
}
}SetReadLimit стоит перед циклом — это принципиально: порог должен работать с первого кадра. При ошибке чтения логируем вместе с userID: попытка превысить лимит — событие, которое стоит видеть в журнале, прежде чем отключённый вернётся с новым соединением. Break и defer завершают жизнь клиента штатно — через unregister и close(send).
Полезно проговорить, что именно рвётся при превышении, потому что здесь путаются даже опытные разработчики. Лимит применяется к целому сообщению: если клиент послал кадр на две тысячи байт, а затем продолжение ещё на две тысячи, при лимите в четыре тысячи девяносто шесть сумма проходит, а при лимите в две тысячи — соединение рвётся на втором кадре. Ошибка, которую вернёт ReadMessage, не даёт продолжить чтение: gorilla уже отправила close 1009, и протокольный диалог завершён. Проверять после этого pong-обработчик или дедлайны бессмысленно — соединение закрыто на уровне протокола. Именно поэтому обработка ошибки в цикле не пытается различить причину: любая ошибка чтения означает конец соединения, а разница между истёкшим дедлайном и превышением лимита видна в логе, а не в коде. Такой подход проще, и он никогда не ошибается в классификации.
Буфер чтения против лимита сообщения#
Рядом с SetReadLimit в Upgrader живёт ReadBufferSize, и их регулярно путают, хотя это настройки из разных миров. Буфер — оптимизация: сколько памяти gorilla держит под чтение кадра, переиспользуя её между сообщениями; больше буфер — меньше системных вызовов на больших нагрузках, но не больше безопасности. Лимит — граница: то, что нельзя превышать ни при каком буфере. Сообщение в два мегабайта пройдёт через буфер в четыре килобайта — библиотека соберёт его из частей, потому что лимит не задан или велик. И наоборот: маленький буфер не спасёт от гигантского сообщения, если SetReadLimit не поставлен. Правило простое: за безопасность отвечает лимит, за скорость — буфер, и путать их нельзя. Практика: буфер оставляют дефолтным, а лимит выводят из протокола и требований продукта. Если после этой фразы вы пошли проверять, что стоит у вас, — урок сделал своё дело.
Если SetReadLimit не вызван, лимит равен нулю, а ноль в gorilla означает «ограничения нет»: ReadMessage честно выделит память под объявленную длину. Скопированный из старого туториала readPump без этой строки превращает сервер в бесплатный генератор аллокаций. Проверьте все места, где создаётся conn: лимит ставится на каждое соединение, а не на первое попавшееся.
Кейс: как выбрать порог#
Классический пример gorilla ставит пятьсот двенадцать байт — для эха достаточно, для реального протокола это отправная точка, а не ответ. Порог выбирается от данных: измерьте самые большие легитимные сообщения вашего протокола, добавьте запас на рост формата и округлите до понятной границы. Для чат-конверта из десятого урока с текстом на несколько абзацев четыре килобайта — щедро; для кооп-редактора с правками и метаданными документа нужны десятки килобайт; для передачи файлов websocket вообще не тот транспорт, и загрузка идёт по HTTP с собственными лимитами. Второй вопрос — что делать с нарушителями: при жёстком лимите соединение рвётся, и честный клиент с ошибкой в коде теряет сессию целиком. Если ваши клиенты ещё развиваются, логируйте нарушение и отключение: по количеству записей видно, что протокол разъехался с реальностью. Отдельно следите за пиковыми днями: порог, выбранный по средним данным, ломается о всплеск.
Мягкий лимит: NextReader и LimitReader
GOconst softLimit = 64 * 1024
func (c *Client) readLoop() {
for {
_, reader, err := c.conn.NextReader()
if err != nil {
break
}
payload, err := io.ReadAll(io.LimitReader(reader, softLimit+1))
if err != nil {
break
}
if len(payload) > softLimit {
c.send <- &Envelope{Type: "error", Body: "message too large"}
continue
}
c.hub.broadcast <- payload
}
}NextReader отдаёт reader на очередное сообщение, LimitReader режет его на softLimit плюс один байт — лишний байт служит детектором превышения. Соединение не рвётся: клиент получает ошибку протоколом и может послать корректное сообщение дальше. Такой вариант стоит выбрать, когда сообщения близки к порогу и рвать сессию из-за одного крупного кадра нельзя.
Жёсткий лимит против мягкого#
| Критерий | SetReadLimit | NextReader + LimitReader |
|---|---|---|
| Что происходит при превышении | close 1009, соединение умирает | ошибка протоколом, соединение живо |
| Где живёт | один вызов до цикла | проверка в теле цикла |
| Память под гигантский кадр | не выделяется | читается ровно порог плюс один байт |
| Когда выбирать | протокол устоялся | клиенты в развитии, размер плавает |
Спорное мнение: лимит кадра — не защита от DoS#
Мне часто встречается настрой, при котором SetReadLimit ставят и успокаиваются, но он закрывает ровно одну дыру из нескольких. Атакующего не остановит порог в четыре килобайта, если он шлёт тысячи таких кадров в секунду — это уже вопрос rate limit, отдельного механизма с окнами и запретами. Молчаливый клиент, который просто держит соединение и не читает, выедает буферы — его отключают дедлайны и heartbeat шестого урока. Исходящий спам через broadcast отсекает select с default из восьмого урока, отключая неплатёжеспособных получателей. Иными словами, защита websocket — это связка: лимит на размер, лимит на частоту, дедлайны на тишину и неблокирующая доставка. Каждый механизм стоит строки-две, но работает только в комплекте, как замки на разных дверях. Мой порог зрелости сервиса: когда про DoS спрашивают не «какой лимит поставить», а «какие четыре лимита у нас работают» — значит, тема осознана.
Проверка: что видит нарушитель
Клиент отправил сообщение крупнее SetReadLimit. Что он получит?
Проверка: кадр или сообщение
На что распространяется SetReadLimit?
Чтение после превышения
Прочитайте код и выберите, что он выведет:
c.conn.SetReadLimit(64)
// клиент отправил текстовое сообщение на 100 байт
_, _, err := c.conn.ReadMessage() // err != nil
// вторая попытка:
_, _, err2 := c.conn.ReadMessage()Сервер без лимита
Прочитайте код и выберите, что он выведет:
// SetReadLimit не вызван вовсе
// злонамеренный клиент отправляет
// сообщение с объявленной длиной 500 МБ
_, message, err := conn.ReadMessage()У лимита есть клиентская сторона, о которой забывают, пока не увидят вечный цикл. Браузер получает close с кодом 1009 и честно сообщает его в onclose, и вот тут наивный reconnect из туториала становится врагом: клиент переподключается и тут же шлёт то же огромное сообщение — соединение рвётся, цикл повторяется до ручного останова. Правильная реакция: код 1009 — не сетевая проблема, а ошибка приложения, и переподключаться с тем же payload бессмысленно. Клиент обязан показать пользователю человеческое сообщение — «сообщение слишком большое» — и очистить очередь исходящих, а не хранить её до переподключения. Сетевые же обрывы — 1006 и прочие — наоборот, повод для reconnect с backoff из семнадцатого урока. Различение этих двух случаев на клиенте занимает пять строк и экономит нервные клетки: лог переполненного reconnect-цикла плохо читается в три ночи.
Запустите сервер с лимитом на локальном Go и пошлите из консоли браузера длинную строку: ws.send("a".repeat(10000)) при пороге в четыре килобайта. Соединение закроется с кодом 1009, а в логе сервера появится запись с userID — ровно то, что должно происходить.
Соберите настройки readPump
Впишите четыре пропуска: имя константы лимита, закрытие соединения в defer, значение лимита и источник дедлайна.
Соберите начало мягкого лимита
Расставьте строки чтения через reader: получение сообщения, обработка ошибки и проверка размера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: лимиты
Лимит — это настройка readPump, поэтому верните себя к полному насосу из девятого урока и проверьте, что строка SetReadLimit стоит там, где должна. Вторая половина защиты от тишины — дедлайны и heartbeat из шестого урока, а неблокирующая доставка при переполнении — из восьмого. Что происходит с соединениями, которые всё-таки умерли без ведома сервера, — тема восемнадцатого урока про чистку мёртвых клиентов. Для сравнения: те же лимиты тела запроса в HTTP выглядят как MaxBytesReader и middleware — в курсе про роутер chi это встроено в конвейер, и идея общая: обрезать вредителя до того, как он стал нагрузкой. Проверьте свой порог локально длинной строкой из браузера и посмотрите в лог. План курса — на странице курса WebSocket.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- SetReadLimit ставится один раз до цикла чтения: ноль означает «без лимита», и это уязвимость.
- Превышение — close 1009 Message Too Big: соединение умирает, все последующие чтения вернут ошибку.
- Лимит считается на целое сообщение из кадров и продолжений, а не на отдельный кадр.
- Служебные ping, pong и close не подчиняются лимиту: их нагрузка ограничена спецификацией.
- Мягкий лимит через NextReader и LimitReader отвечает ошибкой протоколом, не рвя соединение.
- Порог выбирается от реальных данных: измерьте максимум протокола и добавьте запас.
- SetReadLimit — один из четырёх механизмов: рядом rate limit, дедлайны и неблокирующая доставка.
Домашнее задание
Измерьте размер самых больших легитимных конвертов вашего протокола из десятого урока, выберите порог и поставьте SetReadLimit в readPump. Затем реализуйте мягкий вариант через NextReader с ответом error-конвертом и сравните поведение: жёсткий вариант должен рвать соединение с кодом 1009, мягкий — сохранять его. Заложите порог в конфиг, а не в константу кода, и опишите, как будете менять его без релиза.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему лимит рвёт соединение, а не просто отбрасывает сообщение?
Потому что протокол потоковый: библиотека уже приняла часть кадра, и игнорирование означало бы угадывание границ следующего сообщения. Разрыв с кодом 1009 — честный сигнал, после которого обе стороны начинают заново; для мягкого поведения есть вариант с NextReader.
Нужен ли отдельный лимит на исходящие?
Да, и он реализован архитектурно: select с default в рассылке не даёт хабу блокироваться, а буфер send ограничивает объём неотправленного. Сервер, который ретранслирует клиентские сообщения, дополнительно ограничивает их размер входящим лимитом — гигантский кадр не пройдёт дальше readPump.
Как лимит сочетается с батчингом из двадцатого урока?
Независимо: батчинг склеивает исходящие в один кадр на стороне писателя, а лимит контролирует входящие. Если вы когда-нибудь соберёте исходящий батч, следите, чтобы его размер уложился в лимиты промежуточных прокси — они тоже могут резать кадры по своему потолку.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
RabbitMQ в Go: очереди сообщений на amqp091-go
Интерактивный курс RabbitMQ в Go: обменники, очереди, ack, prefetch, dead letter и RPC. 25 уроков amqp091-go с примерами кода и итоговым проектом.
- Подключаться к RabbitMQ из Go и управлять connection и channel
- Объявлять очереди, обменники и привязки с нужными свойствами
- Публиковать и потреблять сообщения с ручными ack
- Настраивать prefetch и справедливое распределение между воркерами