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)

Go
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)"]
  
Go
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 требует жесткой настройки таймаутов на чтение, которые по умолчанию равны бесконечности!

Go
// Идиоматичная настройка 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) данных двумя разными пользователями.

  1. Администратор А запрашивает профиль пользователя (баланс = 100).
  2. Администратор Б запрашивает этот же профиль (баланс = 100).
  3. Админ А списывает 20 и отправляет PUT с балансом 80.
  4. Админ Б (не зная о действиях А) начисляет 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:

Go
if len(req.Name) > 50 { return Error }

Но функция len(str) в Go возвращает количество байт, а не количество символов (рун)!

  • Обычная английская буква 'A' весит 1 байт.
  • Русская буква 'Я' весит 2 байта.
  • Эмодзи «👨‍👩‍👧‍👦» (Семья) — это композитный символ (ZWJ sequence), который может весить до 25 байт!

Пользователь присылает имя из 3 семейных эмодзи. Для пользователя это 3 символа. Для Go это 75 байт. База данных отвергает строку (или обрезает её посередине составного символа, превращая в нечитаемый мусор).


7. Итог

  1. Доверяй, но ограничивай: Никогда не читайте входящий поток без обертки http.MaxBytesReader, иначе вас убьют огромными Payload-ами.
  2. Таймауты: Голый &http.Server{} без прописанных таймаутов на чтение/запись — это зияющая дыра безопасности, уязвимая к Slowloris-атакам.
  3. Отмена контекста: Приучите себя слушать r.Context(). Если клиент ушел — немедленно прекращайте сжигать CPU и ресурсы базы данных.
  4. Оптимистичная блокировка: Используйте ETag и If-Match, чтобы предотвратить гонку данных при конкурентных обновлениях ресурсов (Lost Update).
  5. Санитизация: Помните о разнице между байтами, рунами и графемами в Go. Опасайтесь Null-байтов (\x00), которые могут сломать ваш PostgreSQL.

В следующей лекции мы перейдем к финальному техническому рубежу — оптимизации производительности, минимизации аллокаций памяти и профилированию высоконагруженных сетевых API на Go: 39. API performance.