Дайджесты новостей
Европейский комикс BD о бэкенд-разработчике на Go, обнаружившем скрытую ошибку превышения лимита тела HTTP-запроса.

Ограничение размера тела запроса в Go: почему io.LimitReader маскирует ошибки

Любой бэкенд-разработчик, проектирующий публичные 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 мегабайт:

  1. io.LimitReader добросовестно читает первый мегабайт и останавливается.
  2. Декодер JSON пытается прочитать продолжение объекта, натыкается на io.EOF прямо посреди структуры и возвращает банальную синтаксическую ошибку парсинга: unexpected EOF или syntax error.
  3. Твой обработчик перехватывает эту ошибку и отдает клиенту статус HTTP 400 Bad Request.

В итоге происходит двойной сбой логики:

  • Сервер не понимает, что произошла атака или отправлен слишком большой файл, и логирует ложные ошибки синтаксиса.
  • Легитимный клиент в ответ получает дезинформирующее сообщение «Неверный синтаксис JSON» вместо честного статуса HTTP 413 Payload Too Large.
  • Но хуже всего то, что если тело запроса было обрезано точно по границе валидного вложенного поля, парсер может успешно проглотить урезанный объект, и сервер запишет в базу данных покалеченную запись!

Правильный инструмент: net/http.MaxBytesReader

Чтобы решить эту проблему штатно, стандартная библиотека Go предоставляет специализированную функцию http.MaxBytesReader.

В отличие от примитивного io.LimitReader, обертка http.MaxBytesReader специально спроектирована для работы в связке с http.ResponseWriter:

  1. При превышении заданного лимита хотя бы на один байт последующий вызов Read() возвращает типизированную ошибку *http.MaxBytesError.
  2. Функция автоматически устанавливает внутренний флаг на сетевом соединении сервера, запрещая обработчику дальнейшее чтение сокета.
  3. Сервер корректно закрывает соединение или посылает заголовок 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 предсказуемым для внешних клиентов.