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

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

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

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

Лимиты: SetReadLimit и DoS

SetReadLimit — один вызов перед циклом чтения, который отличает websocket-сервер от сервиса, готового съесть гигабайт памяти из-за одного кадра: без лимита протокол разрешает клиенту объявить сообщение любого размера. В этом уроке разберём, что происходит при превышении, как выбрать порог и почему один SetReadLimit — ещё не вся защита websocket от DoS.

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

Начнём с неприятного факта о протоколе. Заголовок каждого кадра WebSocket несёт длину полезной нагрузки, и по спецификации она кодируется числом до шестидесяти четырёх бит — протокол честно допускает кадры на эксабайты, вопрос только в воле отправителя. ReadMessage верит заголовку: чтобы собрать сообщение, библиотеке нужно выделить буфер объявленного размера, и злонамеренный клиент способен попросить гигабайт одной строкой. Сервер без лимита — это сервис, который меняет память на чужие пакеты, причём на каждый кадр, а не один раз: отправитель может повторять запрос вечно. Защита websocket от этой версии DoS помещается в одну строку — потолок на размер сообщения, после которого соединение закрывается. Здесь важна системность: лимит ставится в readPump до цикла чтения, а не в обработчиках протокола, потому что кадр нужно обрезать до того, как он стал сообщением.

Websocket SetReadLimit: один вызов до цикла чтения#

Метод SetReadLimit задаёт максимальный размер сообщения в байтах и вызывается один раз на соединение, до входа в цикл чтения — мы уже видели его в скелете readPump девятого урока, а теперь разберём по-настоящему. Websocket SetReadLimit считается на сообщение, а не на кадр: если клиент шлёт длинное сообщение, разбитое на продолжения, библиотека соберёт его целиком и только потом сверит сумму с лимитом. Превышение — фатально для соединения: gorilla отправляет клиенту close-кадр с кодом 1009 Message Too Big и возвращает ошибку, а все последующие чтения вернут ту же ошибку, потому что соединение уже мертво. ReadPump после этого должен выйти — продолжать читать бессмысленно, состояние соединения не восстановится. Служебные кадры под лимит не попадают: у ping, pong и close нагрузка ограничена самой спецификацией в сто двадцать пять байт, и heartbeat не отвалится из-за сурового лимита на сообщения.

readPump с лимитом и честным логом

GO
const 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 не поставлен. Правило простое: за безопасность отвечает лимит, за скорость — буфер, и путать их нельзя. Практика: буфер оставляют дефолтным, а лимит выводят из протокола и требований продукта. Если после этой фразы вы пошли проверять, что стоит у вас, — урок сделал своё дело.

Кейс: как выбрать порог#

Классический пример gorilla ставит пятьсот двенадцать байт — для эха достаточно, для реального протокола это отправная точка, а не ответ. Порог выбирается от данных: измерьте самые большие легитимные сообщения вашего протокола, добавьте запас на рост формата и округлите до понятной границы. Для чат-конверта из десятого урока с текстом на несколько абзацев четыре килобайта — щедро; для кооп-редактора с правками и метаданными документа нужны десятки килобайт; для передачи файлов websocket вообще не тот транспорт, и загрузка идёт по HTTP с собственными лимитами. Второй вопрос — что делать с нарушителями: при жёстком лимите соединение рвётся, и честный клиент с ошибкой в коде теряет сессию целиком. Если ваши клиенты ещё развиваются, логируйте нарушение и отключение: по количеству записей видно, что протокол разъехался с реальностью. Отдельно следите за пиковыми днями: порог, выбранный по средним данным, ломается о всплеск.

Мягкий лимит: NextReader и LimitReader

GO
const 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 плюс один байт — лишний байт служит детектором превышения. Соединение не рвётся: клиент получает ошибку протоколом и может послать корректное сообщение дальше. Такой вариант стоит выбрать, когда сообщения близки к порогу и рвать сессию из-за одного крупного кадра нельзя.

Жёсткий лимит против мягкого#

КритерийSetReadLimitNextReader + 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-цикла плохо читается в три ночи.

Соберите настройки readPump

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

const ( writeWait = 10 * time.Second pongWait = 60 * time.Second = 4096 // потолок сообщения в байтах ) func (c *Client) readPump() { defer func() { c.hub.unregister <- c c.conn.() }() c.conn.SetReadLimit() c.conn.SetReadDeadline(time.Now().Add())

Соберите начало мягкого лимита

Расставьте строки чтения через reader: получение сообщения, обработка ошибки и проверка размера.

Строки

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

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

Карточки: лимиты

Термин

SetReadLimit

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

Потолок размера сообщения в байтах. Ставится один раз до цикла чтения; ноль означает «без лимита».

1 / 5

Лимит — это настройка 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, мягкий — сохранять его. Заложите порог в конфиг, а не в константу кода, и опишите, как будете менять его без релиза.

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

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

Почему лимит рвёт соединение, а не просто отбрасывает сообщение?

Потому что протокол потоковый: библиотека уже приняла часть кадра, и игнорирование означало бы угадывание границ следующего сообщения. Разрыв с кодом 1009 — честный сигнал, после которого обе стороны начинают заново; для мягкого поведения есть вариант с NextReader.

Нужен ли отдельный лимит на исходящие?

Да, и он реализован архитектурно: select с default в рассылке не даёт хабу блокироваться, а буфер send ограничивает объём неотправленного. Сервер, который ретранслирует клиентские сообщения, дополнительно ограничивает их размер входящим лимитом — гигантский кадр не пройдёт дальше readPump.

Как лимит сочетается с батчингом из двадцатого урока?

Независимо: батчинг склеивает исходящие в один кадр на стороне писателя, а лимит контролирует входящие. Если вы когда-нибудь соберёте исходящий батч, следите, чтобы его размер уложился в лимиты промежуточных прокси — они тоже могут резать кадры по своему потолку.

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

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

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

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

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

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

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