JavaRush /Курси /Go SELF /Безпечне читання тіла запиту в Go: ліміт,

Безпечне читання тіла запиту в Go: ліміт, json.Decoder і закриття Body

Go SELF
Рівень 59 , Лекція 2
Відкрита

1. Чому читання r.Body — це «вхід від незнайомця»

Коли ви пишете HTTP-сервер, рано чи пізно це стається неминуче: клієнт надсилає вам дані в body і каже: «Тримайте, я хороший». Проблема в тому, що інтернет так влаштований, що «хороший» може виявитися і кривим, і величезним, і навіть трохи шкідливим. Тому читання тіла запиту — це не просто «прочитав JSON і пішов далі», а невеликий протокол безпеки.

З практичного погляду нас цікавлять три ризики. Перший ризик — розмір: клієнт може надіслати не { "title": "купити молоко" }, а 50 мегабайт тексту — випадково або навмисно. А ви героїчно спробуєте з’їсти все це оперативною пам’яттю. Другий ризик — невалідний формат: JSON може бути битий, несподіваного типу або містити сміття після нормального JSON. Третій ризик — неочікувані поля: клієнт надіслав { "titile": "..." } замість { "title": "..." }, а ви мовчки це проковтнули — і застосунок починає поводитися дивно, ніби «привид» керує вашим кодом.

Для орієнтиру ось як ми хочемо, щоб виглядав процес обробки тіла запиту:

flowchart TD
    A[HTTP-запит: заголовки + тіло] --> B[Обмежити розмір тіла]
    B --> C[json.Decoder: розібрати у структуру]
    C --> D[Перевірити: у тілі рівно один JSON]
    D --> E[Закрити тіло]
    E --> F[Далі: валідація полів / бізнес-логіка]

2. Інструменти для безпечного читання тіла запиту

Що таке r.Body і чому його не можна читати «як рядок»

Якщо ви раніше писали консольні програми, то введення зазвичай отримували через fmt.Scan або bufio.Scanner: ви читаєте дані, а потім працюєте зі змінними. У HTTP усе трохи інакше: тіло запиту — це потік байтів, який можна читати поступово. У Go це видно з типу: r.Body — це io.ReadCloser.

Важливо усвідомити дві речі.

По-перше, r.Body не є рядком. Він не зберігає весь текст наперед. Він схожий на кран: доки ви його відкрили, вода тече, а байти надходять. По-друге, r.Body не можна прочитати двічі. Якщо ви один раз прочитали потік до кінця, вдруге там уже нічого читати — у найкращому разі. Або ви отримаєте дивні ефекти, якщо змішаєте різні способи читання.

Іноді новачки намагаються зробити так — і воно навіть компілюється, тому це особливо небезпечно:

package main

import (
	"io"
	"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
	b, _ := io.ReadAll(r.Body) // читаємо ВСЕ (небезпечно)
	_ = b
}

Цей код небезпечний не тому, що io.ReadAll «поганий за паспортом». Він небезпечний тому, що без ліміту ви погоджуєтеся прочитати скільки завгодно даних. Якщо клієнт надішле дуже багато, ви спробуєте виділити дуже багато пам’яті. А пам’ять, як відомо, не безкінечна — особливо не на сервері, де, окрім вас, ще живуть інші запити.

http.MaxBytesReader: ставимо «турнікет» на вхід

Коли ви створюєте форму реєстрації на сайті, ви ж не лишаєте поле «імʼя» без обмеження на 500 мегабайт. З body та сама історія: сервер повинен уміти сказати «стоп, більше не приймаю».

У Go для цього є готовий інструмент: http.MaxBytesReader. Він «обгортає» r.Body так, щоб під час читання не можна було перевищити ліміт.

І виглядає це дуже просто:

package main

import "net/http"

func limitBody(w http.ResponseWriter, r *http.Request) {
	const maxBodySize = 1 << 20 // 1 MiB
	r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
}

Тут є два важливі питання, які зазвичай виникають одразу.

Перше: чому MaxBytesReader приймає w http.ResponseWriter? Тому що в разі перевищення ліміту серверу іноді потрібно вміти коректно сформувати відповідь, наприклад, про надто великий запит. Внутрішня реалізація спирається на контекст відповіді, тому w потрібен.

Друге: який ліміт обрати? Універсального «правильного» числа немає, але є здоровий глузд. Для створення задачі, коли є лише кілька полів, 1 MiB — це навіть щедро. Для завантаження аватарки — мало, але там зазвичай не JSON. Правило просте: ліміт має відповідати контракту ендпойнта.

