Любой бэкенд-разработчик, проектирующий публичные HTTP-эндпоинты, обязан защищать сервер от атак типа «отказ в обслуживании» (Denial of Service). Если твой обработчик слепо вызывает io.ReadAll(r.Body) или скармливает входящий поток декодеру json.NewDecoder(r.Body), злоумышленнику достаточно отправить HTTP POST запрос с телом размером в 5 гигабайт, чтобы сервер аллоцировал всю оперативную память и упал по Out of Memory (OOM).
Защита кажется очевидной: нужно ограничить максимальное количество байт, которое сервер согласен прочитать из тела запроса. Но именно на этом шаге тысячи разработчиков на Go совершают фундаментальную ошибку: они используют стандартную утилиту io.LimitReader.
На первый взгляд io.LimitReader идеально подходит для задачи. Но в промышленном продакшене эта наивная обертка превращается в мину замедленного действия.
Анатомия ошибки: почему io.LimitReader маскирует превышение лимита
Давай заглянем в реализацию io.LimitReader в стандартной библиотеке Go. Этот ридер устроен предельно просто: он оборачивает нижележащий io.Reader и считывает из него не более N байт. Как только лимит исчерпан, любой последующий вызов метода Read() возвращает 0, io.EOF.
Чувствуешь, где здесь собака зарыта?
С точки зрения декодера json.NewDecoder или функции io.ReadAll, возврат ошибки io.EOF означает нормальное, штатное окончание потока данных!
Представь, что клиент отправил JSON размером в 2 мегабайта при лимите в 1 мегабайт:
io.LimitReaderдобросовестно читает первый мегабайт и останавливается.- Декодер JSON пытается прочитать продолжение объекта, натыкается на
io.EOFпрямо посреди структуры и возвращает банальную синтаксическую ошибку парсинга:unexpected EOFилиsyntax error. - Твой обработчик перехватывает эту ошибку и отдает клиенту статус
HTTP 400 Bad Request.
В итоге происходит двойной сбой логики:
- Сервер не понимает, что произошла атака или отправлен слишком большой файл, и логирует ложные ошибки синтаксиса.
- Легитимный клиент в ответ получает дезинформирующее сообщение «Неверный синтаксис JSON» вместо честного статуса
HTTP 413 Payload Too Large. - Но хуже всего то, что если тело запроса было обрезано точно по границе валидного вложенного поля, парсер может успешно проглотить урезанный объект, и сервер запишет в базу данных покалеченную запись!
Правильный инструмент: net/http.MaxBytesReader
Чтобы решить эту проблему штатно, стандартная библиотека Go предоставляет специализированную функцию http.MaxBytesReader.
В отличие от примитивного io.LimitReader, обертка http.MaxBytesReader специально спроектирована для работы в связке с http.ResponseWriter:
- При превышении заданного лимита хотя бы на один байт последующий вызов
Read()возвращает типизированную ошибку*http.MaxBytesError. - Функция автоматически устанавливает внутренний флаг на сетевом соединении сервера, запрещая обработчику дальнейшее чтение сокета.
- Сервер корректно закрывает соединение или посылает заголовок
Connection: close, не позволяя клиенту бесконечно лить ненужные байты в канал.
Ниже приведен эталонный обработчик для безопасного приема JSON:
package main
import (
"encoding/json"
"errors"
"fmt"
"net/http"
)
type UserPayload struct {
Name string `json:"name"`
Email string `json:"email"`
}
func handleUserRegistration(w http.ResponseWriter, r *http.Request) {
// Жесткий лимит размера тела: не более 1 МБ
const maxUploadSize = 1024 * 1024
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
var payload UserPayload
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
if err := decoder.Decode(&payload); err != nil {
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
// Честное уведомление о превышении допустимого размера
http.Error(w, fmt.Sprintf("Тело запроса превышает допустимый лимит в %d байт", maxBytesErr.Limit), http.StatusRequestEntityTooLarge)
return
}
http.Error(w, fmt.Sprintf("Некорректный синтаксис JSON: %v", err), http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"success"}`))
}
Обрати внимание на проверку errors.As(err, &maxBytesErr): это позволяет точно отделить ошибку превышения квоты от синтаксического брака в JSON.
Создание универсального HTTP Middleware
Если в твоем сервисе десятки эндпоинтов, настраивать лимит в каждом обработчике вручную утомительно и чревато ошибками забывчивости. Намного надежнее вынести эту логику в глобальный HTTP Middleware:
package main
import (
"net/http"
)
// LimitBodySizeMiddleware ограничивает размер входящих тел запросов для всех маршрутов
func LimitBodySizeMiddleware(maxBytes int64) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Ограничиваем чтение только для методов с полезной нагрузкой
if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch {
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
}
next.ServeHTTP(w, r)
})
}
}
Ранее мы разбирали системные нюансы работы с памятью в статье проблема major page faults в Go: почему системный вызов mmap может замедлить программу, и контроль над сетевыми буферами — еще один фундаментальный кирпич в фундаменте стабильности.
Используй специализированные инструменты платформы по назначению: замена одной строчки кода избавит твой сервис от скрытых фантомных ошибок и сделает API предсказуемым для внешних клиентов.
