38. Edge cases API
Пограничные случаи сетевых API: защита от коварных сбоев и атак
«Программы должны писаться прежде всего для людей, которые будут их читать, и лишь во вторую очередь — для машин, которые будут их исполнять. Но когда машины исполняют их во враждебном мире, простота становится вашей единственной настоящей защитой».
— Свободная интерпретация принципа Bell Labs
Архитектура на грани фола: Когда "Happy Path" ведет в пропасть
На протяжении нашего погружения в проектирование API мы выстраивали правильную, элегантную и надежную архитектуру. Но в реальном production-мире пользователи, нестабильные мобильные сети и злоумышленники вовсе не спешат следовать спецификации OpenAPI.
Junior-разработчик пишет код, который работает, когда всё хорошо (Happy Path). Senior-разработчик проектирует системы, предполагая, что всё вокруг сломано: клиенты шлют мусор, сокеты обрываются в самую непредсказуемую миллисекунду, а сеть полна враждебных ботов.
Edge Cases (Граничные случаи) — это те самые 1% сценариев, которые вызывают 99% ночных инцидентов, аварийного срабатывания OOM Killer и деградации распределенных кластеров. В этой статье мы разберем классические сетевые уязвимости, скрытые ловушки сериализации и раскроем механизмы рантайма Go, позволяющие элегантно защитить продакшен.
1. Смерть от мегабайтов: Бесконечный Payload
Что произойдет с вашим сервисом, если недобросовестный клиент или атакующий бот отправит HTTP-запрос POST /upload, добросовестно укажет заголовок Content-Type: application/json, но в качестве тела запроса начнет непрерывно гнать файл размером 10 Гигабайт?
Антипаттерн (Junior Way)
body, err := io.ReadAll(r.Body)
// Или
err := json.NewDecoder(r.Body).Decode(&req)
В первом случае функция io.ReadAll попытается вычитать весь поток в оперативную память. Ваша горутина запросит у Garbage Collector 10 Гигабайт кучи (Heap). Сборщик мусора войдет в крутое пике, процесс мгновенно упадет с ошибкой OOM (Out of Memory), и Kubernetes его убьет.
Второй вариант ничуть не лучше: потоковый json.Decoder сделает то же самое при попытке распарсить колоссальную структуру, непрерывно аллоцируя узлы в куче.
Идиоматичный подход (Senior Way)
Главное правило безопасного бэкенда: ограничивать размер тела запроса ЕЩЕ ДО того, как начнете его читать. В Go для этого есть встроенный http.MaxBytesReader.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Req["Входящий HTTP-запрос (TCP Stream)"]:::entry --> Limit{"http.MaxBytesReader\n(Лимит: 1 MB)"}
Limit -->|"Размер <= 1 MB"| Decoder["json.NewDecoder & json.Decoder\nБизнес-логика"]
Decoder --> RespOK["200 OK"]
Limit -->|"Превышен лимит байт"| Trunc["Блокировка чтения & Закрытие TCP-сокета"]
Trunc --> Resp413["413 Payload Too Large\n(StatusRequestEntityTooLarge)"]
package handler
import (
"encoding/json"
"errors"
"net/http"
)
type MyRequest struct {
Query string `json:"query"`
}
func SafeHandler(w http.ResponseWriter, r *http.Request) {
// Ограничиваем размер тела до 1 Мегабайта (1 << 20)
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var req MyRequest
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
// Уникальная ошибка, если лимит превышен
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) {
http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge) // 413
return
}
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
2. Атака Медленного Лори (Slowloris)
Представьте, что клиент открывает TCP-соединение с вашим Go-сервером и начинает отправлять тело JSON со скоростью 1 байт в секунду.
В модели Goroutine-per-Connection ваш сервер выделит отдельную горутину под этот запрос. Горутина заблокируется на чтении (read). Если хакер откроет 100 000 таких соединений, у вас зависнет 100 000 горутин, исчерпав файловые дескрипторы ОС (RLIMIT_NOFILE). Сервер ляжет, хотя процессор и сеть будут почти свободны.
Чтобы защититься от «медленных» клиентов, стандартный http.Server требует жесткой настройки таймаутов на чтение, которые по умолчанию равны бесконечности!
// Идиоматичная настройка production сервера
srv := &http.Server{
Addr: ":8080",
Handler: myRouter,
// Максимальное время на чтение заголовков (защита от Slowloris на этапе Handshake)
ReadHeaderTimeout: 5 * time.Second,
// Максимальное время на чтение всего тела запроса
ReadTimeout: 15 * time.Second,
// Максимальное время ответа (включая бизнес-логику)
WriteTimeout: 30 * time.Second,
// Время удержания простаивающего TCP-соединения
IdleTimeout: 120 * time.Second,
}
3. Отмененные запросы (The Ghost Request)
Пользователь заходит на сложную страницу отчета, ваш бэкенд начинает собирать данные из PostgreSQL (это занимает 5 секунд). На 2-й секунде пользователю надоедает ждать, и он закрывает вкладку браузера (или мобильное приложение обрывает запрос).
TCP-соединение разорвано. Клиента больше нет. Но знает ли об этом ваш код в Go?
Если вы не используете context.Context корректно, ваша горутина будет честно «пыхтеть» оставшиеся 3 секунды, грузить процессор, парсить данные из базы данных, а затем попытается сделать w.Write() в мертвый сокет и просто выбросит результат в мусорку.
sequenceDiagram
autonumber
actor Client as Клиент (Браузер)
participant GoSrv as Go HTTP Server
participant Netpoll as Netpoller (epoll)
participant DB as База данных (PostgreSQL)
Client->>GoSrv: GET /heavy-report
GoSrv->>DB: SQL Query (pgx с r.Context())
Note over Client,GoSrv: Пользователь закрыл вкладку (TCP FIN/RST)
Client--xGoSrv: TCP Connection Closed
Netpoll-->>GoSrv: netpoller фиксирует EOF / hangup
Note over GoSrv: context.Cancel() активирует <-ctx.Done()
GoSrv-->>DB: pgx посылает сигнал отмены в сокет БД
Note over DB: PostgreSQL отменяет запрос и освобождает локи
Note over GoSrv: Сервер мгновенно завершает горутину
Правило: Вы обязаны передавать r.Context() во ВСЕ тяжелые функции, вызовы базы данных и сторонние HTTP-запросы. Драйверы (например, pgx для PostgreSQL или сгенерированные клиенты oapi-codegen) умеют слушать канал <-ctx.Done() и прерывать выполнение на своей стороне, сохраняя ресурсы всей системы.
4. Гонка Данных: The Lost Update Problem
В статье 13. Idempotency ключи мы обсуждали защиту от сетевых ретраев. Но есть другой класс проблем — конкурентное изменение (Race Condition) данных двумя разными пользователями.
- Администратор А запрашивает профиль пользователя (баланс = 100).
- Администратор Б запрашивает этот же профиль (баланс = 100).
- Админ А списывает 20 и отправляет
PUTс балансом 80. - Админ Б (не зная о действиях А) начисляет 50 и отправляет
PUTс балансом 150.
Баланс стал 150, хотя математически должен был стать 130 (100 - 20 + 50). Это классическая «Потерянная проблема обновления» (Lost Update).
Как решать это в HTTP API?
Оптимистичная блокировка через заголовки ETag / If-Match (см. 12. Caching HTTP).
sequenceDiagram
autonumber
actor AdminA as Администратор А
actor AdminB as Администратор Б
participant API as Go HTTP API
AdminA->>API: GET /users/1
API-->>AdminA: 200 OK (Balance=100, ETag: "v1")
AdminB->>API: GET /users/1
API-->>AdminB: 200 OK (Balance=100, ETag: "v1")
AdminA->>API: PUT /users/1 (Balance=80, If-Match: "v1")
Note over API: ETag совпал ("v1" == "v1").<br/>Обновляем: Balance=80, New ETag="v2"
API-->>AdminA: 200 OK (ETag: "v2")
AdminB->>API: PUT /users/1 (Balance=150, If-Match: "v1")
Note over API: If-Match ("v1") != Текущий ETag ("v2")!
API-->>AdminB: 412 Precondition Failed
Note over AdminB: UI показывает: "Данные были изменены другим пользователем. Обновите страницу."
- Сервер отдает профиль с заголовком:
ETag: "v1". - Админ А шлет запрос:
PUT /users/1, Заголовок:If-Match: "v1". Сервер обновляет баланс до 80 и меняет ETag на"v2". - Админ Б шлет запрос:
PUT /users/1, Заголовок:If-Match: "v1". Сервер видит, что версии не совпадают, и возвращает статус412 Precondition Failed. UI Админа Б показывает ошибку: «Данные были изменены другим пользователем. Обновите страницу».
5. Тьма кодировок: Скрытые убийцы баз данных
Многие разработчики считают, что если JSON валиден (прошел json.Unmarshal), то данные безопасны. Это опасное заблуждение.
6. Zalgo Text и лимиты длины (Emoji Attack)
Если в базе данных у вас стоит ограничение VARCHAR(50), вы проверяете длину строки в Go:
if len(req.Name) > 50 { return Error }
Но функция len(str) в Go возвращает количество байт, а не количество символов (рун)!
- Обычная английская буква
'A'весит 1 байт. - Русская буква
'Я'весит 2 байта. - Эмодзи «👨👩👧👦» (Семья) — это композитный символ (ZWJ sequence), который может весить до 25 байт!
Пользователь присылает имя из 3 семейных эмодзи. Для пользователя это 3 символа. Для Go это 75 байт. База данных отвергает строку (или обрезает её посередине составного символа, превращая в нечитаемый мусор).
7. Итог
- Доверяй, но ограничивай: Никогда не читайте входящий поток без обертки
http.MaxBytesReader, иначе вас убьют огромными Payload-ами. - Таймауты: Голый
&http.Server{}без прописанных таймаутов на чтение/запись — это зияющая дыра безопасности, уязвимая к Slowloris-атакам. - Отмена контекста: Приучите себя слушать
r.Context(). Если клиент ушел — немедленно прекращайте сжигать CPU и ресурсы базы данных. - Оптимистичная блокировка: Используйте
ETagиIf-Match, чтобы предотвратить гонку данных при конкурентных обновлениях ресурсов (Lost Update). - Санитизация: Помните о разнице между байтами, рунами и графемами в Go. Опасайтесь Null-байтов (
\x00), которые могут сломать ваш PostgreSQL.
В следующей лекции мы перейдем к финальному техническому рубежу — оптимизации производительности, минимизации аллокаций памяти и профилированию высоконагруженных сетевых API на Go: 39. API performance.