Невелика шпаргалка-орієнтир — не закон, а підказка:

Сценарій Типовий body Відчутний ліміт
POST «створити задачу» (title + кілька прапорців) сотні байтів 64 KiB – 1 MiB
POST «імпорт JSON масиву» від кілобайтів до мегабайтів 1–10 MiB (і краще стримінг)
завантаження файлу десятки мегабайтів зазвичай не JSON, а інші техніки

json.Decoder: потоковий розбір JSON без io.ReadAll

Навіть якщо ви поставили ліміт на розмір тіла запиту, усе одно хочеться читати дані по-дорослому: не тягнути все в пам’ять одним величезним рядком, а розбирати потік. Для цього в Go є json.Decoder.

Ключова ідея: json.NewDecoder(r.Body) приймає io.Reader, тобто потік, і читає його поступово.

Мінімальний приклад декодування має такий вигляд:

package main

import (
	"encoding/json"
	"net/http"
)

type createTaskRequest struct {
	Title string `json:"title"`
}

func decodeExample(r *http.Request) error {
	var req createTaskRequest
	return json.NewDecoder(r.Body).Decode(&req)
}

Тут важливо: ми декодуємо у структуру, тобто одразу отримуємо типізовані дані. Це набагато зручніше, ніж тримати «map із чого завгодно» і потім вручну розбирати типи.

Ще один плюс json.Decoder: він добре лягає на ідею «не довіряй вхідним даним». Якщо JSON битий, Decode поверне помилку — і ви зможете відповісти клієнту, що запит некоректний. А якби ви спершу зробили ReadAll, а потім json.Unmarshal, ви все одно отримали б помилку, але вже після того, як прочитали весь текст повністю.

Строгий decode: DisallowUnknownFields() і правило «рівно один JSON»

Якщо ви бодай раз припускалися друкарської помилки в назві поля, ви знаєте, що таке біль у чистому вигляді. Найгірший варіант — коли сервер цю помилку мовчки ковтає.

За замовчуванням encoding/json поводиться доволі поблажливо: якщо в JSON прийшли поля, яких немає у вашій структурі, він їх проігнорує. Іноді це зручно, але для API зазвичай шкідливо: ви хочете, щоб клієнт одразу дізнався, що надіслав щось не те.

Для цього у json.Decoder є суворий режим:

package main

import (
	"encoding/json"
	"net/http"
)

func strictDecoder(r *http.Request) *json.Decoder {
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	return dec
}

Тепер якщо клієнт надішле { "titile": "..." }, ви не отримаєте «порожній Title і тишу», а отримаєте помилку: поле невідоме.

Друге правило, про яке рідко згадують на початку: у body має бути рівно один JSON-об’єкт. Інакше можлива дивна ситуація: клієнт надіслав нормальний JSON, потім пробіли, потім ще один JSON. Якщо ви зробите один Decode, він прочитає перший об’єкт, а хвіст залишиться непоміченим. Для API це майже завжди погана ідея: «пакет із двох JSON» — це, найімовірніше, помилка клієнта.

Перевірка проста: після першого Decode робимо ще один Decode і очікуємо io.EOF.

3. Допоміжна функція для читання JSON: ліміт + суворий режим + один об’єкт

Коли ви напишете 3–4 обробники, вам швидко набридне копіювати однаковий код: ставити ліміт, створювати decoder, увімкнути суворий режим, декодувати, перевіряти EOF. Тому логічно винести це в невелику функцію.

Спочатку заведемо константу ліміту, щоб не писати магічні числа по коду:

package main

const maxBodySize = 1 << 20 // 1 MiB

Далі — допоміжна функція, яка бере на себе всю «гігієну» читання:

package main

import (
	"encoding/json"
	"io"
	"net/http"
)

func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst any) error {
	r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
	defer r.Body.Close()

	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()

	if err := dec.Decode(dst); err != nil {
		return err
	}
	return ensureEOF(dec)
}

І маленька функція, яка перевіряє, що в body більше нічого немає:

package main

import (
	"encoding/json"
	"errors"
	"io"
)

func ensureEOF(dec *json.Decoder) error {
	if err := dec.Decode(&struct{}{}); err != io.EOF {
		return errors.New("тіло має містити лише одне JSON-значення")
	}
	return nil
}

Зверніть увагу на &struct{}{}. Це такий «порожній контейнер»: нам не важливо, що саме там, нам важливо лише побачити, що декодувати більше нічого. Якщо там є ще один JSON, Decode не поверне io.EOF, і ми вважаємо запит некоректним.

4. Закриття r.Body: навіщо, коли і чому defer доречний

На перших порах закриття body сприймається як дивний ритуал: «Я ж нічого не відкривав, чому я щось закриваю?» Це нормально — мозок ще живе у світі змінних, а HTTP живе у світі потоків і ресурсів.

Важливо зрозуміти ідею: r.Body — це ресурс, пов’язаний із мережевим з’єднанням і буферами. Коли ми завершуємо обробку запиту, ресурси мають бути коректно звільнені. На серверному боці net/http у багатьох випадках сам закриє тіло, але явний defer r.Body.Close() робить код передбачуванішим, особливо якщо ви замінили r.Body на обгортку (MaxBytesReader) і хочете гарантувати, що воно закриється за будь-якого виходу.

Ключовий практичний ефект: в обробниках часто трапляються ранні повернення через помилки. Якщо ви не використовуєте defer, вам доведеться пам’ятати: закрити тут, тут і ще ось тут. А defer робить саме те, що й має: закриває один раз під час виходу з функції, навіть якщо ви вийшли зсередини.

І так, defer тут доречний саме тому, що обробник зазвичай невеликий, а тіло ви читаєте рівно один раз. Це як пристебнути пасок безпеки: у 99 % випадків ви про нього не думаєте, але коли треба — добре, що він був.

5. Вбудовуємо читання JSON в обробник

Зараз ми додамо до нашого навчального HTTP-застосунку найпростіший вхідний ендпойнт. Нехай це буде створення задачі. Ми поки не будуємо повноцінного сховища і не обговорюємо складні відповіді — нам важливий саме безпечний прийом JSON.

Ось структура вхідного запиту:

package main

type createTaskRequest struct {
	Title string `json:"title"`
}

А ось обробник, який читає JSON через нашу допоміжну функцію:

package main

import "net/http"

func handleCreateTask(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	var req createTaskRequest
	if err := decodeJSONBody(w, r, &req); err != nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	w.WriteHeader(http.StatusCreated)
}

Зауважте важливу річ: обробник лишається лінійним і коротким. У ньому немає «танців» із ReadAll, немає ручного обмеження розміру, немає ручної перевірки хвоста JSON — усе це сховано в decodeJSONBody. Це дуже схоже на загальний Go-підхід: винести повторювану механіку в helper і залишити в обробнику читабельну бізнес-логіку.

До речі, такою самою ідеєю часто користуються, коли хочуть централізувати обробку помилок у HTTP-шарі: роблять тип-адаптер, який дає обробнику «повернути помилку», а ServeHTTP вирішує, що писати у відповідь. Цей підхід добре відомий і трапляється як у навчальних, так і в реальних прикладах.

6. Типові помилки під час читання тіла запиту

Помилка №1: читати тіло через io.ReadAll без ліміту.
Це найчастіша бомба уповільненої дії. У тестах усе добре, на локальній машині усе добре, а потім хтось випадково надсилає великий запит — і сервер починає їсти пам’ять, ніби він на шведському столі. Ліміт через http.MaxBytesReader — це базовий захист, який варто робити завжди.

Помилка №2: декодувати JSON і не перевіряти, що в body не залишився «хвіст».
Один Decode може успішно прочитати перший JSON і не помітити, що після нього ще щось є. Для API це часто означає, що ви приймаєте некоректний запит «частково», а потім налагоджуєте привидів. Другий виклик Decode з очікуванням io.EOF закриває цю діру.

Помилка №3: забути DisallowUnknownFields() і отримати «мовчазні друкарські помилки».
Клієнт надсилає поле з помилкою в назві, а сервер мовчки його ігнорує. У результаті поле виявляється порожнім, і ви починаєте підозрювати все: кодування, мережу, фазу Місяця і ретроградний Меркурій. Строгий decode перетворює це на зрозумілу помилку просто на межі входу.

Помилка №4: намагатися прочитати r.Body двічі.
Наприклад, спочатку «для логів» зробити ReadAll, а потім ще раз Decode. Потік уже прочитано, тож удруге ви отримаєте порожнечу або помилку. Якщо потрібно логувати сирий вхід, це роблять обережно й усвідомлено (зазвичай через io.TeeReader), але в базовому сервері краще не ускладнювати й читати рівно один раз.

Помилка №5: ставити defer r.Body.Close() десь «після Decode», а потім вийти раніше.
Якщо defer поставлено надто пізно, то за раннього return він не спрацює. Тому правило просте: щойно ви вирішили «я читаю тіло», одразу після встановлення ліміту ставте defer r.Body.Close() — і далі спокійно пишіть код.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