Тема 10

Веб-сервисы на Go

Секция, где проверяют не знание синтаксиса, а инженерную зрелость: умеешь ли ты спроектировать контракт, который переживёт три релиза, и написать сервис, который не ляжет от медленного клиента, не потеряет запрос при выкатке и не отдаст наружу стек-трейс с именем таблицы. Всё это в Go делается почти целиком стандартной библиотекой — поэтому вопросы очень конкретные.

Что реально проверяют этой секцией

Три вещи. Первая — понимаешь ли ты HTTP как протокол, а не как «функция, которая возвращает JSON»: коды, идемпотентность (повторил запрос — состояние сервера не изменилось), кэшируемость, обратная совместимость. Вторая — знаешь ли ты net/http изнутри: горутина на соединение, четыре таймаута сервера, пул соединений клиента, graceful shutdown. Третья — есть ли у тебя привычка думать про злоумышленника: инъекции, CSRF, rate limiting, что не должно попасть в ответ. Кандидат уровня middle обязан закрывать первые две; третья отличает того, кто держал сервис в проде, от того, кто писал только хендлеры.

10.1Дизайн API

Что такое REST, здесь не спрашивают. Просят спроектировать API вслух: назвать ручки, коды, формат ошибки, пагинацию. Смотрят, есть ли у тебя в голове система или ты придумываешь на ходу.

Базовое правило: ресурс — существительное, действие — метод

URL именует ресурс (сущность или коллекцию), а действие над ним задаёт HTTP-метод. Стоит в пути появиться глаголу (/getPost, /createPost, /updatePostById), и получается RPC поверх HTTP: все механизмы протокола (кэширование, идемпотентные ретраи прокси, понятные коды) перестают работать.

# так делать не надо
POST /api/getPosts
POST /api/createPost
POST /api/deletePostById?id=7
GET  /api/posts/get_by_author_and_tag
# ресурсно
GET    /v1/posts
POST   /v1/posts
DELETE /v1/posts/7
GET    /v1/posts?author=3&tag=go

Коллекции пишут во множественном числе и в нижнем регистре (/posts, а не /Post). Вкладывают не глубже одного уровня: /posts/{id}/comments читается нормально, /authors/{a}/posts/{p}/comments/{c}/likes уже нет. Вложенный ресурс со своим идентификатором выносят на верхний уровень (/comments/{c}/likes). Слова в сегменте пути разделяют дефисом (/blog-posts), поля JSON называют в snake_case или camelCase, но одинаково по всему API.

CRUD API блога: полная таблица ручек

Эту таблицу и просят написать на доске. Метода и пути мало: проговаривай ещё код успеха, коды ошибок и что в теле. Как раз этого кандидату обычно не хватает.

Метод и путьЧто делаетУспехОшибкиТело запроса / ответа
GET /v1/postsСписок постов200400 (плохой курсор/сортировка)— / {items:[…], next_cursor, has_more}
POST /v1/postsСоздать пост201 + Location: /v1/posts/42400, 401, 403, 422, 429{title, body, tags, status} / созданный ресурс
GET /v1/posts/{id}Один пост200 + ETag404, 304 (если If-None-Match)— / ресурс
PUT /v1/posts/{id}Заменить целиком200 (или 204)404, 409, 412, 422полный ресурс / обновлённый ресурс
PATCH /v1/posts/{id}Частичное изменение200404, 409, 422только изменяемые поля / ресурс
DELETE /v1/posts/{id}Удалить204403, 404 (или тоже 204)— / —
POST /v1/posts/{id}/publishСмена состояния (не CRUD)200 / 202404, 409 (уже опубликован)— / ресурс со статусом
GET /v1/posts/{id}/commentsКомментарии поста200404— / страница комментариев
POST /v1/posts/{id}/commentsДобавить комментарий201 + Location401, 404, 422, 429{body, reply_to} / комментарий
DELETE /v1/comments/{id}Удалить комментарий204403, 404— / —
PUT /v1/posts/{id}/likes/meПоставить лайк204401, 404— / — (идемпотентно by design)
DELETE /v1/posts/{id}/likes/meСнять лайк204401— / —
Приёмы, которые сразу выдают опыт
  • /likes/me через PUT, а не POST /like. Лайк сводится к тому, существует ли ресурс «мой лайк на этом посте». PUT идемпотентен: два клика подряд не сломают счётчик, ретрай безопасен.
  • Действия, которые не ложатся на CRUD (publish, cancel, refund) оформляются как под-ресурс с POST. REST это не нарушает, приём давно устоялся (так делают Stripe и GitHub).
  • 201 обязательно с Location: так клиент узнаёт URL созданного ресурса и не вычисляет его по формуле.
  • 422 vs 400: 400 означает «я не смог разобрать запрос» (битый JSON, плохой тип), 422 — «разобрал, но данные не проходят бизнес-валидацию» (пустой title, тег длиннее 32 символов). По коду клиент понимает, чинить парсер или показывать форму.

Коды ответа: минимальный набор, который надо знать наизусть

КодКогдаЧто часто путают
200 OKУспех с теломНе отдавать 200 с {"error": …} внутри — это главный грех API
201 CreatedСоздан ресурсНужен Location
202 AcceptedПринято в обработку, результата ещё нетОтдавать вместе со ссылкой на статус-ресурс
204 No ContentУспех без телаТела быть не должно, иначе часть клиентов сломается
304 Not ModifiedОтвет на If-None-Match/If-Modified-SinceЭкономит трафик, нужен валидатор: ETag или Last-Modified
400 Bad RequestСинтаксис запроса неверенНе использовать как «универсальную ошибку»
401 UnauthorizedНе аутентифицирован: токена нет или он протух. Токен — строка-пропуск, которую клиент носит с собой и предъявляет вместо логина с паролемНазвание историческое: это про аутентификацию
403 ForbiddenАутентифицирован, но нет правИногда осознанно заменяют на 404, чтобы не раскрывать существование ресурса
404 Not FoundРесурса нетНе путать с 410 Gone — «был и удалён навсегда»
405 Method Not AllowedПуть есть, метод не тотОбязателен заголовок Allow. ServeMux с Go 1.22 отдаёт его сам
409 ConflictКонфликт состояния: дубль, устаревшая версияТипично для оптимистичных блокировок
412 Precondition FailedIf-Match не совпалОснова безопасного concurrent-update
413 Content Too LargeТело больше лимитаЕго пишет хендлер, получив от MaxBytesReader ошибку *http.MaxBytesError
415 Unsupported Media TypeНе тот Content-TypeПолезно как защита от CSRF-формы
422 Unprocessable ContentВалидация не прошлаЗдесь отдают список ошибок по полям
429 Too Many RequestsRate limitЖелателен Retry-After
500 / 502 / 503 / 504Наша ошибка / плохой апстрим / недоступен / таймаут апстримаВ теле — никаких подробностей, только trace id

Версионирование

Версия нужна ровно тогда, когда предстоит ломающее изменение. Всё, что можно добавить совместимо, версии не требует. Способов три, и у каждого своя цена.

СпособКак выглядитПлюсыМинусы
В пути (URI) GET /v1/posts Видно глазами в логах и метриках; тривиально роутить (/v1/* → сервис A, /v2/* → B); легко curl-ить; кэши различают версии из коробки Формально нарушает «один ресурс — один URI»: /v1/posts/7 и /v2/posts/7 — та же сущность; ссылки, сохранённые клиентом, привязаны к версии
В заголовке Accept: application/vnd.blog.v2+json
или X-API-Version: 2
URI стабилен; можно версионировать отдельные представления; «чистый» с точки зрения HTTP content negotiation Не видно в браузере и логах доступа; сложнее отлаживать; обязателен Vary, иначе кэш и CDN перепутают версии; клиенты забывают заголовок → нужен дефолт
В query GET /posts?version=2 Проще всего прикрутить к существующему API; удобно для быстрых экспериментов Мешается с фильтрами; легко потерять при копировании ссылки; ломает кэш, если CDN настроен игнорировать query-строку в ключе
Что отвечать

«По умолчанию кладу major-версию в путь: для публичного API это самый практичный вариант, его выбрали Stripe (частично) и Twilio. Заголовок оправдан, когда версий много и они мелкие — тогда это дата-версионирование вида Stripe-Version: 2024-06-20, привязанное к аккаунту клиента. Query-параметр в проде не использую: он смешивается с бизнес-фильтрами и ломает кэширование». Ещё проговори: версию меняет только major, minor-изменения её не получают и обязаны быть совместимыми.

Пагинация, фильтрация, сортировка

У любой ручки-коллекции должны быть лимит по умолчанию и жёсткий максимум. Без них первый же клиент напишет ?limit=1000000 и уронит сервис, причём по памяти, а не по сети: миллион строк надо сначала собрать в слайс, потом сериализовать.

const (
    defaultLimit = 20
    maxLimit     = 100
)

func parseLimit(q url.Values) (int, error) {
    raw := q.Get("limit")
    if raw == "" {
        return defaultLimit, nil
    }
    n, err := strconv.Atoi(raw)
    if err != nil || n < 1 {
        return 0, fmt.Errorf("limit: ожидается целое >= 1")
    }
    if n > maxLimit {
        n = maxLimit // молча зажимаем вместо 400, клиенту так удобнее
    }
    return n, nil
}
OFFSET / LIMIT LIMIT 20 OFFSET 10000 прочитано и выброшено: 10 000 строк отдано 20 Цена страницы растёт линейно с её номером: СУБД обязана материализовать всё, что пропускает. Вставка сверху сдвигает окно — одна и та же запись приходит на двух страницах подряд. Плюс: можно прыгнуть на страницу 57 и знать total. KEYSET / CURSOR WHERE (created_at,id) < (:cur) LIMIT 20 даже не читается 20 строк cursor = ключ последней строки Index seek прямо в точку — O(log n), цена не зависит от глубины страницы. Минус: нет «страницы 57» и нет total count. Правило выбора Админка с номерами страниц и мелкими таблицами — offset. Лента, экспорт, публичный API, всё, что скроллится бесконечно и растёт — keyset. Курсор отдавать непрозрачным (base64 от кортежа ключа), чтобы менять его формат.
Offset против курсора. Offset удобен людям, курсор — машинам. На глубоких страницах разница не в процентах, а в порядках: OFFSET 1000000 может выполняться секунды.
# фильтры и сортировка в плоских query-параметрах
GET /v1/posts?status=published&author_id=3&tag=go&created_after=2026-01-01
GET /v1/posts?sort=-created_at,title&limit=20&cursor=eyJ0IjoiMjAyNi0wOC0wMSIsImlkIjo0Mn0

# ответ с курсором
{
  "items": [ ... 20 постов ... ],
  "next_cursor": "eyJ0IjoiMjAyNi0wNy0zMSIsImlkIjozMX0",
  "has_more": true
}

Ссылку на следующую страницу почти всегда собирают из URL текущего запроса: скопировать всё, что прислал клиент, и заменить один параметр. И тут же ловят классическую ошибку: поле r.URL объявлено как указатель, а url.Values как map. Присвоишь такую переменную другой, и копия будет смотреть на те же данные, поэтому «поправил копию» на деле значит «поправил запрос, который сейчас обрабатывают остальные слои». Раньше глубокую копию писали руками, поле за полем, а в Go 1.27 появились готовые URL.Clone() и Values.Clone().

// Go 1.27
func nextPageURL(r *http.Request, cursor string) string {
    u := r.URL.Clone()          // глубокая копия: оригинальный r.URL не трогаем
    q := u.Query()              // Query() и так парсит заново, копия не нужна
    q.Set("cursor", cursor)
    u.RawQuery = q.Encode()
    return u.String()
}

// Values.Clone() нужен, когда параметры лежат в поле или в общей переменной
var base = url.Values{"status": {"published"}, "limit": {"20"}}

page := base.Clone()            // без Clone() строка ниже испортила бы сам base
page.Set("cursor", cursor)

Сортировку задают одним параметром sort со списком полей, минус означает DESC (?sort=-created_at,title). По безопасности правило жёсткое: имя поля никогда не подставляется в SQL напрямую, только через белый список. Иначе получишь SQL-инъекцию в ORDER BY, а плейсхолдеры туда не подставить.

var sortable = map[string]string{
    "created_at": "p.created_at",
    "title":      "p.title",
    "likes":      "p.likes_count",
}

func orderBy(sort string) (string, error) {
    if sort == "" {
        return "p.created_at DESC, p.id DESC", nil // всегда добиваем уникальным полем
    }
    var parts []string
    for _, f := range strings.Split(sort, ",") {
        dir := "ASC"
        if strings.HasPrefix(f, "-") {
            dir, f = "DESC", f[1:]
        }
        col, ok := sortable[f] // ORDER BY защищает только белый список
        if !ok {
            return "", fmt.Errorf("sort: неизвестное поле %q", f)
        }
        parts = append(parts, col+" "+dir)
    }
    return strings.Join(parts, ", ") + ", p.id DESC", nil
}
Пагинация без tie-breaker

ORDER BY created_at DESC LIMIT 20 OFFSET 20 при одинаковых created_at у нескольких строк даёт неопределённый порядок: СУБД вправе вернуть их в любой последовательности, и на разных страницах она может быть разной. Записи пропадают и задваиваются даже без всяких вставок. Лечится это уникальным полем в конце сортировки (, id DESC). Любимая добивка после вопроса про пагинацию.

Идемпотентность и Idempotency-Key

Идемпотентная операция при повторе даёт тот же результат, что и при однократном вызове. Речь об эффекте на сервере, тело ответа тут ни при чём: у DELETE второй вызов может вернуть 404 вместо 204, и метод всё равно идемпотентен, потому что состояние системы не изменилось.

МетодБезопасный (safe)ИдемпотентныйКомментарий
GET, HEAD, OPTIONSдадаНе меняют состояние вообще
PUTнетда«Сделай так, чтобы стало вот так» — сколько ни повторяй, результат один
DELETEнетдаПосле первого вызова ресурса нет, дальше ничего не меняется
POSTнетнетКаждый вызов создаёт новую сущность — отсюда двойные списания
PATCHнетзависит{"title":"x"} идемпотентен, {"op":"increment","by":1} — нет

Клиент не может отличить «запрос не дошёл» от «дошёл, но ответ потерялся». Платёж мог пройти и при таймауте на стороне клиента, и при разрыве TCP, и при перезапуске пода. Ретрай без защиты спишет деньги второй раз. Поэтому клиент генерирует уникальный ключ (UUIDv4) на бизнес-операцию, а не на HTTP-запрос, и посылает его в заголовке Idempotency-Key. Сервер хранит ключ вместе с ответом.

Клиент Платёжный API Стор ключей (PG/Redis) Банк / PSP POST /v1/payments Idempotency-Key: K INSERT (K, hash(body), in_progress) уникальный индекс по K — гонку выиграет один списание 1000 руб UPDATE K: done, response = 201 {...} 201 Created (ответ потерялся в сети) клиент не дождался ответа по таймауту и повторяет ровно тот же запрос POST /v1/payments Idempotency-Key: K SELECT K → done + сохранённый ответ тот же 201, к банку не ходили
Idempotency-Key. Ключ вставляется до похода в банк, поэтому, даже если процесс упадёт посередине, двойного списания не будет: второй запрос увидит запись in_progress и получит 409, а не повторный платёж.
CREATE TABLE idempotency_keys (
    key           text        NOT NULL,
    user_id       bigint      NOT NULL,
    endpoint      text        NOT NULL,
    request_hash  bytea       NOT NULL,   -- sha256 от тела: защита от «тот же ключ, другой запрос»
    state         text        NOT NULL,   -- in_progress | done
    status_code   int,
    response_body jsonb,
    created_at    timestamptz NOT NULL DEFAULT now(),
    expires_at    timestamptz NOT NULL,   -- TTL 24 часа, чистим фоновым джобом
    PRIMARY KEY (user_id, key)            -- ключ уникален в пределах клиента
);
CREATE INDEX ON idempotency_keys (expires_at);
func (h *Handler) CreatePayment(w http.ResponseWriter, r *http.Request) {
    key := r.Header.Get("Idempotency-Key")
    if key == "" {
        writeProblem(w, http.StatusBadRequest, "missing-idempotency-key",
            "Заголовок Idempotency-Key обязателен для этой операции")
        return
    }
    body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
    if err != nil { // MaxBytesReader сам 413 не пишет
        writeProblem(w, http.StatusRequestEntityTooLarge, "body-too-large", "Тело больше 1 МБ")
        return
    }
    sum := sha256.Sum256(body)

    // 1. Пытаемся занять ключ. Уникальный индекс делает это атомарным.
    rec, fresh, err := h.keys.Acquire(r.Context(), key, userID(r), r.URL.Path, sum[:])
    switch {
    case err != nil:
        writeProblem(w, 500, "internal", "")
        return
    case !fresh && !bytes.Equal(rec.RequestHash, sum[:]):
        // ключ тот же, а тело другое: клиент ошибся, это не ретрай
        writeProblem(w, http.StatusUnprocessableEntity, "idempotency-key-reuse",
            "Idempotency-Key уже использован с другим телом запроса")
        return
    case !fresh && rec.State == "done":
        // честный ретрай: отдаём сохранённый ответ, ничего не выполняя
        w.Header().Set("Idempotent-Replay", "true")
        w.WriteHeader(rec.StatusCode)
        w.Write(rec.ResponseBody)
        return
    case !fresh: // state == in_progress: параллельный дубль
        w.Header().Set("Retry-After", "1")
        writeProblem(w, http.StatusConflict, "request-in-progress",
            "Запрос с этим ключом ещё выполняется")
        return
    }

    // 2. Ключ наш, выполняем операцию.
    resp, code, err := h.charge(r.Context(), body)
    if err != nil {
        h.keys.Release(r.Context(), key) // освобождаем, чтобы ретрай мог попробовать снова
        writeProblem(w, 502, "psp-unavailable", "")
        return
    }

    // 3. Сохраняем ответ до отправки клиенту.
    _ = h.keys.Complete(r.Context(), key, code, resp)
    w.WriteHeader(code)
    w.Write(resp)
}
Глубже, чем спросят: где именно живёт атомарность

В идеале всё лежит в одной транзакции БД: и вставка в idempotency_keys, и списание с баланса. Тогда «выполнили, но не записали ключ» невозможно физически. Как только побочный эффект уходит во внешнюю систему (банк, брокер), транзакцию на неё не растянешь. Спасает только то, что ключ занимается раньше внешнего вызова, плюс свой идемпотентный ключ для PSP (merchant_order_id). Если PSP такого ключа не принимает, остаётся паттерн outbox: сначала запись в БД в одной транзакции, потом отдельный воркер дожимает вызов с ретраями. На собесе достаточно сказать: «ключ занимаем до эффекта, завершаем после, а внешний вызов делаем идемпотентным своим ключом».

Единый формат ошибок: RFC 7807

RFC 7807 (обновлён как RFC 9457) описывает media type application/problem+json, то есть стандартное тело ошибки. В ядре пять полей: type (URI класса проблемы), title (человекочитаемое короткое название, стабильное для типа), status (дублирует HTTP-код), detail (подробности этого случая), instance (URI конкретного вхождения). Свои поля добавлять можно, стандарт это разрешает.

// HTTP/1.1 422 Unprocessable Content
// Content-Type: application/problem+json
{
  "type":     "https://api.blog.ru/errors/validation-failed",
  "title":    "Validation failed",
  "status":   422,
  "detail":   "Поле title обязательно и не длиннее 200 символов",
  "instance": "/v1/posts",
  "trace_id": "01J8Z9K2R3Q4",
  "errors": [
    {"field": "title", "code": "required"},
    {"field": "tags",  "code": "too_many", "max": 10}
  ]
}
// Так нельзя: утечка внутренностей
func handle(w http.ResponseWriter, r *http.Request) {
    if err := svc.Do(r.Context()); err != nil {
        http.Error(w, err.Error(), 500)
        // pq: duplicate key value violates
        // unique constraint "users_email_key"
        // ...в ответе клиенту
    }
}
// Так надо: наружу код, внутрь детали
func handle(w http.ResponseWriter, r *http.Request) {
    if err := svc.Do(r.Context()); err != nil {
        tid := middleware.TraceID(r.Context())
        slog.ErrorContext(r.Context(), "create user",
            "err", err, "trace_id", tid)
        writeProblem(w, 500, "internal",
            "Внутренняя ошибка", tid)
    }
}
Что именно нельзя светить и почему
  • Текст ошибки СУБД выдаёт имена таблиц, колонок, констрейнтов, тип и версию базы. Это готовая карта для SQL-инъекции и разведки.
  • Стек-трейс и пути файлов раскрывают структуру проекта, имена внутренних пакетов, версию Go и путь до домашней директории сборочного агента.
  • Адреса внутренних сервисов в ошибках вида dial tcp 10.0.3.17:5432: connect: connection refused рисуют топологию внутренней сети.
  • Разные формулировки для «нет пользователя» и «неверный пароль» превращают форму входа в перечислитель существующих аккаунтов. Ответ должен быть один: «неверный логин или пароль».
  • Разные коды на «нет доступа» и «нет объекта» в приватных ресурсах: 403 подтверждает, что объект существует. Иногда правильнее отвечать 404 в обоих случаях.

Обратная совместимость: что ломает клиентов

ИзменениеЛомает?Почему
Добавить необязательное поле в ответнетСтрогие парсеры — редкость; клиент игнорирует лишнее
Добавить необязательный параметр запросанетСтарые клиенты его не шлют, работает дефолт
Добавить новую ручку / новый методнет
Удалить или переименовать поле ответадаКлиент читает отсутствующее поле — nil / zero value / паника
Сменить тип поля ("7"7, число → строка)даОшибка десериализации. Особенно больно с int64-id в JS: там теряется точность, поэтому id часто отдают строкой — и менять это назад уже нельзя
Сделать необязательное поле обязательнымдаСтарые клиенты его не шлют → 422
Сузить формат значения (был свободный текст — стал enum)даРанее валидные запросы начинают отбиваться
Добавить значение в enum в ответечасто даКлиенты с exhaustive-switch падают на неизвестном значении. Поэтому в контракте сразу пишут «неизвестные значения игнорируйте / трактуйте как unknown»
Сменить код ответа (200202)даКлиент проверяет == 200
Изменить смысл поля при том же имени и типеда, тихоСамое опасное: компилируется, парсится, работает неправильно. Пример: amount был в рублях, стал в копейках
Ужесточить лимиты (maxLimit 1000 → 100), новый rate limitдаЛогика клиента, рассчитанная на старые лимиты, начинает получать 429
Изменить порядок элементов без явной сортировкидаКлиенты полагаются на наблюдаемое поведение, а не на контракт
Удалить ручкудаТолько через deprecation: Deprecation/Sunset-заголовки, срок, метрики использования
Как выключают старую версию

Ломающее изменение выкатывают новой major-версией рядом со старой, старую не правят. Дальше: Deprecation: @1769904000 (RFC 9745: дата, с которой версия устарела) и Sunset: Sat, 01 Aug 2026 00:00:00 GMT в ответах v1, метрика api_requests_total{version="v1",client_id=…}, по которой видно, кто ещё жив, рассылка владельцам, и только потом отключение. Есть ещё приём «brownout»: за месяц до выключения ручка на 5 минут в день отдаёт 410 Gone, и забытые интеграции находятся до финального дня, а не в момент отключения.

Вопросы

6
Суть: путь именует ресурс (существительное во множественном числе), действие задаёт метод, версия — в начале пути; на каждую ручку называй код успеха, коды ошибок и что в теле.

Как строить ответ вслух

Сначала проговори модель. Сущности здесь post, comment, author, like. Потом отношения: комментарии принадлежат посту, лайк — паре (пост, пользователь). И только потом рисуй таблицу ручек. Так видно, что ты проектируешь от домена, а не от «мне нужен endpoint».

GET    /v1/posts                    200                 список + пагинация
POST   /v1/posts                    201 + Location      создать
GET    /v1/posts/{id}               200 / 404 / 304     получить
PUT    /v1/posts/{id}               200 / 404 / 409     заменить целиком
PATCH  /v1/posts/{id}               200 / 404 / 422     частично изменить
DELETE /v1/posts/{id}               204 / 404           удалить
POST   /v1/posts/{id}/publish       200 / 409           смена состояния
GET    /v1/posts/{id}/comments      200 / 404           комментарии поста
POST   /v1/posts/{id}/comments      201 + Location      добавить комментарий
DELETE /v1/comments/{id}            204 / 403 / 404     удалить комментарий
PUT    /v1/posts/{id}/likes/me      204                 лайк (идемпотентно)
DELETE /v1/posts/{id}/likes/me      204                 снять лайк

Что обязательно проговорить

  • PUT vs PATCH. PUT заменяет представление целиком и идемпотентен, отсутствующие поля трактуются как «сбросить». PATCH меняет ресурс частично; чтобы отличить «не прислал поле» от «прислал null», в Go нужен json.RawMessage или свой тип с UnmarshalJSON: указатель *string в обоих случаях останется nil. Иначе zero value затрёт данные.
  • 201 с Location, а 204 — без тела вообще.
  • 409 для оптимистичной блокировки: клиент шлёт If-Match: "v7", сервер сравнивает с текущим ETag. Не совпало, значит 412 Precondition Failed, а если версия пришла в теле, то 409 Conflict. Без этого два редактора молча затирают правки друг друга.
  • Действия вне CRUD (publish, archive) оформляют под-ресурсом с POST, а не через PATCH {"status":"published"}, если публикация запускает побочные эффекты (рассылка, индексация): у неё своя авторизация и свои коды.
  • Идентификаторы. Автоинкремент выдаёт объём базы и позволяет перебирать объекты (IDOR). Для публичного API берут UUIDv7 (сортируемый по времени) или ULID, а в JSON отдают строкой: JS теряет точность на числах больше 2^53.
На чём заваливают этот вопрос
  • Глаголы в путях: POST /createPost.
  • Все ответы 200, а ошибка внутри тела. Так ломаются мониторинг, ретраи, кэш и любой стандартный клиент.
  • Ни слова про пагинацию у GET /posts, хотя первый вопрос интервьюера будет ровно про неё.
  • DELETE без разговора про soft delete: для постов с комментариями почти всегда нужен deleted_at, а не физическое удаление.
Суть: версионируется только major и только ломающие изменения; практичный дефолт — /v1/ в пути, заголовок оправдан для дата-версий, query — почти никогда.

В пути

GET /v1/posts/42

Плюсы: видно в логах, метриках и трейсах без разбора заголовков; тривиально маршрутизируется на уровне ingress (/v1/* → старый деплоймент, /v2/* → новый), и версии катятся независимо; кэши и CDN различают версии автоматически, потому что различается URL; можно прислать коллеге ссылку. Минусы: формально один и тот же ресурс получает два URI, и гипермедиа-ссылки (HATEOAS) начинают вести в конкретную версию; сохранённые клиентами ссылки привязаны к версии навсегда.

В заголовке

Accept: application/vnd.blog.v2+json
X-API-Version: 2
Stripe-Version: 2024-06-20

Плюсы: URI остаётся каноническим; это «правильный» HTTP через content negotiation; можно версионировать представление отдельно от ресурса; удобна модель Stripe, где версия привязана к аккаунту и меняется осознанно. Минусы: ничего не видно в access-логах и в браузере, отлаживать больно; обязателен Vary: Accept (или Vary: X-API-Version), иначе CDN отдаст клиенту v1 ответ версии v2; нужен дефолт для клиентов, забывших заголовок, и этот дефолт со временем становится ловушкой.

В query

GET /posts?version=2&tag=go

Плюсы: вкручивается в существующий API за пять минут, легко тестировать из браузера. Минусы: смешивается с бизнес-параметрами и теряется при построении ссылок; ломает кэш-ключи, если не добавить в них параметр; невозможно отроутить на уровне L7-балансировщика так же чисто, как префикс пути; по теории query входит в идентификатор ресурса и к представлению отношения не имеет.

Чем добить ответ

«Лучшая версия — та, которой нет. Большинство изменений можно сделать совместимо: добавить поле, добавить необязательный параметр, добавить ручку. Версия нужна там, где меняется семантика. И ещё: версионировать надо и формат ответа, и поведение. Новый rate limit или новый обязательный заголовок ломает клиентов ровно так же, как удалённое поле». Можно добавить, что во внутренних межсервисных API вместо HTTP-версий обычно развивают protobuf-схему: новые поля с новыми тегами совместимы по определению.

Суть: лимит по умолчанию 20–50, жёсткий максимум 100–200 и он зажимается молча; offset — для админок с номерами страниц, keyset-курсор — для лент и всего, что растёт; сортировка только по белому списку полей.

Два вида пагинации

Offset/limit: ?page=3&per_page=20 или ?limit=20&offset=40. Можно открыть любую страницу и узнать общее количество. Но за это платишь дважды: СУБД обязана прочитать и выбросить все пропускаемые строки (глубокие страницы стоят линейно дороже), а вставка или удаление сдвигает окно, и записи дублируются или пропадают между страницами.

Keyset (cursor / seek): WHERE (created_at, id) < (:ts, :id) ORDER BY created_at DESC, id DESC LIMIT 20. Курсор отдают непрозрачной base64-строкой с кортежем ключа последней отданной строки. По индексу СУБД сразу встаёт на нужное место, цена страницы постоянна, окно не «плывёт». Минус в том, что нельзя прыгнуть на произвольную страницу и дёшево посчитать total.

type Cursor struct {
    CreatedAt time.Time `json:"t"`
    ID        int64     `json:"i"`
}

func encodeCursor(c Cursor) string {
    b, _ := json.Marshal(c)
    return base64.RawURLEncoding.EncodeToString(b)
}

// составной ключ сравниваем именно кортежем, чтобы работал индекс:
// WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT $3

Лимит по умолчанию

  • Дефолт 20 (для «тяжёлых» объектов 10, для лёгких справочников 50). Ориентир: ответ должен укладываться в сотни килобайт, а не мегабайты.
  • Максимум 100. Больше отдают только через отдельную ручку экспорта с потоковой отдачей (NDJSON) или асинхронной выгрузкой в файл.
  • Лимит больше максимума зажимают молча, а не отвечают 400: клиенту нужен результат, а не спор. А вот на limit=abc и limit=-5 ответ 400.
  • Никогда не отдавать «всё», если параметра нет. Ручка без лимита похожа на мину: сегодня 300 строк, через год 3 миллиона.

Фильтрация и сортировка

  • Плоские query-параметры: ?status=published&author_id=3&created_after=…. Язык запросов в URL не изобретают: сложные условия уходят в отдельную POST /v1/posts/search с телом (и да, это осознанное нарушение «POST не для чтения»).
  • Несколько значений передают повтором параметра (?tag=go&tag=api), и r.URL.Query()["tag"] вернёт слайс.
  • Сортировку пишут как ?sort=-created_at,title, минус = DESC. Поле маппится через map белого списка на выражение SQL. Плейсхолдеры в ORDER BY не работают, поэтому альтернативы белому списку нет.
  • У каждого фильтруемого и сортируемого поля должен быть индекс, иначе API «работает», только пока таблица маленькая.
Три добивки, которые задают почти всегда
  • «Почему OFFSET 1000000 медленный?» Потому что план всё равно проходит миллион строк (пусть и по индексу) и выбрасывает их. Работа сделана впустую.
  • «Как отдать total при keyset?» Дёшево никак. Варианты: не отдавать вовсе (has_more хватает), отдавать приблизительное из pg_class.reltuples или считать точно отдельной ручкой, которую клиент дёргает по требованию.
  • «Что если строки с одинаковым created_at?» Нужен tie-breaker: уникальное поле последним в ORDER BY и в кортеже курсора. Без него пагинация недетерминирована.
Суть: идемпотентность — свойство эффекта, а не ответа. Клиент шлёт UUID в Idempotency-Key, сервер атомарно занимает ключ до списания, сохраняет ответ и на повторе отдаёт сохранённое, не выполняя операцию.

Определение и таблица методов

Операция идемпотентна, если N одинаковых вызовов оставляют систему в том же состоянии, что и один. GET/HEAD/OPTIONS safe и идемпотентны, PUT и DELETE идемпотентны, но не safe. POST ни то ни другое, PATCH — как напишешь. У DELETE второй вызов может вернуть 404, и идемпотентности это не отменяет: сравнивают состояние сервера, а не тело ответа.

Зачем это платежам

Клиент, получивший таймаут, не знает, дошёл ли запрос. Сеть теряет ответ, под перезапускается посреди обработки, балансировщик ретраит сам. Без защиты каждый такой случай оборачивается вторым списанием. При этом просто «не ретраить» нельзя: тогда часть платежей теряется.

Механика ключа — по шагам

  1. Клиент генерирует UUIDv4 на бизнес-операцию (одна кнопка «оплатить» — один ключ) и переиспользует его во всех ретраях. Если генерировать ключ на каждый HTTP-запрос, толку от него нет.
  2. Сервер пытается вставить строку (key, user_id, endpoint, sha256(body), in_progress). Уникальный индекс по key делает шаг атомарным: параллельную гонку выигрывает ровно один запрос.
  3. Вставка удалась → выполняем операцию и идём в PSP со своим идемпотентным идентификатором (merchant_order_id), чтобы и там ретрай не задвоился.
  4. Записываем status_code и тело ответа, переводим ключ в done, в идеале в той же транзакции, где меняется баланс.
  5. Повторный запрос с тем же ключом: done → отдаём сохранённый ответ (полезно добавить Idempotent-Replay: true); in_progress409 Conflict с Retry-After; тот же ключ, но другой sha256(body)422: это ошибка клиента, а не ретрай.
  6. Ключи живут ограниченное время (у Stripe 24 часа) и чистятся по expires_at. Уникальность обычно проверяют в паре (клиент, ключ), чтобы ключ одного мерчанта не блокировал другого.
-- занимаем ключ атомарно одним запросом
INSERT INTO idempotency_keys (key, user_id, endpoint, request_hash, state, expires_at)
VALUES ($1, $2, $3, $4, 'in_progress', now() + interval '24 hours')
ON CONFLICT (user_id, key) DO NOTHING
RETURNING key;
-- пусто в ответе => ключ уже занят, читаем существующую строку и смотрим state
Как это звучит на собесе

«Ключ занимается до побочного эффекта и завершается после. Тогда падать можно в любой точке. Если упали до эффекта, ключ висит in_progress, ретрай получит 409 и попробует позже, а фоновая чистка снимет зависшие ключи по таймауту. Если упали после эффекта, но до записи ответа, дубль отсечёт уже сам PSP по нашему merchant_order_id».

Ошибки реализации
  • Проверка через SELECT, потом INSERT без уникального индекса даёт классическую гонку: оба параллельных ретрая увидят «ключа нет».
  • Хранение ключа в памяти процесса ломается, как только реплик больше одной.
  • Не сохранять тело ответа: тогда на ретрай нечего отдать, и приходится выполнять запрос заново.
  • Не проверять хеш тела: клиент случайно переиспользует ключ для другого платежа и молча получает чужой ответ, а деньги «пропадают».
  • Ключ на GET/PUT не нужен: они идемпотентны по протоколу.
Суть: одна структура ошибки на весь сервис, машиночитаемый код + человекочитаемый текст + trace id; наружу — класс проблемы, внутрь (в лог) — подробности.

RFC 7807 / 9457

Media type application/problem+json, поля: type (URI класса проблемы, де-факто он и служит стабильным кодом ошибки), title (короткое название, одинаковое для всех вхождений типа), status (дублирует HTTP-код, чтобы не потерялся при проксировании), detail (что именно пошло не так в этом вызове), instance (URI вхождения). В расширения кладут любые свои поля: trace_id, errors[] с разбивкой по полям, retry_after.

type Problem struct {
    Type     string       `json:"type"`
    Title    string       `json:"title"`
    Status   int          `json:"status"`
    Detail   string       `json:"detail,omitempty"`
    Instance string       `json:"instance,omitempty"`
    TraceID  string       `json:"trace_id,omitempty"`
    Errors   []FieldError `json:"errors,omitempty"`
}

func writeProblem(w http.ResponseWriter, p Problem) {
    w.Header().Set("Content-Type", "application/problem+json; charset=utf-8")
    w.Header().Set("X-Content-Type-Options", "nosniff")
    w.WriteHeader(p.Status)
    _ = json.NewEncoder(w).Encode(p)
}

// доменные ошибки маппятся в HTTP в одном месте, а не по хендлерам
func toProblem(ctx context.Context, err error) Problem {
    tid := trace.SpanContextFromContext(ctx).TraceID().String()
    switch {
    case errors.Is(err, domain.ErrNotFound):
        return Problem{Type: "/errors/not-found", Title: "Not found", Status: 404, TraceID: tid}
    case errors.Is(err, domain.ErrConflict):
        return Problem{Type: "/errors/conflict", Title: "Conflict", Status: 409, TraceID: tid}
    case errors.As(err, new(*domain.ValidationError)):
        // ... 422 + разбивка по полям
    }
    slog.ErrorContext(ctx, "unhandled", "err", err, "trace_id", tid) // детали только в лог
    return Problem{Type: "/errors/internal", Title: "Internal error", Status: 500, TraceID: tid}
}

Почему единый формат важнее его конкретного вида

Клиент должен уметь разобрать ошибку одной функцией и ветвиться по стабильному коду, а не по тексту сообщения. Как только в API появляются три разных формата ошибок (один в старых ручках, второй в новых, третий отдаёт nginx), клиентский код обрастает эвристиками вида «если в теле есть слово error». RFC 7807 удобен как готовое решение: его не надо обсуждать, а по Content-Type ошибку видно, даже не читая тело.

Почему нельзя светить внутренности

  • Разведка перед атакой. Текст pq: relation "users" does not exist сообщает СУБД, схему и имя таблицы. Дальше подбирать инъекцию гораздо проще.
  • Топология сети. dial tcp 10.0.3.17:6379: i/o timeout показывает внутренние адреса и состав инфраструктуры.
  • Версии и пути. Стек-трейс выдаёт версию Go, структуру пакетов, путь сборки, а иногда и имя приватного репозитория.
  • Оракул перечисления. «Пользователь не найден» vs «неверный пароль» превращает логин-форму в инструмент сбора валидных email. То же с 403 vs 404: разный код подтверждает существование объекта.
  • Тайминг тоже утечка. Если «нет пользователя» отвечает за 5 мс, а «неверный пароль» за 80 мс (там честно считается bcrypt), разницу видно. Лечится так: хеш считают от заглушки, даже если пользователя нет.
Правило

Наружу отдаём класс ошибки и trace id, внутрь пишем всё: исходную ошибку с %w-цепочкой, SQL, параметры (кроме секретов), стек. Клиент приходит в поддержку с trace_id, и по нему в логах находится ровно этот запрос. Это и безопасно, и удобнее, чем текст ошибки в ответе.

Суть: добавлять необязательное — безопасно, удалять / переименовывать / сужать / менять тип и смысл — ломает. Самое опасное — изменение семантики при том же имени поля: ничего не падает, просто всё считается неправильно.

Совместимые изменения

  • Новое необязательное поле в ответе.
  • Новый необязательный query-параметр или поле запроса с дефолтом.
  • Новая ручка, новый метод у существующего пути.
  • Ослабление валидации (было максимум 100 символов, стало 200).
  • Новый заголовок ответа.

Ломающие изменения

  1. Удаление или переименование поля ответа. Клиент получает zero value и не отличает его от «пусто».
  2. Смена типа: "42"42, число → объект, скаляр → массив. encoding/json вернёт ошибку, статически типизированные клиенты упадут.
  3. Новое обязательное поле запроса или новый обязательный заголовок.
  4. Сужение допустимых значений: свободная строка стала enum, лимит уменьшился, формат даты стал строже.
  5. Новое значение существующего enum в ответе ломает клиентов с полным перебором вариантов. Формально это добавление, а на деле авария. Поэтому в контракте изначально пишут: «неизвестные значения трактуйте как unknown».
  6. Смена кода ответа или превращение синхронной ручки в асинхронную (200202).
  7. Изменение семантики при том же имени и типе: amount из рублей стал копейками, timeout из секунд стал миллисекундами, date сменил таймзону. Тихо испорченные данные хуже падения.
  8. Изменение поведения: новый rate limit, ужесточённый maxLimit, другой порядок элементов, другая длина или формат идентификатора (клиент положил его в varchar(36), а ты выдал 40 символов).
  9. Удаление ручки или изменение пути.

Закон Хайрама и его практический вывод

«При достаточном числе пользователей API неважно, что обещано в контракте: любое наблюдаемое поведение системы кем-то используется». Поэтому в реальности ломающими оказываются вещи, которых нет в документации: порядок элементов без ORDER BY, наличие поля debug, конкретная длина токена, время ответа. Отсюда вывод: явно писать в контракте, на что нельзя полагаться, и по возможности вносить небольшой контролируемый хаос (перемешивать порядок там, где он не гарантирован).

Инструменты, которые стоит назвать
  • OpenAPI + diff в CI: oasdiff валит пайплайн на breaking change.
  • Contract-тесты (Pact) или просто golden-файлы ответов в тестах.
  • Заголовки Deprecation и Sunset + метрика использования старой версии с разбивкой по client_id.
  • Для gRPC есть buf breaking по protobuf-схеме: нельзя менять номера тегов и типы полей, можно только добавлять новые.

10.2HTTP-сервер в Go

Самая «инженерная» глава темы. Здесь спрашивают, как net/http устроен изнутри: кто создаёт горутину, какой таймаут что именно режет, почему дефолтный http.Client однажды уронит прод и как выкатиться, не оборвав запросы на полуслове.

Путь запроса: от Listener до твоего хендлера

Внутри http.ListenAndServe(addr, handler) всего три строчки: создать &http.Server{Addr: addr, Handler: handler}, открыть слушающий сокет через net.Listen("tcp", addr) и уйти в srv.Serve(l). Дальше начинается цикл, который стоит уметь рассказать пошагово.

  1. l.Accept() в бесконечном цикле в горутине, вызвавшей Serve. Если Accept вернул временную ошибку (например, кончились файловые дескрипторы), сервер не падает, а спит по нарастающей (5 мс, 10, 20… до 1 с) и пробует снова.
  2. На принятое соединение создаётся c := srv.newConn(rw) и сразу запускается go c.serve(connCtx). Пула воркеров нет: одна горутина на одно TCP-соединение. В HTTP/1.x не на запрос, а именно на соединение; HTTP/2 внутри соединения запускает ещё по горутине на каждый поток.
  3. Внутри c.serve для HTTPS сначала идёт TLS-handshake (там же согласуется ALPN: h2 или http/1.1), потом цикл чтения запросов.
  4. c.readRequest(ctx) парсит стартовую строку и заголовки в *http.Request. Тело не читается: в r.Body кладётся обёртка над буферизованным ридером соединения, и байты потекут, только когда хендлер сам вызовет Read.
  5. serverHandler{srv}.ServeHTTP(w, req) при srv.Handler == nil подставляет http.DefaultServeMux. Затем роутер выбирает нужный Handler и вызывает его ServeHTTP. Это уже твой код.
  6. Когда хендлер вернулся, w.finishRequest() сбрасывает буфер ответа в сокет, дочитывает и выбрасывает остаток тела запроса (иначе соединение нельзя переиспользовать) и закрывает chunked-поток.
  7. Если клиент и сервер согласны на keep-alive и ошибок не было, цикл делает continue, и та же горутина ждёт следующий запрос на том же соединении. Иначе вызывается c.close(), и горутина завершается.
main-горутина srv.ListenAndServe() l, _ := net.Listen(tcp, addr) for { rw, err := l.Accept() } c := srv.newConn(rw) go c.serve(connCtx) горутина соединения: c.serve(ctx) если TLS: handshake + ALPN (h2 / http1.1) w, req := c.readRequest(ctx) serverHandler{srv}.ServeHTTP(w, req) mux.ServeHTTP: match паттерна твой Handler.ServeHTTP(w, r) finishRequest: flush + дочитать тело keep-alive и не было ошибок? да — тот же conn берёт следующий запрос нет — c.close() Что из этого важно на собесе В HTTP/1.x горутина на СОЕДИНЕНИЕ, а не на запрос. Пула воркеров и лимита на соединения нет — 10 000 клиентов дадут 10 000 горутин. Ограничивать надо самому: netutil.LimitListener, лимиты на LB, ulimit по дескрипторам.
Обработка запроса в net/http. Слева работает принимающий цикл, справа — жизнь одного соединения. Правый цикл крутится, пока живёт keep-alive, и всё это время держит одну горутину.
Глубже: почему в Go «горутина на соединение» не антипаттерн

В C++/Java эта модель считается антипаттерном: поток ОС стоит мегабайты стека и переключение через ядро. В Go горутина стартует с 2 КиБ стека, а все сетевые вызовы проходят через netpoller (epoll/kqueue внутри рантайма). Когда горутина упирается в Read на сокете без данных, рантайм её паркует (gopark) и снимает с потока ОС; поток уходит выполнять другие горутины. Получается модель, которая пишется как блокирующая, а работает как асинхронная. За это платят памятью: каждое соединение держит горутину, буфер чтения (4 КиБ) и буфер записи (4 КиБ), то есть примерно 10–20 КиБ на idle-соединение. Отсюда вывод: IdleTimeout нужен, чтобы сервер не держал десятки тысяч бесполезных горутин.

http.Handler и HandlerFunc: почему это одно и то же

Весь net/http стоит на интерфейсе из одного метода:

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

Писать структуру ради одного метода лень, а обычная функция интерфейс не реализует. Выручает адаптер на именованном функциональном типе:

type HandlerFunc func(ResponseWriter, *Request)

// метод на функциональном типе просто вызывает f
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}

Запись http.HandlerFunc(myFunc) ничего не вызывает и не заворачивает в структуру. Это конверсия типа: та же функция, но теперь у неё есть метод ServeHTTP, значит, она удовлетворяет Handler. Разница между mux.Handle и mux.HandleFunc ровно в этом — второй делает конверсию за тебя.

// три эквивалентные записи
mux.Handle("GET /ping", http.HandlerFunc(ping))
mux.HandleFunc("GET /ping", ping)
mux.Handle("GET /ping", pingHandler{}) // структура со своим ServeHTTP
Формулировка, которую ждут

«Handler — интерфейс, HandlerFunc — функциональный тип с методом ServeHTTP, который просто вызывает себя. Это стандартный приём адаптера в Go: превратить функцию в реализацию интерфейса без структуры. Тот же паттерн у expvar.Func и у RoundTripperFunc, который пишут руками.» Из этого же растут middleware: интерфейс Handler можно обернуть другим Handler.

Когда нужна структура, а не функция? Когда хендлеру нужны зависимости: репозиторий, логгер, конфиг. Подходов два, и оба в ходу:

// структура-хендлер
type PostsHandler struct {
    repo Repo
    log  *slog.Logger
}

func (h PostsHandler) ServeHTTP(
    w http.ResponseWriter, r *http.Request) {
    // h.repo, h.log доступны
}
// замыкание-конструктор (чаще)
func GetPost(repo Repo) http.HandlerFunc {
    return func(w http.ResponseWriter,
        r *http.Request) {
        // repo захвачен замыканием
    }
}
mux.Handle("GET /v1/posts/{id}", GetPost(repo))
Подвох: глобальное состояние в хендлере

Хендлер вызывается конкурентно из множества горутин. Если он пишет в поле своей структуры, это гонка. Поля должны быть либо read-only после старта, либо под мьютексом, либо atomic. Классический провал: счётчик h.count++ в хендлере и удивление, почему -race ругается.

Роутинг: ServeMux после Go 1.22 против chi/gin/echo

До Go 1.22 стандартный ServeMux умел стыдно мало: только префиксы и точные пути, никаких методов и параметров. Поэтому все тащили сторонний роутер. В Go 1.22 мукс переписали, и теперь паттерн выглядит так: [МЕТОД ][хост]/путь.

mux := http.NewServeMux()

mux.HandleFunc("GET /v1/posts",          listPosts)   // только GET (и HEAD автоматически)
mux.HandleFunc("POST /v1/posts",         createPosts)
mux.HandleFunc("GET /v1/posts/{id}",     getPost)     // сегмент-параметр
mux.HandleFunc("GET /v1/files/{path...}", serveFile)  // «остаток пути», строго последним
mux.HandleFunc("GET /v1/posts/{$}",      exactOnly)   // {$} = конец пути, без префиксного матча
mux.HandleFunc("api.example.com/", apiOnly)           // матч по хосту

func getPost(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")     // Go 1.22
    pattern := r.Pattern        // Go 1.23: "GET /v1/posts/{id}", годится как метка метрики
    _ = id
    _ = pattern
}

Что новый ServeMux умеет:

  • Метод в паттерне; GET автоматически покрывает HEAD.
  • Параметры пути {id} и хвост {rest...}, значения через r.PathValue / r.SetPathValue.
  • Конфликты решаются по специфичности, а не по порядку регистрации: /v1/posts/{id} проигрывает более конкретному /v1/posts/latest. Если два паттерна пересекаются, а специфичнее ни один, мукс паникует при регистрации, а не затирает молча.
  • Если путь совпал, а метод — нет, мукс отдаёт 405 с заголовком Allow. Раньше был 404, и это сбивало клиента с толку.
  • {id} матчится по распаршенному сегменту: %2F внутри сегмента не «расклеит» путь, в отличие от наивных роутеров на strings.Split.

Чего не умеет и за чем всё ещё идут в chi/gin/echo:

  • Группы роутов со своим набором middleware. В ServeMux middleware либо один на весь сервер, либо навешивается вручную на каждый хендлер. Можно завести отдельный mux под /admin/ плюс http.StripPrefix — работает, но громоздко.
  • Ограничений на параметры ({id:[0-9]+}) нет, валидируй сам.
  • Опциональные сегменты, необязательный завершающий слэш: мукс сам редиректит /x на зарегистрированный /x/ — и это бывает сюрпризом для клиентов, которые не следуют редиректам на POST. До Go 1.26 это был 301, и он был вдвойне опасен: браузер кэширует «навсегда», а на 301 POST исторически превращается в GET с потерей тела. С Go 1.26 код сменили на 307: такой ответ не кэшируется и обязан сохранять метод и тело (проверено на go1.27.0: POST /x307 Location: /x/). Тот же 307 теперь приходит и при чистке пути, вроде /a//b/a/b.
  • Кастомные 404/405-хендлеры, интроспекция списка роутов, генерация URL по имени роута.
  • Биндинг и валидация тела, рендеринг, контекст фреймворка (*gin.Context).
Критерийnet/http ServeMux (1.22+)chiginecho
Зависимостиноль, stdlibноль сторонних (только stdlib)несколько (binding, валидатор)несколько
Совместимость с http.Handlerполнаяполная — весь экосистемный middleware работаетсвоя сигнатура func(*gin.Context), нужен адаптерсвоя func(echo.Context) error
Алгоритмдерево сегментов + правило специфичностиradix trieradix trieradix trie
Группы + per-group middlewareнетr.Route/r.Group/r.Useестьесть
Регэкспы в параметрахнетестьнетнет
Биндинг + валидация теласамсамc.ShouldBindJSON + теги binding:c.Bind + валидатор
Скорость роутингадостаточная (сотни нс)достаточнаяочень быстрый, zero-allocочень быстрый
Когда братьсервис на 10–40 ручек, хочется меньше магиисредний/большой REST, нужны группы и стандартные интерфейсыбыстро слепить, команда уже на нёмто же, что gin
Как отвечать на «что использовал»

Ответ «gin, потому что он быстрый» не годится: на роутинге разница в наносекундах, а один поход в БД стоит миллисекунды. Выбирают по другим признакам: совместимость с http.Handler (не придётся переписывать middleware и переезжать), нужны ли группы, сколько магии готовы терпеть. Хороший ответ звучит так: «Сейчас на новых сервисах беру стандартный ServeMux — после 1.22 его хватает, ноль зависимостей и никакого своего контекста. Там, где много групп и версий, беру chi, потому что он остаётся в терминах http.Handler. С gin работал на легаси-сервисе, главный минус — свой *gin.Context протекает во все слои.»

Подвох: http.DefaultServeMux

http.HandleFunc(...) без mux регистрирует в глобальный DefaultServeMux. Туда же в init() прописывает свои ручки net/http/pprof. Достаточно одного import _ "net/http/pprof" в любой зависимости, и, если сервер поднят с Handler: nil, наружу торчат /debug/pprof/*: дампы горутин, профайлер (который ещё и тормозит процесс) и /debug/pprof/cmdline с аргументами запуска. Правило: всегда свой mux, а pprof выносят на отдельный порт, закрытый от внешнего мира.

Middleware: цепочка «луковицей»

Middleware — это функция func(http.Handler) http.Handler: берёт хендлер, возвращает хендлер, который что-то делает до, вызывает next.ServeHTTP и что-то делает после. Цепочка собирается вложенными вызовами, поэтому структура выходит концентрической.

type Middleware func(http.Handler) http.Handler

func Chain(mws ...Middleware) Middleware {
    return func(final http.Handler) http.Handler {
        // идём с конца, чтобы mws[0] оказался самым внешним
        for i := len(mws) - 1; i >= 0; i-- {
            final = mws[i](final)
        }
        return final
    }
}

stack := Chain(RequestID, Logging(log), Recover(log), Auth(keys), RateLimit(lim))
srv.Handler = stack(mux)
// эквивалентно: RequestID(Logging(Recover(Auth(RateLimit(mux)))))
1 RequestID 8 ответ уходит клиенту 2 Logging (снимает start := time.Now()) 7 пишет метод, путь, статус, латенси 3 Recover (ставит defer recover) 6 паники не было — просто выход 4 Auth (проверяет токен) 5 возврат из хендлера Handler: бизнес-логика запрос ответ Порядок имеет значение Recover должен быть ВНУТРИ Logging, иначе паника не попадёт в лог со статусом. RequestID — самым внешним, чтобы id был у всех, включая лог паники. RateLimit и Auth — до тяжёлой работы, чтобы отсекать дёшево.
Луковица. Первый в списке middleware оказывается самым внешним: он первым видит запрос и последним видит ответ. Код до next.ServeHTTP идёт «внутрь», код после — «наружу».

Логирующий middleware: перехват статуса и латенси

Сложнее всего со статусом ответа. http.ResponseWriter его не отдаёт: узнать, что хендлер записал 404, можно только перехватив вызов WriteHeader. Значит, нужна обёртка со встроенным ResponseWriter.

type statusWriter struct {
    http.ResponseWriter       // встраивание: остальные методы наследуются
    status int
    bytes  int
}

func (w *statusWriter) WriteHeader(code int) {
    if w.status != 0 {
        return // защита от повторного WriteHeader (иначе superfluous WriteHeader в логах)
    }
    w.status = code
    w.ResponseWriter.WriteHeader(code)
}

func (w *statusWriter) Write(b []byte) (int, error) {
    if w.status == 0 {
        w.status = http.StatusOK // хендлер писал тело без явного WriteHeader
    }
    n, err := w.ResponseWriter.Write(b)
    w.bytes += n
    return n, err
}

// без него http.ResponseController не развернёт обёртку (Go 1.20+)
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

func Logging(log *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            sw := &statusWriter{ResponseWriter: w}

            next.ServeHTTP(sw, r)

            if sw.status == 0 {
                sw.status = http.StatusOK // хендлер не написал вообще ничего
            }
            log.LogAttrs(r.Context(), slog.LevelInfo, "http_request",
                slog.String("method", r.Method),
                slog.String("path", r.URL.Path),
                slog.String("route", r.Pattern),          // Go 1.23: низкая кардинальность
                slog.Int("status", sw.status),
                slog.Int("bytes", sw.bytes),
                slog.Duration("latency", time.Since(start)),
                slog.String("request_id", RequestIDFrom(r.Context())),
                slog.String("ip", clientIP(r)),
            )
        })
    }
}
Ловушка обёртки: убитые Hijacker, Flusher, ReadFrom

В интерфейсе http.ResponseWriter три метода, но реальный *http.response дополнительно реализует http.Flusher (SSE), http.Hijacker (websocket-апгрейд), io.ReaderFrom (быстрая отдача файла через sendfile) и http.CloseNotifier. У твоей обёртки уже другой тип, и w.(http.Hijacker) внутри библиотеки websocket вернёт ok == false. Классический баг: «после добавления логирующего middleware сломался websocket».

Раньше обходились костылём: руками пробрасывали все интерфейсы и получали комбинаторный взрыв из 2N вариантов: для четырёх интерфейсов их уже 16, а httpsnoop генерирует десятки. Сейчас на обёртку добавляют метод Unwrap() http.ResponseWriter, а на стороне потребителя берут http.NewResponseController(w) (Go 1.20): контроллер сам разворачивает цепочку обёрток и находит настоящий writer.

// вместо w.(http.Flusher) и w.(http.Hijacker):
rc := http.NewResponseController(w)
if err := rc.Flush(); err != nil { /* не поддерживается */ }
_ = rc.SetWriteDeadline(time.Now().Add(30 * time.Second)) // продлить WriteTimeout для SSE
conn, buf, err := rc.Hijack()

Recovery middleware

func Recover(log *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            sw := &statusWriter{ResponseWriter: w}
            defer func() {
                rec := recover()
                if rec == nil {
                    return
                }
                // штатный способ оборвать ответ: пробрасываем дальше, сервер его ждёт
                if rec == http.ErrAbortHandler {
                    panic(rec)
                }
                log.Error("panic in handler",
                    "err", rec,
                    "path", r.URL.Path,
                    "request_id", RequestIDFrom(r.Context()),
                    "stack", string(debug.Stack()))

                // заголовки уже ушли: 500 не написать, рвём соединение
                if sw.status != 0 {
                    panic(http.ErrAbortHandler)
                }
                w.Header().Set("Content-Type", "application/problem+json")
                w.WriteHeader(http.StatusInternalServerError)
                _, _ = io.WriteString(w,
                    `{"type":"about:blank","title":"internal error","status":500}`)
            }()
            next.ServeHTTP(sw, r)
        })
    }
}
Три вещи про recover, которые проверяют
  • recover() работает только внутри defer той же горутины. Паника в горутине, которую хендлер запустил сам (go doWork()), убьёт весь процесс — middleware её не поймает. Поэтому в каждой новой горутине нужен свой defer recover.
  • Сам net/http тоже ловит панику в conn.serve, пишет стек в ErrorLog и закрывает соединение, так что процесс не падает. Но клиент получит оборванное соединение, а не 500, и в метриках это будет выглядеть как сетевая ошибка. Свой recovery нужен ради корректного ответа и своего лога.
  • Не отдавать наружу стек или текст паники: это утечка внутренностей (пути, имена таблиц, версии). Клиенту уходит обезличенный 500 с request_id, всё остальное идёт в лог.

Request ID через контекст

type ctxKey int                    // неэкспортируемый тип: чужой пакет не подменит
const requestIDKey ctxKey = iota

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-Id")
        if id == "" || len(id) > 64 || !isSafeID(id) {
            id = uuid.New().String()  // не доверяем чужому значению вслепую
        }
        ctx := context.WithValue(r.Context(), requestIDKey, id)
        w.Header().Set("X-Request-Id", id)          // вернуть клиенту для саппорта
        next.ServeHTTP(w, r.WithContext(ctx))       // r.WithContext делает shallow copy
    })
}

func RequestIDFrom(ctx context.Context) string {
    id, _ := ctx.Value(requestIDKey).(string)       // type assertion с ok не паникует
    return id
}
Go 1.27: uuid переехал в стандартную библиотеку

Тот uuid в примере годами означал github.com/google/uuid — модуль, который тянули почти в каждый сервис. С Go 1.27 пакет uuid есть в стандартной библиотеке (RFC 9562), и внешняя зависимость для этого больше не нужна.

import "uuid"

u := uuid.New()        // случайный, синоним uuid.NewV4()
u = uuid.NewV7()       // сортируемый по времени: первые 48 бит = миллисекунды Unix
s := u.String()        // "01a041dd-3cde-794b-a425-da2cd9f4379f"
v, err := uuid.Parse(s)
_ = uuid.MustParse(s)  // паникует вместо ошибки: только для констант в коде
_ = uuid.Nil()         // 00000000-…-000000000000
_ = uuid.Max()         // ffffffff-…-ffffffffffff

Два практических следствия. Первое: тип объявлен как UUID [16]byte, обычный массив. Он сравним через ==, годится ключом map и не аллоцирует; в fmt и в JSON сам превращается в привычную строку с дефисами (String() и MarshalText), а database/sql знает про него напрямую — и как параметр запроса, и как приёмник в Scan, без всяких driver.Valuer у прикладного кода (проверено на go1.27.0). Второе: отдельной функции NewString() здесь нет — пишут uuid.New().String(), и такой код одинаково работает и со старым модулем. А для первичных ключей бери NewV7(): случайный v4 разбрасывает вставки по всему B-дереву индекса, а v7 монотонно растёт и ложится в хвост.

Правила работы с context.Value
  • Ключом делают неэкспортируемый тип (type ctxKey struct{} или type ctxKey int), никогда не строку: строковый ключ может совпасть с чужим.
  • В контекст кладут только request-scoped метаданные: request id, trace id, user id, локаль. Зависимости (репозиторий, логгер, конфиг) не кладут, их передают явными аргументами или полями структуры. «Контекст как мешок для DI» на код-ревью считают красным флагом.
  • Значение ищется за O(n) по цепочке родителей, но n мал. Проблема не в скорости, а в неявности: компилятор не проверит, что значение положили.
  • Всегда value, ok := ctx.Value(k).(T) и разумный фолбэк, а не .(T) без ok.

Таймауты сервера: четыре штуки плюс TimeoutHandler

Нулевые таймауты в http.Server{} означают «без ограничений». Это дефолт http.ListenAndServe, и он небезопасен: одно медленное или злонамеренное соединение может держаться вечно, занимая горутину, дескриптор и буферы. Каждый таймаут режет свою фазу.

accept + TLS чтение заголовков чтение тела хендлер работает запись ответа idle (keep-alive) время ReadHeaderTimeout ReadTimeout WriteTimeout IdleTimeout http.TimeoutHandler Slowloris Атакующий открывает тысячи соединений и шлёт по одному байту заголовка раз в 10 секунд, не завершая запрос. Без ReadHeaderTimeout каждое такое соединение вечно держит горутину и дескриптор — сервер умирает без единого запроса.
Таймауты по фазам. WriteTimeout начинает тикать не с начала записи, а сразу после чтения заголовков, так что покрывает ещё и чтение тела, и работу хендлера.
ПолеЧто именно ограничиваетС какого момента тикаетЧто будет при срабатыванииРазумное значение
ReadHeaderTimeoutчтение стартовой строки и всех заголовковс начала чтения запросасоединение закрывается, хендлер не вызывался3–10 с
ReadTimeoutчтение всего запроса: заголовки + телос начала чтения запросаr.Body.Read вернёт ошибку, соединение закроется10–30 с (больше для загрузок)
WriteTimeoutчтение тела + работа хендлера + запись ответасразу после чтения заголовковw.Write вернёт ошибку, соединение рвётсяReadTimeout + бюджет хендлера
IdleTimeoutпростой между запросами на keep-alive-соединениипосле отправки ответасоединение закрывается60–120 с
MaxHeaderBytesсуммарный размер заголовков (дефолт 1 МиБ)431 Request Header Fields Too Largeоставить дефолт или 64 КиБ
MaxHeaderValueCount Go 1.27суммарное количество значений заголовков (дефолт DefaultMaxHeaderValueCount = 500)431 Request Header Fields Too Largeоставить дефолт
http.TimeoutHandlerтолько время работы хендлерас вызова хендлераклиенту корректный 503 с телом, соединение живопод SLA ручки
srv := &http.Server{
    Addr:                ":8080",
    Handler:             stack(mux),
    ReadHeaderTimeout:   5 * time.Second,   // главный щит от slowloris
    ReadTimeout:         15 * time.Second,
    WriteTimeout:        20 * time.Second,  // > ReadTimeout, иначе долгое тело съест бюджет ответа
    IdleTimeout:         90 * time.Second,
    MaxHeaderBytes:      1 << 20,
    MaxHeaderValueCount: 500,               // Go 1.27: потолок числа значений; дефолт тот же 500
    ErrorLog:            slog.NewLogLogger(handler, slog.LevelError), // вместо дефолтного stderr
    BaseContext:         func(net.Listener) context.Context { return rootCtx },
}
Три подвоха про таймауты
  • WriteTimeout не отменяет хендлер. Он ставит дедлайн на сокет: горутина хендлера молотит дальше, запрос к БД выполняется — просто ответ уже некому отдать. Отменяет работу только r.Context(), который закрывается, когда клиент отвалился, или http.TimeoutHandler, который отменяет контекст запроса.
  • WriteTimeout ломает SSE, long polling и большие выгрузки. Для таких ручек поднимают отдельный сервер без WriteTimeout или зовут http.NewResponseController(w).SetWriteDeadline(...) прямо из хендлера.
  • IdleTimeout == 0 означает «использовать ReadTimeout», а если и он ноль, простой не ограничен вовсе. Поэтому нельзя выставить только ReadHeaderTimeout и считать, что защитился.
Глубже: что ещё держит сервер, кроме таймаутов

Таймауты ограничивают одно соединение, но не их количество. Нужны ещё: netutil.LimitListener(l, N) (жёсткий потолок одновременных соединений), Server.ConnState для метрик по фазам (new/active/idle/hijacked/closed), Server.MaxHeaderBytes и Server.MaxHeaderValueCount, http.MaxBytesReader на тело, Server.ConnContext для проброса данных о соединении, а для HTTP/2 ещё и http2.Server{MaxConcurrentStreams}, потому что там тысяча стримов может прилететь в одном TCP-соединении (та самая атака Rapid Reset, CVE-2023-44487; в Go её чинили в 1.21.3/1.20.10). В Go 1.24 у Server и Transport появились поля Protocols/HTTP2, и настраивать HTTP/2 или включать его без TLS теперь можно без костылей с golang.org/x/net/http2.

Go 1.27: MaxHeaderValueCount и дыра, которую не закрывал MaxHeaderBytes

При заголовочном флуде клиент шлёт не один огромный заголовок, а очень много маленьких: десятки тысяч строк вида X-A: 1. По байтам такой запрос спокойно влезает в MaxHeaderBytes, зато в Request.Header получается map с десятками тысяч ключей: аллокации, память и время разбора растут, хотя формально лимит не превышен. В HTTP/2 тот же приём усиливается сжатием HPACK — несколько килобайт на проводе разворачиваются в мегабайты заголовков.

В Go 1.27 у Server появилось поле MaxHeaderValueCount и константа DefaultMaxHeaderValueCount = 500, которая применяется, если поле не задано. Считаются именно значения: перечисленные через запятую в одной строке идут за одно, а у заголовка, повторённого в нескольких строках, считается каждая строка. Превышение даёт 431 Request Header Fields Too Large (проверено запуском на go1.27.0). Обновление на 1.27 включает эту защиту само, но если у тебя есть законный клиент, который шлёт сотни повторяющихся заголовков, лимит придётся поднять руками.

Клиент: таймауты, транспорт и пул соединений

За http.Get(url) и http.DefaultClient стоит клиент с Timeout: 0, то есть без таймаута вообще. Если удалённый сервис принял соединение и замолчал, твоя горутина повиснет навсегда. Дальше сценарий известный: горутины копятся, каждая держит соединение и память, метрики показывают рост go_goroutines, через час — OOM. Это самый частый ответ на «падал ли у тебя прод?».

var transport = &http.Transport{
    Proxy: http.ProxyFromEnvironment,
    DialContext: (&net.Dialer{
        Timeout:   3 * time.Second,   // установка TCP-соединения
        KeepAlive: 30 * time.Second,  // TCP keepalive-пробы
    }).DialContext,
    TLSHandshakeTimeout:   3 * time.Second,
    ResponseHeaderTimeout: 5 * time.Second,  // от конца запроса до первого байта заголовков
    ExpectContinueTimeout: 1 * time.Second,
    MaxIdleConns:          256,   // всего idle-соединений в пуле
    MaxIdleConnsPerHost:   128,   // дефолт 2, почти всегда узкое место
    MaxConnsPerHost:       256,   // потолок активных, 0 = без лимита
    IdleConnTimeout:       90 * time.Second,
    ForceAttemptHTTP2:     true,
}

var client = &http.Client{
    Transport: transport,
    Timeout:   10 * time.Second, // общий потолок: dial + TLS + запрос + ответ + чтение тела
    CheckRedirect: func(r *http.Request, via []*http.Request) error {
        if len(via) >= 3 { return http.ErrUseLastResponse }
        return nil
    },
}
Client.Timeout против таймаутов Transport

Client.Timeout задаёт один общий дедлайн на всю операцию, включая чтение тела ответа. Он работает через дедлайн контекста запроса и таймер отмены, поэтому обрывает и медленный io.Copy из resp.Body. Таймауты Transport работают пофазно: быстро отсекают «сервис недоступен» (dial 3 с) и не ограничивают законно долгую выгрузку. Правильная схема: пофазные таймауты в транспорте + общий потолок в клиенте + http.NewRequestWithContext(ctx, ...) с дедлайном из контекста запроса. Контекст сильнее: при его отмене запрос обрывается, даже если Timeout ещё не вышел.

Пул соединений живёт в Transport, а не в Client. Отсюда правило: один Transport (и обычно один Client) на всё приложение, его создают один раз при старте. Новый http.Transport{} на каждый запрос даёт классическую утечку: у каждого свой пул (голый http.Client{} без Transport берёт общий DefaultTransport и не течёт), соединения не переиспользуются, порты уходят в TIME_WAIT, при высоком RPS кончаются эфемерные порты (cannot assign requested address).

MaxIdleConnsPerHost по умолчанию 2. Если сервис делает 500 RPS в один апстрим, то из пула переиспользуются два соединения, а остальные создаются и закрываются заново — на каждый запрос новый TCP-хендшейк и полный TLS-хендшейк. Симптом: латенси p99 выше, чем объясняет апстрим, и куча соединений в TIME_WAIT в ss -s. Лечится одной строкой.

// тело надо дочитать и закрыть, иначе соединение не вернётся в пул
resp, err := client.Do(req)
if err != nil {
    return err
}
defer func() {
    // дочитываем остаток (с лимитом, чтобы злой сервер не залил нас гигабайтом)
    _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
    _ = resp.Body.Close()
}()

if resp.StatusCode != http.StatusOK {
    return fmt.Errorf("upstream: %s", resp.Status)  // тело всё равно дочитается в defer
}
Подвох: «я же сделал defer resp.Body.Close()»

Недостаточно. Close() на недочитанном теле заставляет транспорт закрыть соединение, а не вернуть его в пул: остаток тела всё ещё в сокете, и переиспользовать такой сокет нельзя. Особенно больно на ветках ошибок, где тело обычно игнорируют: пул «худеет» ровно во время инцидента, когда апстрим сыплет пятисотками. Поэтому сначала дочитать в io.DiscardLimitReader), потом закрыть. Второй частый промах: defer resp.Body.Close() до проверки err != nil. При ошибке resp равен nil, и получаешь панику вместо понятного лога.

Что изменилось в Go 1.27. HTTP/1-транспорт теперь при Close() сам пробует дочитать недочитанный остаток и всё-таки вернуть соединение в пул, но при двух жёстких условиях: тело не длиннее 256 КиБ (или его длина неизвестна) и остаток успевает вычитаться за 50 мс. То есть маленькие тела ошибок (типичный problem+json) с 1.27 прощаются сами, а большая или медленная выгрузка соединения по-прежнему стоит. Явный io.Copy(io.Discard, io.LimitReader(...)) остаётся правильной привычкой: он работает одинаково на всех версиях Go и не зависит от того, знал ли апстрим длину заранее.

Go 1.27: тест без реального порта через httptest.NewTestServer

И сервер, и клиента из предыдущих разделов надо чем-то тестировать, и проще всего для этого поднять фейковый сервер. Обычный httptest.NewServer открывает при этом настоящий сокет на 127.0.0.1 со случайным портом. Чаще всего это незаметно, но в CI даёт знакомые неприятности: тесты конкурируют за порты, песочница может вообще не разрешать listen, а после Close() остаётся TIME_WAIT.

// Go 1.27: сервер живёт на фейковой сети в памяти, сокета и порта нет вовсе
ts := httptest.NewTestServer(t, mux)     // t.Cleanup(ts.Close) регистрируется сам
client := ts.Client()                    // ходить надо именно этим клиентом
resp, err := client.Get(ts.URL + "/v1/orders/1")
// ts.URL == "http://example.com": имя синтетическое, DNS и порт не участвуют
// https://example.com/... тоже работает: TLS поднят на той же фейковой сети

Соединения идут через подменённый DialContext транспорта, поэтому сторонний клиент со своим транспортом до такого сервера не достучится. Тестируй либо через ts.Client(), либо прокидывай его Transport в свой код. Взамен получаешь тест без портов, без гонок за них и без сетевых таймаутов, а паника в хендлере прилетает в вывод теста, а не в stderr. Проверено на go1.27.0.

Graceful shutdown

Задача: получив SIGTERM (его шлёт Kubernetes при выкатке), перестать принимать новые запросы, дать доработать текущим, закрыть внешние ресурсы и выйти с нулевым кодом. Успеть надо за terminationGracePeriodSeconds, иначе прилетит SIGKILL.

func main() {
    // 1. ловим сигналы: ctx закроется по SIGINT/SIGTERM
    ctx, stop := signal.NotifyContext(context.Background(),
        syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    srv := &http.Server{ /* ... таймауты ... */ }

    errCh := make(chan error, 1)
    go func() {
        // ErrServerClosed: штатный возврат после Shutdown, не ошибка
        if err := srv.ListenAndServe(); err != nil &&
            !errors.Is(err, http.ErrServerClosed) {
            errCh <- err
        }
    }()

    select {
    case err := <-errCh:
        log.Error("сервер не поднялся", "err", err)
        os.Exit(1)
    case <-ctx.Done():
        // Go 1.26: NotifyContext отменяет контекст через CancelCauseFunc,
        // поэтому причина отмены прямо называет пришедший сигнал.
        //   SIGTERM -> "terminated signal received"
        //   SIGINT  -> "interrupt signal received"
        //   stop()  -> context.Canceled
        log.Info("начинаем graceful shutdown", "cause", context.Cause(ctx))
    }
    stop() // вернуть дефолтное поведение: второй Ctrl+C убьёт процесс немедленно

    // 2. readiness начинает отдавать 503, но запросы ещё принимаем
    ready.Store(false)
    time.Sleep(5 * time.Second) // дать балансировщику убрать нас из пула

    // 3. перестать принимать новые, дождаться активных
    shCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    if err := srv.Shutdown(shCtx); err != nil {
        log.Error("не успели за отведённое время, рвём соединения", "err", err)
        _ = srv.Close() // жёстко
    }

    // 4. только теперь закрываем ресурсы: до этого ими пользуются хендлеры
    consumer.Stop()          // остановить читателей Kafka, закоммитить оффсеты
    _ = db.Close()           // закрыть пул соединений с БД
    _ = tracerProvider.Shutdown(context.Background()) // выплюнуть накопленные спаны
    if f, ok := log.Handler().(interface{ Flush() error }); ok {
        _ = f.Flush() // у JSONHandler и TextHandler Flush нет
    }
}
Go 1.26: у отмены появилась причина — видно, каким сигналом нас погасили

До 1.26 signal.NotifyContext просто отменял контекст, и в логе оставалось безликое context canceled: пришёл SIGTERM от Kubernetes при штатной выкатке или человек нажал Ctrl+C — по логам не различить. С Go 1.26 внутри работает context.CancelCauseFunc — та же отмена, но с прикреплённой причиной: ошибкой, которую потом достают через context.Cause(ctx).

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
<-ctx.Done()

ctx.Err()              // context.Canceled, как раньше, ничего не говорит
context.Cause(ctx)     // "terminated signal received", а вот это уже SIGTERM

errors.Is(context.Cause(ctx), context.Canceled) // true: старые проверки не ломаются

Зачем это на практике. Во-первых, логи выкатки: строка «остановлены по SIGTERM» отличает штатный роллинг-апдейт от Ctrl+C на стенде и от SIGHUP при потере терминала. Во-вторых, поведение можно развести: по SIGTERM отработать полный цикл с preStop-паузой, а по SIGINT от разработчика гаситься быстро. Причина приходит строкой (os.Signal из неё не достать), так что для типизированного сигнала по-прежнему нужен собственный signal.Notify в канал. Проверено на go1.27.0.

Kubernetes Балансировщик Наш процесс БД / брокер / трейсы 1. SIGTERM процессу 2. Pod убран из Endpoints 3. readiness = 503, ready.Store(false) 4. запросы ещё долетают: preStop sleep 5-10 с 5. srv.Shutdown: listener закрыт, idle-conn закрыты 6. активные хендлеры дорабатывают, Connection: close если ctx истёк: srv.Close() рвёт всё принудительно 7. consumer.Stop(), db.Close(), tracer.Shutdown() 8. return из main, exit 0 Чего Shutdown НЕ делает Не закрывает hijacked-соединения: websocket после Hijack он не считает активным запросом. Такие закрывай сам через RegisterOnShutdown. И не отменяет r.Context() у хендлеров: висящий 10 минут хендлер продержит Shutdown 10 минут.
Порядок остановки. Чаще всего пропускают пункты 3 и 4, и балансировщик ещё несколько секунд шлёт трафик на уже закрытый listener: клиент видит connection refused вместо мягкой деградации.
Порядок остановки: об этом на самом деле и спрашивают
  1. Сказать миру, что мы уходим: readiness-проба отдаёт 503, ждём 5–10 секунд (или preStop: sleep в манифесте), пока балансировщик и service mesh перестанут слать трафик.
  2. Перестать принимать новое: Shutdown закрывает listener и все idle keep-alive-соединения.
  3. Дождаться текущего: Shutdown блокируется, пока активные запросы не завершатся, и добавляет в их ответы Connection: close.
  4. Только потом закрывать ресурсы: БД, брокер, кэш, экспортёры трейсов и метрик. Если закрыть db до Shutdown, доигрывающие хендлеры получат sql: database is closed и отдадут 500.
  5. Таймаут Shutdown должен быть меньше terminationGracePeriodSeconds (обычно 30 с), иначе SIGKILL прилетит посреди уборки.

Тело запроса: Decoder против Unmarshal, лимиты, стриминг

// json.Unmarshal
b, err := io.ReadAll(r.Body)   // всё в память
if err != nil { ... }
err = json.Unmarshal(b, &in)

// + можно перечитать/залогировать сырое тело
// + понятные ошибки с позицией
// - память = размер тела
// - без MaxBytesReader это DoS
// json.Decoder
dec := json.NewDecoder(r.Body)  // стрим
dec.DisallowUnknownFields()
err := dec.Decode(&in)

// + не держит тело в памяти целиком
// + умеет поток JSON-объектов (dec.More)
// + UseNumber(), Token() для больших данных
// - сырое тело уже не залогируешь

В обычной API-ручке Decoder ставят поверх MaxBytesReader. Потоковую загрузку (NDJSON, батчи) тот же Decoder читает в цикле по dec.More(), и память не растёт с размером входа.

func createPost(w http.ResponseWriter, r *http.Request) {
    // 1. жёсткий потолок на тело; w нужен, чтобы сервер закрыл соединение корректно
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 МиБ

    // 2. на публичных API стоит проверить и Content-Type
    if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
        writeProblem(w, http.StatusUnsupportedMediaType, "нужен application/json")
        return
    }

    dec := json.NewDecoder(r.Body)
    dec.DisallowUnknownFields() // поле с опечаткой даст 400, а не пропадёт молча

    var in createPostReq
    if err := dec.Decode(&in); err != nil {
        var maxErr *http.MaxBytesError      // Go 1.19+
        var synErr *json.SyntaxError
        var typErr *json.UnmarshalTypeError
        switch {
        case errors.As(err, &maxErr):
            writeProblem(w, http.StatusRequestEntityTooLarge, "тело больше 1 МиБ")
        case errors.As(err, &synErr):
            writeProblem(w, http.StatusBadRequest,
                fmt.Sprintf("битый JSON на позиции %d", synErr.Offset))
        case errors.As(err, &typErr):
            writeProblem(w, http.StatusBadRequest,
                fmt.Sprintf("поле %q ожидает тип %s", typErr.Field, typErr.Type))
        case errors.Is(err, io.EOF):
            writeProblem(w, http.StatusBadRequest, "пустое тело")
        default:
            writeProblem(w, http.StatusBadRequest, "не удалось разобрать тело")
        }
        return
    }

    // 3. в теле ровно один объект, иначе {"a":1}{"b":2} проедет молча
    if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
        writeProblem(w, http.StatusBadRequest, "ожидается ровно один JSON-объект")
        return
    }

    if err := in.Validate(); err != nil { /* 422 */ }
}
Go 1.26: errors.AsType[T] вместо трёх переменных заранее

Схема «объяви переменную нужного типа, потом errors.As(err, &v)» появилась не от хорошей жизни: когда писали errors.As, дженериков в Go ещё не было. С Go 1.26 есть дженерик-версия: тип передаётся параметром, переменная объявляется прямо в условии, а промах, на котором errors.As паникует в рантайме (цель не указатель), с AsType просто не скомпилируется.

// Go 1.26: тип в параметре, переменная прямо в условии
if _, ok := errors.AsType[*http.MaxBytesError](err); ok {
    writeProblem(w, http.StatusRequestEntityTooLarge, "тело больше 1 МиБ")
} else if se, ok := errors.AsType[*json.SyntaxError](err); ok {
    writeProblem(w, http.StatusBadRequest, fmt.Sprintf("битый JSON на позиции %d", se.Offset))
} else if te, ok := errors.AsType[*json.UnmarshalTypeError](err); ok {
    writeProblem(w, http.StatusBadRequest, fmt.Sprintf("поле %q ожидает тип %s", te.Field, te.Type))
}

Старый errors.As никуда не делся и в switch из примера выше по-прежнему удобнее: там переменные объявлены один раз, а case-ветки читаются в столбик. AsType выигрывает там, где проверка одна-две и заводить переменную заранее не хочется. Проверено на go1.27.0.

Подвох: «поле не пришло» против «поле пришло пустым»

json.Unmarshal в структуру не различает отсутствующее поле и нулевое значение: {"title":""} и {} дадут одинаковый in.Title == "". А PATCH должен знать, чего от него хотят: «не трогать поле» или «затереть в пустоту». Лечится это указателями (*string: nil = не пришло), обёрткой type Optional[T any] struct{ Value T; Set bool } со своим UnmarshalJSON или разбором в map[string]json.RawMessage с проверкой ключа. И не путай DisallowUnknownFields с required: обязательность полей encoding/json не проверяет вообще, это работа валидатора.

Загрузка файлов и multipart

Браузерная форма с файлом шлёт Content-Type: multipart/form-data; boundary=.... Тело состоит из частей, разделённых boundary, и у каждой свои заголовки (Content-Disposition, Content-Type). В Go его разбирают двумя способами.

// Вариант A: ParseMultipartForm — просто, для небольших файлов
func upload(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 32<<20) // общий потолок 32 МиБ

    // аргумент задаёт порог памяти; остальное уйдёт во временные файлы в os.TempDir
    if err := r.ParseMultipartForm(8 << 20); err != nil {
        writeProblem(w, http.StatusBadRequest, "битая форма")
        return
    }
    defer func() { _ = r.MultipartForm.RemoveAll() }() // сервер чистит только форму исходного r, не копии

    file, hdr, err := r.FormFile("avatar")
    if err != nil { /* 400 */ }
    defer file.Close()

    // hdr.Filename и hdr.Header.Get("Content-Type") прислал клиент, им не доверяем
    name := filepath.Base(hdr.Filename)               // с Go 1.17 путь отрезан и так, это страховка
    if hdr.Size > 5<<20 { /* 413 */ }

    // определяем реальный тип по содержимому, а не по расширению
    head := make([]byte, 512)
    n, _ := io.ReadFull(file, head)
    mime := http.DetectContentType(head[:n])
    if mime != "image/jpeg" && mime != "image/png" { /* 415 */ }
    if _, err := file.Seek(0, io.SeekStart); err != nil { /* 500 */ }

    dst, err := os.CreateTemp(uploadDir, "up-*")      // имя генерируем сами
    if err != nil { /* 500 */ }
    defer dst.Close()
    if _, err := io.Copy(dst, file); err != nil { /* 500 */ }
    _ = name
}
// Вариант B: MultipartReader — потоково, без буферизации, годится для гигабайтов
func uploadStream(w http.ResponseWriter, r *http.Request) {
    r.Body = http.MaxBytesReader(w, r.Body, 256<<20)
    mr, err := r.MultipartReader() // до этого нельзя вызывать r.FormValue/r.FormFile: съедят тело
    if err != nil {
        writeProblem(w, http.StatusUnsupportedMediaType, "ожидается multipart/form-data")
        return
    }
    for {
        part, err := mr.NextPart()
        if errors.Is(err, io.EOF) {
            break
        }
        if err != nil { /* 400 */ return }

        switch part.FormName() {
        case "meta":
            var meta Meta
            if err := json.NewDecoder(io.LimitReader(part, 64<<10)).Decode(&meta); err != nil {
                /* 400 */ return
            }
        case "file":
            // сразу в S3/minio, минуя диск и память
            // не LimitReader: тот молча обрежет файл, и в хранилище уйдёт обрубок
            _, err = uploader.Upload(r.Context(), key, http.MaxBytesReader(w, part, 200<<20))
            if err != nil { /* 500 */ return }
        }
        _ = part.Close()
    }
}
Чек-лист безопасной загрузки файлов
  • MaxBytesReader на всё тело и лимит на каждую часть. Для файла такой, что вернёт ошибку: io.LimitReader молча обрежет.
  • Имя файла от клиента пропускай только через filepath.Base, а лучше не используй вовсе: генерируй uuid, оригинальное имя храни в метаданных.
  • Тип определяй по http.DetectContentType (первые 512 байт), а не по расширению и не по Content-Type части. Разрешённые типы задаёт whitelist, не blacklist.
  • Отдавай загруженное с Content-Type из whitelist, X-Content-Type-Options: nosniff и Content-Disposition: attachment — и с отдельного домена, чтобы залитый HTML не выполнился в origin приложения.
  • Никогда не клади загруженные файлы в директорию, откуда сервер исполняет код.
  • Большие файлы пусть идут по presigned URL прямо в объектное хранилище: бэкенд не должен проксировать гигабайты.
  • Вызывай RemoveAll() сам. Сервер чистит только форму исходного запроса, а за middleware с r.WithContext хендлер работает с копией, и /tmp постепенно забивается.

Вопросы

8
Суть: цикл Accept в одной горутине, на каждое принятое соединение запускается отдельная горутина conn.serve, которая в цикле читает запросы и вызывает Handler.ServeHTTP. Handler — интерфейс с одним методом, HandlerFunc — функциональный тип с методом ServeHTTP, вызывающим сам себя.

Пошагово

  1. ListenAndServenet.Listen("tcp", addr)srv.Serve(l).
  2. В цикле l.Accept(). Временные ошибки не роняют сервер: ретрай с экспоненциальной паузой от 5 мс до 1 с.
  3. c := srv.newConn(rw); go c.serve(connCtx) запускает горутину на соединение. Пула воркеров нет, лимита на число соединений тоже.
  4. Горутина делает TLS-handshake (если нужно) и входит в цикл c.readRequest(): разбирает стартовую строку и заголовки. Тело не читается, r.Body остаётся ленивым ридером поверх сокета.
  5. serverHandler{srv}.ServeHTTP(w, req); если srv.Handler == nil, подставляется http.DefaultServeMux. Роутер выбирает хендлер.
  6. Когда хендлер вернулся, finishRequest сбрасывает буфер и дочитывает остаток тела (иначе соединение не переиспользовать).
  7. Keep-alive → continue, та же горутина обслуживает следующий запрос. Иначе c.close().

Handler и HandlerFunc

type Handler interface { ServeHTTP(ResponseWriter, *Request) }

type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) { f(w, r) }

В http.HandlerFunc(fn) нет обёртки в структуру, только конверсия типа: функция та же, но теперь с методом, и она удовлетворяет интерфейсу. Классический приём «функция как реализация интерфейса». На интерфейсе Handler напрямую держатся middleware: func(http.Handler) http.Handler.

Чем добить ответ

  • Модель «горутина на соединение» работает потому, что горутина стоит 2 КиБ стека, а блокирующие сетевые вызовы уходят в netpoller (epoll/kqueue): рантайм паркует горутину и освобождает поток ОС. Код пишешь блокирующий, а ведёт он себя как асинхронный.
  • Соединение обходится в горутину (стек быстро дорастает до 8 КиБ) и два буфера по 4 КиБ, на чтение и на запись, всего около 20 КиБ. На 50 000 idle-соединений набегает около гигабайта, поэтому IdleTimeout и нужен.
  • Хендлер вызывается конкурентно, так что любое разделяемое изменяемое состояние в нём даёт гонку.
  • Ограничить число соединений сам net/http не умеет, для этого есть netutil.LimitListener(l, N).
  • При HTTP/2 схема другая: одно TCP-соединение, много стримов, и горутина создаётся на стрим, а не на соединение.
На чём ловят

На фразе «горутина на запрос». При keep-alive одна горутина обслуживает десятки запросов подряд, поэтому ReadHeaderTimeout и IdleTimeout и нужны: без них соединение (а с ним и горутина) висит бесконечно.

Суть: с Go 1.22 ServeMux умеет метод в паттерне, {id}, {rest...}, разрешение конфликтов по специфичности и корректный 405 — для типового REST-сервиса этого хватает. Внешний роутер берут ради групп с per-group middleware, регэкспов в параметрах и биндинга.

Что дал Go 1.22

mux.HandleFunc("GET /v1/posts/{id}", getPost)
mux.HandleFunc("GET /v1/files/{path...}", files)   // хвост пути
mux.HandleFunc("GET /v1/posts/{$}", exact)         // без префиксного матча
id := r.PathValue("id")
route := r.Pattern    // Go 1.23: готовая метка для метрик
  • Конфликты решаются по специфичности, а не по порядку регистрации. Пересекающиеся паттерны, из которых ни один не специфичнее, вызывают панику при регистрации — баг всплывает на старте, а не в проде.
  • Совпал путь, не совпал метод → 405 + заголовок Allow (раньше был 404).
  • {id} берётся из распаршенного сегмента, поэтому %2F не «расклеит» путь.

Чего нет

  • Групп со своим набором middleware. Остаётся общий стек на сервер, обёртка на каждый хендлер или вложенный ServeMux, обёрнутый в свой middleware.
  • Ограничений на параметры ({id:[0-9]+}), кастомных 404/405, интроспекции роутов, генерации URL.
  • Биндинга и валидации тела.
  • Есть неочевидность: мукс сам редиректит /x на /x/ и чистит путь, а POST-клиенты, которые по редиректам не ходят, получают сюрприз. Тут важна версия: до Go 1.26 отдавался 301 (браузер кэширует его надолго, POST по дороге вырождается в GET), с Go 1.26 отдаётся 307, который не кэшируется и сохраняет метод с телом.

Как звучит хороший ответ

«На новых сервисах беру стандартный ServeMux: после 1.22 функциональности хватает, ноль зависимостей, никакого своего Context, всё в терминах http.Handler. Где много версий и групп с разными middleware, ставлю chi: он тоже держится стандартных интерфейсов, и миграция туда-обратно почти бесплатная. На легаси работал с gin. Главный минус: *gin.Context протекает в сервисный слой и привязывает код к фреймворку, а выигрыш в скорости роутинга (наносекунды) тонет в походе в БД (миллисекунды).»

Отдельный вопрос, который любят

«Чем опасен http.DefaultServeMux?» Тем, что net/http/pprof регистрирует в нём свои ручки в init(). Хватит одного import _ "net/http/pprof" в транзитивной зависимости и Handler: nil, чтобы наружу торчали /debug/pprof/*. Всегда заводи свой mux, а pprof вешай на отдельный внутренний порт.

Суть: middleware — это func(http.Handler) http.Handler; цепочка собирается вложенными вызовами и работает «луковицей»: первый в списке — самый внешний. Статус ответа перехватывается обёрткой над ResponseWriter, request ID прокидывается через context.WithValue с неэкспортируемым ключом.

Цепочка

type Middleware func(http.Handler) http.Handler

func Chain(mws ...Middleware) Middleware {
    return func(final http.Handler) http.Handler {
        for i := len(mws) - 1; i >= 0; i-- { // с конца: mws[0] станет внешним
            final = mws[i](final)
        }
        return final
    }
}
srv.Handler = Chain(RequestID, Logging(log), Recover(log), Auth)(mux)

Код до next.ServeHTTP выполняется на пути внутрь в порядке объявления, а код после отрабатывает на пути наружу, в обратном порядке. Отсюда правила: RequestID ставят самым внешним (id нужен всем, включая лог паники), Recover кладут внутрь Logging (иначе паника не попадёт в access-лог со статусом), а Auth и RateLimit пускают до тяжёлой работы.

Логирующий middleware

type statusWriter struct {
    http.ResponseWriter
    status, bytes int
}
func (w *statusWriter) WriteHeader(code int) {
    if w.status != 0 { return }             // гасим повторный WriteHeader
    w.status = code
    w.ResponseWriter.WriteHeader(code)
}
func (w *statusWriter) Write(b []byte) (int, error) {
    if w.status == 0 { w.status = http.StatusOK }
    n, err := w.ResponseWriter.Write(b)
    w.bytes += n
    return n, err
}
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

func Logging(log *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            sw := &statusWriter{ResponseWriter: w}
            next.ServeHTTP(sw, r)
            if sw.status == 0 { sw.status = http.StatusOK }
            log.Info("http",
                "method", r.Method, "path", r.URL.Path, "route", r.Pattern,
                "status", sw.status, "bytes", sw.bytes,
                "latency_ms", time.Since(start).Milliseconds(),
                "request_id", RequestIDFrom(r.Context()))
        })
    }
}
Главный подвох этого вопроса

Обёртка над ResponseWriter «съедает» опциональные интерфейсы: http.Flusher (SSE), http.Hijacker (websocket), io.ReaderFrom (sendfile). После такого middleware w.(http.Hijacker) вернёт ok == false и websocket сломается. Правильный ответ: дать обёртке метод Unwrap() http.ResponseWriter и работать через http.NewResponseController(w) (Go 1.20), который сам разворачивает цепочку обёрток. Старый способ: руками пробрасывать все комбинации интерфейсов, как делает httpsnoop.

Recovery

func Recover(log *slog.Logger) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            defer func() {
                rec := recover()
                if rec == nil { return }
                if rec == http.ErrAbortHandler { panic(rec) } // штатный обрыв, не глотаем
                log.Error("panic", "err", rec, "stack", string(debug.Stack()),
                    "request_id", RequestIDFrom(r.Context()))
                w.WriteHeader(http.StatusInternalServerError)
                _, _ = io.WriteString(w, `{"title":"internal error","status":500}`)
            }()
            next.ServeHTTP(w, r)
        })
    }
}
  • recover() работает только в своей горутине: паника в go doWork(), запущенной хендлером, уронит процесс. Каждой новой горутине нужен свой defer recover.
  • Сам net/http тоже ловит панику в conn.serve, но клиент получает оборванное соединение, а не 500. Свой recovery нужен ради корректного ответа, метрики и лога.
  • Если заголовки уже ушли, WriteHeader(500) ничего не даст — останется только оборвать ответ (для этого и есть panic(http.ErrAbortHandler)).
  • Наружу уходит обезличенный 500 с request_id, стек только в лог.

Request ID

type ctxKey int
const requestIDKey ctxKey = iota   // неэкспортируемый тип: чужой пакет не подменит ключ

func RequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        id := r.Header.Get("X-Request-Id")
        if id == "" || len(id) > 64 { id = uuid.New().String() }  // Go 1.27: uuid уже в stdlib
        w.Header().Set("X-Request-Id", id)
        next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey, id)))
    })
}
func RequestIDFrom(ctx context.Context) string { s, _ := ctx.Value(requestIDKey).(string); return s }

Дальше id уезжает в логи всех слоёв (log.With("request_id", ...) в контексте) и заголовком в исходящие запросы — так и собирается сквозная трассировка. В проде вместо самописного id обычно берут traceparent из W3C Trace Context и OpenTelemetry, механика та же.

Про context.Value

Ключом делают неэкспортируемый тип, а не строку. В контекст кладут только request-scoped метаданные (request id, trace id, user id, локаль), зависимостям там не место: репозиторий и логгер передаются явно. Читают всегда через v, ok := ctx.Value(k).(T).

Суть: каждый таймаут режет свою фазу жизни соединения. Дефолт — ноль, то есть «без ограничений», и это дыра: одно медленное соединение держит горутину, дескриптор и буферы вечно. Slowloris — атака, которая ровно на этом и построена.
ТаймаутЧто ограничиваетС какого момента
ReadHeaderTimeoutчтение стартовой строки и заголовковс начала чтения запроса
ReadTimeoutчтение всего запроса: заголовки + телос начала чтения запроса
WriteTimeoutчтение тела + работа хендлера + запись ответасразу после чтения заголовков
IdleTimeoutпростой между запросами на keep-aliveпосле отправки ответа
http.TimeoutHandlerтолько время работы хендлерас вызова хендлера

Slowloris

Атакуют ресурсы, а не полосу. Клиент открывает тысячи соединений и в каждом шлёт по одному байту заголовка раз в несколько секунд, никогда не завершая блок заголовков пустой строкой. Запрос формально ещё не пришёл — хендлер не вызывался, логов нет, трафик мизерный. Но каждое такое соединение держит горутину, файловый дескриптор и буферы. Сервер перестаёт принимать легитимных клиентов и «умирает» без единого обработанного запроса. Та же идея для тела зовётся slow POST / R-U-Dead-Yet: клиент заявляет Content-Length: 10000000 и шлёт по байту в секунду.

Лечат это ReadHeaderTimeout (от slowloris), ReadTimeout (от slow POST), MaxHeaderBytes, netutil.LimitListener на число соединений и лимитами на соединения с одного IP на балансировщике. Поэтому голый http.ListenAndServe(":8080", mux) в продакшене сразу настораживает.

Закончить ответ можно свежим дополнением: в Go 1.27 появилось Server.MaxHeaderValueCount (по умолчанию берётся константа DefaultMaxHeaderValueCount, равная 500). Оно закрывает соседний сценарий — заголовочный флуд: вместо одного гигантского заголовка идут десятки тысяч крошечных, которые по байтам влезают в MaxHeaderBytes, но раздувают Request.Header и время разбора. Превышение даёт те же 431 Request Header Fields Too Large.

Тонкости, которые отличают сильный ответ

  • WriteTimeout не останавливает хендлер. Это дедлайн на сокет: горутина продолжает работать, запрос в БД продолжает выполняться, просто ответ уже некому отдать. Отменяют работу только r.Context() и http.TimeoutHandler.
  • WriteTimeout тикает с момента, когда прочитаны заголовки, а не с начала записи. Значит, он должен быть больше ReadTimeout, иначе долгая загрузка тела съест весь бюджет ответа.
  • WriteTimeout убивает SSE, long polling и большие выгрузки. Им нужен отдельный сервер или http.NewResponseController(w).SetWriteDeadline().
  • IdleTimeout == 0 означает «взять ReadTimeout», а если и он ноль, простой не ограничен. Выставить один ReadHeaderTimeout и считать, что закрылся, не выйдет.
  • Для HTTP/2 отдельная история: тысяча стримов в одном соединении, нужен MaxConcurrentStreams (атака Rapid Reset, CVE-2023-44487).
Суть: у http.DefaultClient Timeout == 0 — запрос может висеть вечно и копить горутины до OOM. Нужен свой клиент: общий потолок в Client.Timeout, пофазные таймауты в Transport, увеличенный MaxIdleConnsPerHost (дефолт 2) и обязательное дочитывание resp.Body.

Два уровня таймаутов

  • Client.Timeout ставит один дедлайн на всю операцию: dial + TLS + отправка запроса + получение заголовков + чтение тела. Работает через дедлайн контекста запроса и таймер отмены, поэтому обрывает и медленный io.Copy(dst, resp.Body).
  • Таймауты Transport действуют пофазно: DialContext.Timeout (установка TCP), TLSHandshakeTimeout, ResponseHeaderTimeout (сколько ждать заголовков ответа после отправки запроса), ExpectContinueTimeout, IdleConnTimeout (сколько соединение живёт в пуле без дела).
  • context сильнее обоих: http.NewRequestWithContext(ctx, ...) обрывает запрос при отмене родительского контекста, даже если Timeout не вышел. Им дедлайн входящего запроса и пробрасывают в исходящие.
var transport = &http.Transport{
    DialContext:           (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
    TLSHandshakeTimeout:   3 * time.Second,
    ResponseHeaderTimeout: 5 * time.Second,
    MaxIdleConns:          256,
    MaxIdleConnsPerHost:   128,  // дефолт 2
    MaxConnsPerHost:       256,
    IdleConnTimeout:       90 * time.Second,
    ForceAttemptHTTP2:     true,
}
var client = &http.Client{Transport: transport, Timeout: 10 * time.Second}

Пул соединений

Пул принадлежит Transport, а не Client, отсюда два правила. Первое: один Transport на приложение, созданный при старте. Новый &http.Transport{} на каждый запрос даёт утечку: у каждого свой пул (голый &http.Client{} без Transport берёт общий DefaultTransport и не течёт), переиспользования нет, сокеты уходят в TIME_WAIT, при высоком RPS кончаются эфемерные порты (cannot assign requested address). Второе: MaxIdleConnsPerHost = 2 по умолчанию, и при 500 RPS в один апстрим переиспользуются два соединения, а остальные каждый раз пересоздаются с полным TCP- и TLS-хендшейком. Симптом: p99 выше, чем объясняет апстрим, и гора TIME_WAIT в ss -s.

Почему обязательно закрывать и дочитывать тело

resp, err := client.Do(req)
if err != nil { return err }          // сначала err: при ошибке resp == nil
defer func() {
    _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10)) // дочитать
    _ = resp.Body.Close()
}()

Если тело не закрыть, соединение и горутина readLoop утекут навсегда. Если закрыть, но не дочитать, соединение не вернётся в пул, а закроется: в сокете остались непрочитанные байты. Больнее всего это на ветках ошибок, где тело обычно игнорируют, — пул «худеет» ровно во время инцидента. LimitReader нужен, чтобы недобросовестный апстрим не залил тебя гигабайтом в io.Discard.

Оговорка про версию: с Go 1.27 HTTP/1-транспорт при Close() сам пробует дочитать остаток, если Content-Length ≤ 256 КиБ и остаток вычитывается за 50 мс. Мелкие тела ошибок с 1.27 прощаются сами, крупные и медленные нет, так что явное дочитывание по-прежнему нужно.

Чем добить

Назови ещё три вещи: (1) http.DefaultTransport общий на весь процесс, включая сторонние библиотеки — его поля глобально не меняют, а делают свой; (2) Client.CheckRedirect по умолчанию идёт до 10 редиректов и перекладывает заголовки: при редиректе на чужой хост Authorization отбрасывается, но кастомные заголовки с секретами могут утечь, а бесконтрольный редирект заодно открывает дорогу SSRF; (3) без MaxConnsPerHost клиент может открыть тысячи соединений и сам положить апстрим, так что лимит на клиенте бережёт и чужой сервис.

Суть: ловим SIGTERM через signal.NotifyContext, сначала выключаем readiness и ждём, пока балансировщик уберёт нас из пула, потом srv.Shutdown(ctx) с таймаутом меньше terminationGracePeriodSeconds, и только после этого закрываем БД, брокер и экспортёры.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()

srv := &http.Server{Addr: ":8080", Handler: h, ReadHeaderTimeout: 5 * time.Second}

errCh := make(chan error, 1)
go func() {
    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        errCh <- err
    }
}()

select {
case err := <-errCh:
    log.Error("не поднялись", "err", err); os.Exit(1)
case <-ctx.Done():
    // Go 1.26: причина отмены называет сигнал — "terminated signal received" и т. п.
    log.Info("получен сигнал", "cause", context.Cause(ctx))
}
stop()                        // второй Ctrl+C убьёт процесс сразу

ready.Store(false)            // /readyz отдаёт 503
time.Sleep(5 * time.Second)   // ждём, пока нас выведут из балансировки

shCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shCtx); err != nil {
    log.Error("не успели", "err", err)
    _ = srv.Close()           // рвём остатки
}

consumer.Stop()               // и только теперь ресурсы
_ = db.Close()
_ = tracer.Shutdown(context.Background())

Порядок и почему он такой

  1. Объявить о выходе: readiness → 503, подождать 5–10 с. Kubernetes помечает Pod неготовым в EndpointSlice асинхронно с отправкой SIGTERM: если закрыть listener сразу, часть трафика упрётся в connection refused. Тот же эффект даёт встроенное действие preStop: sleep в манифесте; exec sleep 10 в distroless-образе не сработает — там нет бинарника sleep.
  2. Перестать принимать новое: Shutdown закрывает listener и все idle keep-alive-соединения.
  3. Дождаться текущих: Shutdown блокируется до завершения активных запросов и добавляет им в ответ Connection: close, чтобы клиент не пытался переиспользовать соединение.
  4. Потом ресурсы: вызовешь db.Close() до Shutdown, и доигрывающие хендлеры получат sql: database is closed и отдадут 500.

Что Shutdown НЕ делает

  • Не закрывает hijacked-соединения: websocket после Hijack он не считает активным запросом и просто про него не знает. Закрывай такие сам через srv.RegisterOnShutdown(func(){ hub.CloseAll() }) и свой broadcast-канал. SSE обходится без Hijack: это обычный активный запрос, и Shutdown прождёт его до таймаута, так что стримы гаси тем же хуком.
  • Не отменяет r.Context() у работающих хендлеров: висящий 10 минут хендлер продержит Shutdown все 10 минут. Поэтому нужен отдельный «shutdown-контекст», который хендлеры уважают, и таймаут на Shutdown с фолбэком в Close().
  • Close() в отличие от Shutdown рвёт всё немедленно, включая активные запросы.
  • После Shutdown сервер нельзя переиспользовать: ListenAndServe сразу вернёт ErrServerClosed.
Свежая деталь: какой сигнал пришёл

С Go 1.26 signal.NotifyContext отменяет контекст через context.CancelCauseFunc, поэтому context.Cause(ctx) возвращает ошибку с текстом вида terminated signal received (для SIGTERM) или interrupt signal received (для SIGINT). Раньше в логе оставалось безликое context canceled, и штатная выкатка не отличалась от Ctrl+C на стенде. ctx.Err() и errors.Is(cause, context.Canceled) при этом работают по-старому, так что существующий код не ломается.

Что сказать про Kubernetes

Бюджет: terminationGracePeriodSeconds (по умолчанию 30 с) = preStop sleep + таймаут Shutdown + закрытие ресурсов, и всё это должно уложиться с запасом, иначе прилетит SIGKILL посреди уборки. Отдельно проговори, что процесс обязан сам обрабатывать SIGTERM: если приложение запущено через shell-обёртку, сигнал может до него не дойти (PID 1 и отсутствие проброса) — отсюда и «необъяснимые» обрывы при выкатке.

Суть: Unmarshal требует всё тело в памяти, Decoder читает потоком. В хендлере — всегда http.MaxBytesReader + Decoder с DisallowUnknownFields и проверкой, что после объекта в теле ничего нет.
json.Unmarshaljson.Decoder
Вход[]byte — нужен io.ReadAllio.Reader — читает по мере разбора
Памятьвесь документ + результатбуфер декодера + результат
Поток объектовнетда: for dec.More() { dec.Decode(&v) } — NDJSON, батчи
Сырое тело для лога/подписиестьнет (нужен io.TeeReader)
Строгий режимнетDisallowUnknownFields(), UseNumber()
Типичное применениетело из очереди, конфиг, ответ апстрима под подписьHTTP-хендлер, большие потоки

Скелет хендлера

r.Body = http.MaxBytesReader(w, r.Body, 1<<20)   // 1 МиБ, w нужен для корректного закрытия
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()

var in createReq
if err := dec.Decode(&in); err != nil {
    var maxErr *http.MaxBytesError                // Go 1.19+
    var typErr *json.UnmarshalTypeError
    switch {
    case errors.As(err, &maxErr): // 413
    case errors.As(err, &typErr): // 400 с именем поля и ожидаемым типом
    case errors.Is(err, io.EOF):  // 400 «пустое тело»
    }
    return
}
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
    // в теле было {"a":1}{"b":2} — второй объект молча проехал бы
    return
}

Почему MaxBytesReader, а не io.LimitReader

io.LimitReader на границе просто выдаст io.EOF — тело будет выглядеть как валидно закончившееся, и клиент получит невнятный 400. http.MaxBytesReader возвращает типизированную *http.MaxBytesError (можно отдать честный 413) и через ResponseWriter велит закрыть соединение после ответа, чтобы сервер не читал остаток гигабайтного тела. Лимит ставят до первого чтения, и лучше не в каждом хендлере, а в middleware с дефолтом и точечными исключениями для загрузок.

Тонкости, за которые дают плюс

  • Отсутствующее поле и нулевое значение неразличимы. {} и {"title":""} дают одинаковый результат. Для PATCH берут указатели (*string), обёртку Optional[T] со своим UnmarshalJSON или разбор в map[string]json.RawMessage.
  • DisallowUnknownFields ловит опечатки клиента, но ломает обратную совместимость наоборот: если клиент шлёт поле из будущей версии, ты его отвергнешь. На внутренних API его включают, на публичных сначала думают.
  • Обязательность полей encoding/json не проверяет вообще — это работа валидатора (go-playground/validator или ручной Validate()).
  • Большие числа: float64 по умолчанию теряет точность на int64 больше 2^53, спасают dec.UseNumber() или json.Number в структуре.
  • Очень большой массив разбирают потоково через dec.Token(): читают открывающую скобку, потом элементы по одному.
  • Нужны и сырое тело (для HMAC-подписи вебхука), и разбор? Ставь io.TeeReader в hash.Hash вместо двойного чтения.
Суть: два способа — ParseMultipartForm (просто, часть в памяти, часть во временных файлах) и MultipartReader (потоково, ничего не буферизуем). В обоих — MaxBytesReader, whitelist типов по содержимому, свои имена файлов и обязательный RemoveAll().

Как устроен multipart

Тело состоит из частей, разделённых строкой-boundary из Content-Type: multipart/form-data; boundary=.... У каждой части свои заголовки: Content-Disposition: form-data; name="file"; filename="cat.png" и необязательный Content-Type. Всё это прислал клиент, значит ничему из этого доверять нельзя.

// A: ParseMultipartForm
r.Body = http.MaxBytesReader(w, r.Body, 32<<20)
if err := r.ParseMultipartForm(8 << 20); err != nil {
    return // 400
}
defer r.MultipartForm.RemoveAll() // /tmp!

f, hdr, err := r.FormFile("avatar")
if err != nil {
    return // 400
}
defer f.Close()
// hdr.Filename, hdr.Size, hdr.Header
// B: MultipartReader — стриминг
mr, err := r.MultipartReader()
if err != nil { return } // 415
for {
    p, err := mr.NextPart()
    if errors.Is(err, io.EOF) { break }
    if err != nil { return } // 400
    if p.FormName() == "file" {
        uploader.Upload(ctx, key,
            http.MaxBytesReader(w, p, 200<<20))
    }
    p.Close()
}

Аргумент ParseMultipartForm(n) не лимит: он говорит, сколько держать в памяти, а остальное молча уезжает во временные файлы в os.TempDir(). Без MaxBytesReader и RemoveAll() так недолго забить диск. MultipartReader после FormValue или FormFile не сработает: они уже разобрали тело. ParseForm multipart-тело не трогает.

Чек-лист безопасности

  • Лимиты: MaxBytesReader на всё тело, io.LimitReader на каждую часть, ограничение на количество частей.
  • Имя: не доверяй ему. С Go 1.17 FileName() сам отрезает путь вроде ../../etc/cron.d/x, но лучше генерировать uuid и хранить оригинал в метаданных.
  • Тип: по содержимому, через http.DetectContentType(first512), а не по расширению и не по Content-Type части. Whitelist, не blacklist. Картинки ещё и перекодируют: так гибнет полезный груз, вшитый в EXIF.
  • Отдача: с другого домена или через прокси-ручку, с X-Content-Type-Options: nosniff, Content-Disposition: attachment и жёстко заданным Content-Type — иначе залитый HTML/SVG выполнится как страница в origin приложения (хранимый XSS).
  • Хранилище: не в директорию, откуда исполняется код. Большие файлы грузят по presigned URL прямо в S3/MinIO, гонять гигабайты через бэкенд незачем.
  • Антивирус/санитайзинг для пользовательского контента, если файлы потом видят другие пользователи.
Частая боевая ошибка

Забыть defer r.MultipartForm.RemoveAll() в надежде на сервер. net/http чистит только форму исходного запроса, а любой middleware с r.WithContext отдаёт хендлеру копию. Симптом приходит через месяц: no space left on device в совершенно другом месте сервиса, потому что /tmp забит файлами multipart-*.

10.3Аутентификация и авторизация

В этой теме половина кандидатов путает термины, а другая знает про JWT ровно то, что написано на jwt.io. Определений тут не спрашивают, спрашивают компромиссы: почему токен так трудно отозвать, где хранить его в браузере и что будет, если об этом не подумать.

Аутентификация, авторизация и всё, что рядом

Вся глава крутится вокруг одного слова. Токен — строка, которую сервер один раз выдал клиенту после проверки пароля, а клиент потом прикладывает к каждому запросу вместо пароля. Всё. После логина пароль больше никуда не летит, летит токен. Схемы различаются ответами на два вопроса. Что внутри строки: случайный идентификатор («непрозрачный» токен, его смысл знает только сервер) или подписанные данные, которые сервер проверит, ни у кого не спрашивая (это JWT)? И кто прикладывает токен к запросу: браузер сам (кука) или наш JavaScript руками (заголовок Authorization)? Из второго вопроса вырастает вся тема CSRF в главе 10.4.

ТерминВопрос, на который отвечаетПример механизмаКод ошибки
ИдентификацияКем ты называешься?логин, email, client_id, номер карты
Аутентификация (AuthN)Ты действительно он?пароль, TOTP, подпись токена, mTLS-сертификат401 Unauthorized
Авторизация (AuthZ)Что тебе можно?роли, права, ACL, политики, владение объектом403 Forbidden
Аудит (Accounting)Кто что сделал?журнал действий, immutable-лог

Порядок жёсткий: сначала аутентификация, потом авторизация. Нельзя решить, что человеку можно, пока не знаешь, кто он. Названия кодов в HTTP исторически перепутаны: 401 Unauthorized на деле означает «не аутентифицирован» и обязан идти с заголовком WWW-Authenticate, а 403 Forbidden значит «аутентифицирован, но прав не хватает».

Когда 401, а когда 403
  • Токена нет, он битый или протух → 401 + WWW-Authenticate: Bearer (клиент понимает, что надо обновить токен и повторить).
  • Токен валиден, но прав нет → 403. Повторять бессмысленно.
  • Токен валиден, прав нет, а само существование объекта надо скрыть → 404. Тогда перебором не узнать, какие id существуют (так GitHub прячет приватные репозитории).

Сессии против JWT: где живёт состояние

Разница одна, и всё остальное следует из неё. У сессии состояние лежит на сервере: клиент носит непрозрачный идентификатор, сервер по нему достаёт данные. У JWT состояние у клиента: все данные внутри токена, сервер только проверяет подпись.

Три слова, которые дальше пойдут без пояснений
  • Cross-site («межсайтовым») называют запрос, который страница одного сайта шлёт на другой сайт: ты открыл evil.com, а запрос ушёл на bank.example. Обратный случай — same-site, «в пределах своего сайта».
  • Атрибутом куки SameSite сервер говорит браузеру, прикладывать ли её к межсайтовым запросам: Strict — никогда, Lax — только при обычном переходе по ссылке, None — всегда. Это встроенный в браузер тормоз для CSRF, подробности в главе 10.4.
  • CORS (Cross-Origin Resource Sharing, «совместный доступ к ресурсам между источниками») — набор заголовков, которыми сервер разрешает браузеру отдать свой ответ скрипту с чужого адреса. Именно разрешает: запрет браузер ставит сам, и по умолчанию скрипт с app.example.com не прочитает ответ от api.example.com. Об этом тоже в главе 10.4.
Сессии — состояние на сервере Браузер Cookie: sid=8f3a… каждый запрос Инстанс API любой из N lookup Redis / таблица сессий sid → {uid, roles, exp} + отзыв мгновенный: DEL sid — и юзер вышел + данные всегда свежие, размер куки ~30 байт − поход в общий стор на КАЖДЫЙ запрос − стор сессий = единая точка отказа и узкое место JWT — состояние у клиента Клиент Bearer eyJhbGci… каждый запрос Инстанс API любой из N verify ключ в памяти процесса HMAC-секрет или публичный ключ + ноль обращений в стор, инстансы независимы + масштабируется горизонтально без общей БД − отозвать до exp нельзя: токен валиден, пока жив − 300–800 байт в каждом запросе, данные протухают Одна фраза, которая закрывает вопрос Сессия — ссылка на состояние, JWT — само состояние. Отсюда вся разница: цена проверки, возможность отзыва, размер, свежесть данных.
Где живёт состояние. Остальное вытекает отсюда. Нужен мгновенный отзыв, значит, нужен и стор; без стора придётся мириться с тем, что токен живёт до exp.
КритерийСессии (cookie + стор)JWT (Bearer)
Где состояниена сервере, клиент носит непрозрачный idвнутри токена у клиента
Проверка запросачтение из Redis/БД — сеть, ~0.2–1 мспроверка подписи в памяти — микросекунды
Отзывмгновенный: удалить записьневозможен без доп. механизма (список отзыва, версия токена)
Смена правприменяется со следующего запросатолько после протухания токена
Масштабированиенужен общий стор (или sticky sessions — хуже)инстансы полностью независимы
Размер на запрос~30–50 байт300–1500 байт, растёт с claims; попадает в каждый запрос и в логи
Кросс-доменностьсложно (кука привязана к домену, SameSite)легко — заголовок Authorization
Мобильные и сервис-к-сервисунеудобноестественно
Основной рисккража куки, CSRF, компрометация сторакража токена (XSS), утечка ключа подписи, невозможность отозвать
Когда братьмонолит, классический веб, админки, всё, где нужен мгновенный logoutмикросервисы, SPA + мобильные, API третьих лиц, федерация
Как отвечают сеньоры

«JWT нужен там, где проверяющей стороне нельзя ходить в общий стор: много сервисов, федерация, внешние клиенты. Обычному веб-приложению с одним бэкендом проще и дешевле сессия в Redis, и logout с ней мгновенный. Чаще всего ошибаются так: берут JWT ради моды, а потом прикручивают к нему blacklist в Redis с проверкой на каждый запрос. Получается та же сессия, только с токеном в килобайт и лишней криптографией.» В проде прижился компромисс: короткоживущий access-JWT (5–15 минут) + refresh-токен, хранимый на сервере. Проверка идёт быстро и без стора, а отзыв запаздывает не больше чем на TTL access-токена.

Устройство JWT

JWT (RFC 7519) состоит из трёх частей через точку: header.payload.signature. Каждая закодирована base64url (алфавит без + и /, без паддинга, чтобы спокойно жить в URL). Но кодирование ещё не шифрование: payload прочитает кто угодно.

header (base64url) eyJhbGciOiJIUzI1NiIsInR5… . payload / claims (base64url) eyJzdWIiOiJ1c2VyLTEyMyIsImV4cCI6MTc… . signature dBjftJeZ4CVP-mB92K27uhbUJU… { "alg": "HS256", "typ": "JWT", "kid": "2026-01" } { "iss": "auth.example.com", "sub": "user-123", "aud": "api.example.com", "exp": 1767225600, "iat": 1767225000, "jti": "a1b2c3", "roles": ["editor"] } HMACSHA256( b64(header) + "." + b64(payload), secret) это ПОДПИСЬ, а не шифрование base64url — не шифрование Payload прочитает любой, кто перехватил токен: jwt.io декодирует его без всякого ключа. Подпись даёт целостность и авторство, но не конфиденциальность. Пароли, номера карт, персональные данные в claims класть нельзя — для тайны есть JWE.
Три части JWT. kid в заголовке указывает, каким ключом подписан токен, и благодаря ему ключи можно ротировать без даунтайма.

Стандартные claims, которые обязан назвать кандидат:

ClaimСмыслЧто будет, если не проверить
issкто выпустилпримешь токен от чужого issuer, если ключ случайно совпал или доверяешь нескольким
subкому выдан (id пользователя)
audдля какого сервисаconfused deputy: токен, выданный для сервиса A, примет сервис B
expсрок годности (unix seconds)токен живёт вечно — главная дыра
nbfне действителен раньше
iatкогда выпущеннельзя отсечь «токены старше момента X» при массовом отзыве
jtiуникальный id токенанечем адресовать токен в списке отзыва

HS256 против RS256

HS256 (HMAC-SHA256)RS256 / ES256 (асимметричные)
Ключиодин общий секретприватный подписывает, публичный проверяет
Кто может выпустить токенлюбой, у кого есть секрет — то есть все проверяющие сервисытолько владелец приватного ключа
Раздача ключейсекрет надо безопасно доставить всемпубличный ключ раздаётся открыто (JWKS-эндпоинт)
Скоростьочень быстроподпись дороже, проверка RSA быстрая; ES256 компактнее
Размер подписи32 байтаRSA-2048 — 256 байт; ECDSA P-256 — 64 байта
Когда братьмонолит или один сервис, который и выпускает, и проверяетмикросервисы, внешние потребители, OIDC-провайдер

Главный аргумент за RS256: принцип наименьших привилегий. С HS256 любой сервис, который проверяет токен, технически может его и подделать. Достаточно взломать один второстепенный сервис, чтобы выписать себе админский токен. С RS256 приватный ключ есть только у auth-сервиса, остальные тянут публичные ключи с /.well-known/jwks.json и кэшируют их по kid.

Классические атаки на JWT, о них спросят наверняка
  • alg: none. Атакующий меняет заголовок на {"alg":"none"}, выкидывает подпись и правит payload ("role":"admin"). Наивная библиотека «уважает» заголовок и говорит «подпись валидна, потому что её не требуется». Отсюда правило: алгоритм задаёт сервер, а не токен.
  • Подмена алгоритма RS256 → HS256. Сервер ждёт RSA и передаёт в проверку публичный ключ. Атакующий подписывает токен HMAC-ом, взяв публичный ключ как секрет: он же общедоступен. Если код слепо берёт alg из токена, подпись сойдётся. Защищает жёсткий whitelist допустимых алгоритмов.
  • Непроверенный exp делает токен вечным. Многие библиотеки проверяют exp, только если он есть, так что токен без этого claim тоже дыра.
  • Непроверенные aud/iss: токен соседнего сервиса или чужого тенанта примут как свой.
  • Подмена kid: если kid подставляют в путь к файлу ключа или в SQL-запрос, получается path traversal или инъекция. kid такой же недоверенный ввод, ключ по нему ищут только в whitelist.
  • Словарное слово вместо секрета. HS256 с секретом secret ломается hashcat за секунды. Нужно минимум 32 случайных байта.
// Правильная проверка на golang-jwt/jwt/v5
type Claims struct {
    Roles []string `json:"roles"`
    jwt.RegisteredClaims
}

var claims Claims
token, err := jwt.ParseWithClaims(raw, &claims,
    func(t *jwt.Token) (any, error) {
        // 1. семейство алгоритмов фиксируем сами
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("неожиданный alg: %v", t.Header["alg"])
        }
        // 2. kid ищем только по whitelist, никаких путей и SQL
        kid, _ := t.Header["kid"].(string)
        key, ok := keyring[kid]
        if !ok {
            return nil, errors.New("неизвестный kid")
        }
        return key, nil
    },
    jwt.WithValidMethods([]string{"HS256"}), // 3. дублируем whitelist явно
    jwt.WithIssuer("auth.example.com"),      // 4. iss
    jwt.WithAudience("api.example.com"),     // 5. aud
    jwt.WithExpirationRequired(),            // 6. exp обязателен
    jwt.WithLeeway(30*time.Second),          // допуск на расхождение часов
)
if err != nil || !token.Valid {
    problem(w, http.StatusUnauthorized, "невалидный токен")
    return
}

Где хранить токен на клиенте

Вопрос-ловушка: «правильного» варианта нет, ты выбираешь, от какой атаки страдать.

localStorage / sessionStoragehttpOnly + Secure + SameSite cookie
Доступ из JSесть — любой XSS крадёт токеннет: document.cookie её не видит
Уязвим к CSRFнет: заголовок надо проставить рукамида: браузер сам шлёт куку — нужен SameSite и/или CSRF-токен
Отправкавручную: Authorization: Bearerавтоматически браузером
Кросс-доменпросто, работает и в мобильном приложениисложно: SameSite=None; Secure + CORS с credentials
Утечка в логизаголовок часто пишут в access-логи и трассировкукука тоже может попасть в логи, но реже
Как отвечать

«Из localStorage токен достанет любой JS: одна XSS-дыра, и токен уехал атакующему, причём навсегда, потому что отозвать JWT нечем. Куку с HttpOnly из JS не прочитать, поэтому XSS не сможет её украсть. Зато сможет слать запросы от имени пользователя прямо из его браузера, так что XSS остаётся катастрофой в любом случае. Практический дефолт для веба: Set-Cookie: token=…; HttpOnly; Secure; SameSite=Lax; Path=/ плюс CSRF-защита. SPA на другом домене и мобильным подходит Authorization: Bearer, а токен держим в памяти JS (переменная в замыкании), refresh кладём в httpOnly-куку с Path=/auth/refresh. Тогда XSS не достанет ни долгоживущий refresh, ни токен после перезагрузки страницы.»

http.SetCookie(w, &http.Cookie{
    Name:     "session",
    Value:    token,
    Path:     "/",
    HttpOnly: true,                    // недоступна из JS
    Secure:   true,                    // только по HTTPS
    SameSite: http.SameSiteLaxMode,    // база защиты от CSRF
    MaxAge:   int((15 * time.Minute).Seconds()),
    // Domain намеренно не задаём: без него кука привязана к конкретному хосту
    // и не утечёт на поддомены (__Host- префикс дал бы то же самое жёстче)
})

Refresh-токены: зачем, ротация, обнаружение кражи

Дилемма такая: с длинным TTL access-токена удобно, но украденный токен долго живёт; короткий TTL безопасен, но заставляет логиниться каждые 10 минут. Поэтому выдают пару токенов:

  • Access — JWT, 5–15 минут, живёт в памяти клиента, летит в каждый запрос, проверяется без похода в БД.
  • Refreshнепрозрачная случайная строка (не JWT), 7–30 дней, хранится в БД в хешированном виде, летит только на /auth/refresh, привязан к устройству и сессии.

Refresh специально делают непрозрачным: его всё равно сверяют с БД, так что подпись не нужна, а отозвать его можно одной строкой UPDATE. В БД кладут хеш (хватит SHA-256: энтропии 256 бит, словарём не подберёшь), чтобы дамп базы не давал готовых сессий.

Ротация и обнаружение переиспользования

Ротация — плановая замена секрета: новый вводят, старый тут же выводят из обращения. Это рутина, а не «поменяли пароль, потому что испугались», и идёт она по расписанию (ключи подписи, сертификаты, API-ключи) или по событию. У refresh-токена событие одно: каждое обновление.

При каждом обновлении старый refresh инвалидируется, а клиент получает новый. Заодно выходит бесплатный детектор кражи: если кто-то предъявил уже использованный refresh, значит, токен ходит по рукам. Кто из двоих вор, не понять, поэтому убивают всё семейство токенов и заставляют залогиниться заново.

CREATE TABLE refresh_tokens (
    id          uuid PRIMARY KEY,
    family_id   uuid NOT NULL,          -- цепочка ротаций одной сессии
    user_id     bigint NOT NULL,
    token_hash  bytea NOT NULL UNIQUE,  -- sha256(секрет), не сам секрет
    used_at     timestamptz,            -- NOT NULL => уже потрачен
    revoked_at  timestamptz,
    expires_at  timestamptz NOT NULL,
    user_agent  text,
    ip          inet
);
CREATE INDEX ON refresh_tokens (family_id) WHERE revoked_at IS NULL;
func (s *Auth) Refresh(ctx context.Context, raw string) (Pair, error) {
    h := sha256.Sum256([]byte(raw))

    tx, _ := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
    defer tx.Rollback()

    var t token
    // FOR UPDATE: параллельный refresh ждёт блокировку и потом видит used_at.
    // Без неё оба выпустят по живой паре, и кражу никто не заметит
    err := tx.QueryRowContext(ctx,
        `SELECT id, family_id, user_id, used_at, revoked_at, expires_at
           FROM refresh_tokens WHERE token_hash = $1 FOR UPDATE`, h[:]).Scan(...)
    if errors.Is(err, sql.ErrNoRows) {
        return Pair{}, ErrUnauthorized
    }

    switch {
    case t.RevokedAt.Valid || t.ExpiresAt.Before(time.Now()):
        return Pair{}, ErrUnauthorized
    case t.UsedAt.Valid:
        // переиспользование: токен уже меняли, значит, утёк. Рубим всё семейство
        _, _ = tx.ExecContext(ctx,
            `UPDATE refresh_tokens SET revoked_at = now()
              WHERE family_id = $1 AND revoked_at IS NULL`, t.FamilyID)
        _ = tx.Commit()
        s.alert(ctx, "refresh token reuse", t.UserID, t.FamilyID)
        return Pair{}, ErrUnauthorized
    }

    tx.ExecContext(ctx, `UPDATE refresh_tokens SET used_at = now() WHERE id = $1`, t.ID)
    newRaw, newHash := generateToken()  // 32 байта из crypto/rand
    tx.ExecContext(ctx, `INSERT INTO refresh_tokens
        (id, family_id, user_id, token_hash, expires_at) VALUES ($1,$2,$3,$4,$5)`,
        uuid.NewV7(), t.FamilyID, t.UserID, newHash, time.Now().Add(30*24*time.Hour))
        // v7 сортируется по времени: вставки ложатся в хвост индекса, а не разбрасываются

    if err := tx.Commit(); err != nil { return Pair{}, err }
    return Pair{Access: s.issueJWT(t.UserID), Refresh: newRaw}, nil
}
Глубже: гонка при ротации

В продакшене это настоящая боль. Мобильное приложение шлёт три запроса разом, все три получают 401 и все три идут обновлять токен одним и тем же refresh. Один выигрывает, двое предъявляют «уже использованный» токен, и система честно разлогинивает пользователя, который ничего плохого не делал. Лечат тремя способами, обычно всеми сразу: (1) на клиенте ставят единый мьютекс/промис на обновление, остальные ждут его результата; (2) на сервере дают grace-период: использованный токен ещё 10–30 секунд валиден и отдаёт тот же новый токен (идемпотентный refresh); (3) берут FOR UPDATE или уникальный индекс, чтобы гонка внутри БД разрешалась детерминированно. Назовёшь эту проблему сам, и станет ясно, что ты с этим действительно работал.

Как разлогинить пользователя с JWT

Если коротко, по-настоящему никак. Подписанный токен валиден, пока не истёк exp, и проверяющий сервер не обязан ни с кем сверяться. Остаётся выбрать компромисс.

ПриёмКак работаетЦенаЗадержка отзыва
Короткий TTL + refreshaccess живёт 5–15 мин; при logout удаляем refresh — обновиться уже не выйдетноль, это и так нужнодо конца TTL access
Blacklist по jtiRedis SET jti 1 EX ttl, проверка на каждый запроспоход в Redis на каждый запрос — теряется главный плюс JWTмгновенно
Версия токена (token_version)в claims кладём ver; в БД у юзера счётчик; logout/смена пароля увеличивают счётчикчтение версии (кэшируется в памяти на 5–30 с)мгновенно или до конца кэша
not_before по пользователюхраним «токены, выпущенные раньше T, недействительны»; сравниваем с iatто же, что версиямгновенно
Убить сессию на клиентестереть токен из хранилищанольне защищает: украденный токен продолжает работать
Что сказать на собесе

«Стандартный logout: удаляю refresh-токен и стираю access на клиенте, access доживает свои 5–15 минут. Если бизнесу нужен мгновенный отзыв (бан, кража аккаунта, смена пароля), добавляю token_version в claims и в БД. Версия кэшируется в памяти инстанса на десятки секунд, а инкремент убивает все токены пользователя разом. Полный blacklist по jti делаю только точечно, потому что он превращает stateless-схему в stateful.» Отдельно проговори, что смена пароля обязана инвалидировать все сессии: это требование, а не опция.

OAuth 2.0 и OIDC

OAuth 2.0 придуман для делегирования доступа, а не для логина: «дай этому приложению читать мои репозитории, не отдавая ему пароль от GitHub». OIDC надстраивается над OAuth тонким слоем и добавляет к нему аутентификацию: новый скоуп openid, дополнительный id_token (JWT с данными о пользователе), эндпоинт /userinfo и дискавери /.well-known/openid-configuration. Фраза «OAuth — авторизация, OIDC — аутентификация поверх неё» закрывает половину вопроса.

РольКто этоПример
Resource Ownerпользователь, владелец данныхчеловек с аккаунтом
Clientприложение, которое хочет доступнаш веб-сервис, SPA, мобильное приложение
Authorization Serverвыдаёт токены, аутентифицирует пользователяKeycloak, Auth0, Яндекс ID, «Сбер ID»
Resource ServerAPI, которое принимает access-токеннаш API, GitHub API

Клиенты делятся на confidential (серверные, умеют хранить client_secret) и public (SPA и мобильные: секрет там спрятать негде, любой выковыряет его из бандла или apk). Для public-клиентов PKCE и придумали.

Пользователь / браузер Клиент (наше приложение) Authorization Server Resource API 1. verifier = случайные 43–128 символов challenge = base64url(SHA256(verifier)) 2. редирект на /authorize client_id · redirect_uri · scope · state · code_challenge · S256 3. GET /authorize 4. логин + экран согласия 5. 302 на redirect_uri?code=…&state=… 6. GET /callback 7. сверить state — иначе CSRF на логине 8. POST /token: code + code_verifier 9. SHA256(verifier) == challenge? 10. access_token + refresh_token (+ id_token) 11. Authorization: Bearer access_token Зачем PKCE Перехваченный код бесполезен без verifier: атакующий видел только его SHA256, а обменять код можно один раз. Изначально PKCE придумали для мобильных (кастомную схему редиректа может перехватить чужое приложение), но OAuth 2.1 требует его для ВСЕХ клиентов. Implicit flow (response_type=token) объявлен устаревшим: токен светился прямо в URL.
Authorization code + PKCE. Код летит через браузер, и его можно перехватить. Но меняют код на токен по прямому серверному каналу, и для обмена нужен code_verifier, которого у перехватчика нет.
Что обязательно назвать про этот flow
  • state защищает от CSRF на этапе логина: без него атакующий подсовывает свой code, и жертва «логинится» в аккаунт атакующего. Значение случайное, привязано к сессии, проверяется на колбэке. В OIDC ту же роль для id_token играет nonce.
  • redirect_uri сверяется на AS по точному совпадению с зарегистрированным. Wildcard и «начинается с» оставляют классическую дыру: через неё код уводят на чужой домен (open redirect + перехват кода).
  • Код одноразовый и короткоживущий (обычно 30–60 секунд). Если его предъявили повторно, выданные по нему токены надо отозвать.
  • Почему умер implicit flow: токен приходил прямо в URL-фрагменте и попадал в историю браузера, в Referer, в логи прокси, а refresh-токена не было вовсе. Его место занял code+PKCE.
  • Другие гранты: client_credentials для сервис-к-сервису (пользователя нет вообще), device_code для телевизоров и CLI. password (ROPC) устарел и запрещён в OAuth 2.1: клиент видит пароль пользователя.

API-ключи и Basic auth — где уместны

МеханизмКак выглядитГде уместенЧего не умеет
BasicAuthorization: Basic base64(user:pass)внутренние утилиты, healthcheck, прототипы — только поверх TLSнет срока жизни, пароль летит в каждом запросе, нет скоупов, легко утекает в логи
API-ключAuthorization: Bearer sk_live_… или X-API-Keyинтеграции сервис-к-сервису, вебхуки, партнёрские APIнет пользователя, нет TTL по умолчанию, нет делегирования
OAuth-токенAuthorization: Bearer eyJ…пользовательские сценарии, сторонние приложениясложнее в реализации
mTLSклиентский сертификатвнутренний трафик, service mesh, банковские интеграциинужен PKI и ротация сертификатов
HMAC-подпись запросаподпись тела + timestamp + nonceвебхуки и платёжные API (защищает и от подмены тела, и от replay)сложнее для клиента
Правила для API-ключей
  • Ключ берут из crypto/rand, 32 случайных байта, и добавляют префикс-метку (sk_live_, sk_test_): по префиксу секрет-сканеры ловят утечки в публичных репозиториях.
  • В БД лежит хеш, а не сам ключ; пользователь видит ключ один раз, при создании.
  • Ключ передают только в заголовке Authorization, никогда в query-параметре: URL оседает в логах nginx, в Referer и в истории браузера.
  • У ключа должны быть владелец, скоупы, срок жизни, метка «последнее использование» и кнопка отзыва. Ротируют с окном, в котором валидны оба ключа.
  • Сравнивают через subtle.ConstantTimeCompare или по хешам, но не == по сырой строке.

RBAC, ABAC и middleware авторизации

RBAC (role-based): у пользователя роли, у ролей — права, а проверка звучит как «есть ли у роли право posts:delete». Просто, понятно, легко аудируется, но плохо выражает «можно, если это твой пост, рабочее время и сумма меньше 10 000». В ABAC (attribute-based) решение считается функцией от атрибутов субъекта, объекта, действия и среды. Гибко, но отлаживать труднее, и понять, «почему отказали», тоже.

RBACABAC
Модельuser → roles → permissionspolicy(subject, object, action, env) → allow/deny
Пример«редактор может удалять посты»«автор может удалять свой пост в первые 24 часа»
Плюсыпредсказуемость, простой аудит, легко объяснить бизнесувыражает любые правила, не плодит роли
Минусывзрыв числа ролей (editor_ru_premium_readonly)сложность, риск «почему нет доступа?» без внятного ответа
Инструментытаблицы в БД, CasbinOPA/Rego, Casbin, Cedar, SpiceDB (ReBAC)

В жизни почти всегда встречается гибрид: RBAC грубо фильтрует на входе (middleware), а владение и условия точечно проверяют в сервисном слое.

// Middleware: только грубая проверка «есть ли вообще такое право»
func RequirePerm(perm string) Middleware {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            u, ok := UserFrom(r.Context())
            if !ok {
                w.Header().Set("WWW-Authenticate", `Bearer realm="api"`)
                problem(w, http.StatusUnauthorized, "нужна аутентификация")
                return
            }
            if !u.Can(perm) {
                problem(w, http.StatusForbidden, "недостаточно прав")
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

mux.Handle("DELETE /v1/posts/{id}", RequirePerm("posts:delete")(deletePost))
Главная ошибка: авторизация только в middleware

Middleware знает про маршрут, но ничего не знает про объект. Он пропустит DELETE /v1/posts/777 от любого редактора, даже если пост чужой. Это IDOR / broken object level authorization, номер один в OWASP API Top 10. Владение и условия проверяют там, где объект уже загружен, то есть в сервисе, а лучше прямо в запросе: UPDATE posts SET … WHERE id = $1 AND author_id = $2. Ноль затронутых строк означает «нет прав или нет объекта». Второе правило, deny by default: новый роут без явного разрешения должен быть закрыт, а не открыт. Тест «а что если убрать декоратор» стоит автоматизировать: пройтись по всем зарегистрированным роутам и убедиться, что у каждого есть политика.

Хранение паролей

Пароль нельзя ни хранить в открытом виде, ни шифровать (расшифровать сможет и атакующий с ключом), ни хешировать быстрым алгоритмом. Нужна намеренно медленная функция с солью.

Соль (salt) — уникальная случайная строка, своя для каждого пароля: её подмешивают к паролю перед хешированием и хранят рядом с хешем открыто. Прятать её не нужно, смысл не в этом, а в чём, разобрано во врезке ниже.

АлгоритмСтойкость к GPU/ASICПараметрыВердикт
MD5, SHA-1, SHA-256никакой: миллиарды хешей в секунду на одной видеокартенельзя — они спроектированы быстрыми
PBKDF2средняя (мало памяти → хорошо параллелится на GPU)600 000+ итераций HMAC-SHA256только если требует регулятор/FIPS
bcryptхорошаяcost 12 (2^12 итераций)надёжная классика, есть в stdlib-экосистеме
scryptхорошая (memory-hard)N=2^17, r=8, p=1ок
argon2idлучшая: memory-hard + защита от side-channelминимум OWASP: m=19 МиБ, t=2, p=1; практичнее m=64 МиБ, t=3, p=4дефолт для нового кода
// bcrypt: соль внутри самого хеша
hash, err := bcrypt.GenerateFromPassword(
    []byte(pass), 12)         // cost 12 ≈ 250 мс

err = bcrypt.CompareHashAndPassword(
    hash, []byte(pass))       // сравнение constant-time
// bcrypt учитывает только 72 байта пароля,
// а GenerateFromPassword на более длинном
// вернёт ErrPasswordTooLong. Длинную фразу
// заранее сворачивают в SHA-256 (или берут argon2)
// argon2id
salt := make([]byte, 16)
rand.Read(salt) // с Go 1.24 ошибки не бывает
key := argon2.IDKey([]byte(pass), salt,
    3,          // time: число проходов
    64*1024,    // memory: 64 МиБ
    4,          // threads
    32)         // длина ключа

ok := subtle.ConstantTimeCompare(key, stored) == 1
// в БД: соль + все параметры + хеш,
// иначе не сможешь их поднять при ротации
Соль, перец и timing
  • Соль своя у каждого пароля и лежит рядом с хешем открыто. Прятать пароль она не должна: соль ломает rainbow-таблицы и делает так, что два одинаковых пароля дают разные хеши (иначе видно, у кого пароль 123456).
  • Перец (pepper) — общий секрет, который живёт не в БД, а в HSM/Vault или в конфиге приложения. Его подмешивают к паролю или берут ключом HMAC поверх хеша. Если утекла только база, хеши без перца бесполезны. Минус в том, что перец просто так не сменишь.
  • Сравнение за constant-time. Обычное == для строк идёт побайтово с ранним выходом и в теории позволяет подбирать значение байт за байтом. Для токенов и HMAC subtle.ConstantTimeCompare обязателен.
  • User enumeration. Если на несуществующий логин отвечать мгновенно, а на существующий через 250 мс, атакующий соберёт базу логинов по таймингу. Поэтому, когда пользователя нет, всё равно прогоняют фиктивный хеш и отвечают одинаковым «неверный логин или пароль».
  • Ротация параметров. Cost/params хранят вместе с хешем. Если при успешном логине параметры оказались устаревшими, хеш пересчитывают с новыми: только в этот момент у тебя на руках открытый пароль.
  • Rate limit на логин и блокировка после N неудач: без них против онлайн-перебора бесполезна любая медленная функция.

Секреты приложения

Под секретом понимают пароль БД, ключ подписи JWT, токен платёжного провайдера. Требований к ним три: не попасть в git, не светиться в логах и уметь меняться без релиза.

СпособПлюсыМинусы
Переменные окруженияпросто, поддерживают все платформы, 12-factorвидны в /proc/PID/environ и в дампах; часто утекают в логи краш-репортеров и в docker inspect; ротация = рестарт
Файл, смонтированный в контейнерможно обновлять без рестарта, права на файлнужно уметь перечитывать
Kubernetes Secretнативно, RBAC, монтируется как файл или envэто base64, а не шифрование; без включённого encryption-at-rest в etcd лежит почти открытым
Vault / облачный KMSцентрализованно, аудит, TTL, динамические учётки БД, ротацияещё одна зависимость в рантайме, нужна аутентификация самого приложения

Vault AppRole — стандартный способ аутентифицировать сервис без участия человека. У приложения есть role_id (не секрет, лежит в конфиге) и secret_id (одноразовый, короткоживущий, приходит отдельным каналом: от оркестратора или через response wrapping). Приложение меняет пару на токен с TTL, регулярно продлевает его (renew) и по этому токену читает секреты. role_id и secret_id разделены, чтобы утечка одного артефакта не давала доступа. Есть и отдельный джокер, динамические секреты: Vault сам создаёт временного пользователя в PostgreSQL с TTL 1 час, и «украсть пароль от БД» становится почти бессмысленно.

Что нельзя в git и что делать, если уже
  • Нельзя: .env с боевыми значениями, приватные ключи, kubeconfig, дампы с продовыми данными, docker-compose.yml с реальными паролями. В репозитории живёт только .env.example с пустышками.
  • Удалить файл новым коммитом недостаточно: секрет остался в истории и в форках. Порядок строгий: сначала отозвать и перевыпустить секрет, потом (по желанию) чистить историю git filter-repo/BFG. Ротация обязательна, чистка истории — косметика.
  • В CI ставят gitleaks/trufflehog на pre-commit и в пайплайн.
  • Секрет не должен попадать в логи: не логируй целиком конфиг и заголовки, дай секретным типам String(), который возвращает "[REDACTED]", а в структурированном логгере заведи список замаскированных полей.
  • Ротация должна быть рутиной, а не подвигом: держишь два активных ключа с kid, выкатываешь новый, переключаешь подпись, через окно снимаешь старый.

Вопросы

10
Суть: аутентификация отвечает на вопрос «ты действительно тот, кем назвался», авторизация — «что тебе можно делать». Первое даёт 401, второе — 403. Порядок всегда такой: сначала AuthN, потом AuthZ.

Разложить по полочкам

  • Идентификация: ты предъявляешь идентификатор (логин, email, client_id). Сама по себе она ничего не доказывает.
  • Аутентификация (AuthN) проверяет доказательство: пароль, одноразовый код, подпись JWT, клиентский сертификат mTLS. После неё «мы знаем, кто это».
  • Авторизация (AuthZ) — решение о доступе к конкретному ресурсу и действию: роль, право, политика, владение объектом. На выходе «можно/нельзя».
  • Аудит записывает, кто что сделал. Про него часто забывают, а на собесе в финтехе спрашивают отдельно.

Почему путаница живучая

Виноват сам HTTP: статус 401 называется Unauthorized, хотя означает «не аутентифицирован». По смыслу 401 говорит «предъяви (другие) учётные данные» и обязан нести заголовок WWW-Authenticate, а 403 значит «мы поняли, кто ты, и тебе нельзя»: перевыпуск токена тут не поможет.

Практическое следствие для кода

Это два разных слоя. Аутентификацию делает общий middleware на всё приложение: разбирает заголовок, проверяет подпись или сессию, кладёт subject в контекст. Авторизация живёт ближе к домену. Middleware закрывает грубые вещи (роль admin на /admin/*), а «этот заказ принадлежит этому пользователю» можно проверить только там, где известен заказ, то есть в сервисном слое.

На чём ловят

Вопрос-продолжение: «а 403 отдавать всегда?» Нет. Если само существование ресурса надо скрыть (приватный репозиторий, чужой платёж), правильнее отдать 404: 403 подтверждает, что объект есть, а это уже утечка информации. GitHub так и делает.

Суть: сессия — ссылка на состояние на сервере (мгновенный отзыв, но поход в хранилище на каждый запрос); JWT — самодостаточный подписанный факт (проверка локальная и дешёвая, но отозвать до истечения нельзя без того самого состояния, от которого убегали).

Сравнение по осям

ОсьСессия (cookie + стор)JWT
Где состояниена сервере: Redis/БДу клиента, в самом токене
Проверка запросачтение из стора (сеть ~1 мс)проверка подписи локально (микросекунды)
Отзывмгновенный: DEL session:IDневозможен без чёрного списка
Изменение правприменяется со следующего запросатолько после переиздания токена
Размер на запрос~30–60 байт0.5–2 КиБ в каждом запросе
Горизонтальное масштабированиенужен общий стор (не sticky sessions)инстансы независимы, нужен только публичный ключ
Межсервисностьсложно: у каждого сервиса нет доступа к сторусильная сторона: подпись проверяет кто угодно
SPOFлёг Redis — легли все логинынет зависимости на горячем пути

Главный аргумент, который ждут

Стоит приделать к JWT чёрный список отозванных токенов, и в Redis придётся ходить на каждый запрос. Возвращается ровно та зависимость, от которой JWT и должен был избавить. Поэтому формулировка такая: JWT покупает дешёвую распределённую проверку ценой окна, в котором токен не отозвать; окно равно TTL токена. Всё проектирование сводится к тому, чтобы окно было маленьким (5–15 минут) и закрывалось refresh-токеном, у которого состояние уже есть.

Как выбирать

  • Классическому веб-приложению с одним бэкендом и браузерными клиентами хватит сессий: проще, безопаснее, отзыв бесплатный. Это до сих пор дефолт, а не легаси.
  • Публичный API, мобильные клиенты, десяток сервисов, которым нужно проверять токен без общего стора — JWT (короткий access + серверный refresh).
  • В проде чаще всего побеждает гибрид: JWT-access на 10 минут для межсервисных проверок + серверная запись о сессии/refresh для отзыва и списка устройств.
Про «stateless» без розовых очков

Полностью stateless JWT-система не умеет разлогинить со всех устройств, показать «активные сессии», немедленно забанить пользователя, отозвать доступ при смене пароля, снизить права. Всё это продуктовые требования, они появляются на втором году жизни сервиса и возвращают состояние.

Суть: три base64url-части через точку, подпись считается по header.payload. HS256 — общий секрет (симметрично, годится внутри одного сервиса), RS256/ES256 — приватный ключ подписывает, публичный проверяет (для многих потребителей). Хранить лучше в httpOnly-куке с SameSite, TTL access-токена — минуты.

Структура

// eyJhbGciOi… . eyJzdWIiOi… . 4pcPyMD0…   (три части base64url через точку)
// header:    {"alg":"HS256","typ":"JWT","kid":"2024-06"}
// payload:   {"sub":"123","exp":1700000000,"iat":...,"iss":"api","aud":"web","jti":"..."}
// signature: HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)

Вслух обязательно скажи: base64 не шифрует. Payload читает любой, у кого есть токен, поэтому персональные данные, внутренние идентификаторы и суммы в токен не кладут. Подпись гарантирует целостность и авторство, а не конфиденциальность (для шифрования есть JWE, отдельная спецификация, в вебе она почти не встречается).

HS256 против RS256

HS256 (HMAC)RS256 / ES256
Ключиодин общий секретпара: приватный подписывает, публичный проверяет
Кто может подписатьвсе, кто может проверитьтолько владелец приватного ключа
Скоростьочень быстроподпись дороже, проверка RSA быстрая; ES256 компактнее
Раздача ключасекрет в каждый сервис — риск утечки растёт линейнопубличный ключ по JWKS-эндпоинту, кэшируется по kid
Когдамонолит, один эмитент и один потребительмного сервисов, внешние потребители, OIDC

Где хранить на клиенте

  • localStorage доступен любому JS на странице, так что любой XSS = кража токена. Зато CSRF ему не страшен: браузер сам ничего оттуда не отправляет.
  • httpOnly; Secure; SameSite=Lax-куку JS не прочитает, и XSS её не украдёт. Но браузер прикрепляет её сам, поэтому нужна защита от CSRF (SameSite плюс, для критичных операций, токен).
  • Мобильное приложение — Keychain/Keystore, не SharedPreferences.
  • Продвинутый вариант для SPA: access-токен живёт только в памяти JS (в переменной, не в хранилище), refresh лежит в httpOnly-куке с узким Path=/auth/refresh. После перезагрузки вкладки срабатывает тихий refresh.

Срок жизни

Access живёт 5–15 минут, и это окно, в котором отозванный доступ ещё работает. Refresh живёт от нескольких дней до месяца, с ротацией. Обязательные поля: exp (иначе токен вечный), iat, iss и aud (иначе токен от соседнего сервиса примут как свой), а если нужен поштучный отзыв, ещё и jti. alg проверяют по белому списку своих алгоритмов, а не берут из заголовка токена, иначе открываются классические атаки alg: none и подмена RS256 на HS256 с публичным ключом в роли секрета.

Суть: refresh существует, чтобы access мог быть коротким. Access ходит в каждом запросе и потому светится везде; refresh лежит в одном месте, уходит только на /auth/refresh, хранится на сервере — и потому его можно отозвать.

Зачем разделение

Будь токен один, пришлось бы выбирать между удобством (долгий TTL, пользователя не разлогинивает) и безопасностью (короткий TTL, украденный токен быстро протухает). Два токена снимают этот компромисс: короткий access ограничивает ущерб от утечки, долгий refresh держит сессию. К тому же на refresh сервер обязательно идёт в своё хранилище и заодно может проверить, не забанен ли пользователь, не сменился ли пароль, не отозвано ли устройство.

Ротация (rotation)

На каждый обмен выдаётся новый refresh, старый помечается использованным. Токен становится одноразовым, и «украл один раз — пользуюсь вечно» перестаёт работать.

// обмен уже потраченного refresh = сигнал кражи
row, err := repo.GetRefresh(ctx, hash(presented))
switch {
case err != nil:
    return ErrInvalid
case row.UsedAt != nil:                 // токен уже обменивали
    repo.RevokeFamily(ctx, row.FamilyID) // рвём всю цепочку: и вора, и жертву
    audit.Warn("refresh reuse", "user", row.UserID)
    return ErrInvalid
case time.Now().After(row.ExpiresAt):
    return ErrExpired
}
newRT := random256()
repo.MarkUsedAndIssue(ctx, row.ID, hash(newRT), row.FamilyID)

Reuse detection

Идея взята из RFC 6819 и BCP по OAuth: у всех токенов одной сессии общий family_id. Если предъявлен уже использованный refresh, значит, копия токена есть у двоих: у вора и у жертвы. Кто из них пришёл, серверу неизвестно, поэтому убивают всю семью: настоящий пользователь просто перелогинится, а вор потеряет доступ. Так кражу ловят дёшево, без хитрого фингерпринтинга.

Отзыв и хранение

  • Refresh хранят хешированным (SHA-256), и утёкший дамп таблицы не даёт рабочих токенов.
  • Строка в БД: user_id, family_id, token_hash, expires_at, used_at, revoked_at, user_agent, ip, device_name. Из неё же вырастает экран «активные сессии».
  • Для отзыва проставляют revoked_at одной записи, семье или всем записям пользователя. Отсюда три кнопки: «выйти», «выйти на этом устройстве», «выйти везде».
  • Смена пароля обязана отзывать все refresh (кроме, по вкусу, текущей сессии).
  • Refresh кладут в куку httpOnly; Secure; SameSite=Strict; Path=/auth/refresh: браузер не потащит его в обычные запросы, а XSS не прочитает.
Гонка при ротации

Мобильное приложение шлёт пять параллельных запросов, все получают 401 и все идут рефрешить одним и тем же токеном. Один выигрывает, четверо натыкаются на «reuse» и выкидывают пользователя. На клиенте спасает single-flight (один рефреш, остальные ждут его результат), на сервере — grace-окно 10–30 секунд: повторный обмен тем же токеном в этом окне возвращает тот же новый токен из журнала и кражей не считается.

Суть: подписанный токен нельзя «отменить» — он валиден до exp по построению. Значит логаут — это либо удаление токена у клиента плюс отзыв refresh (окно = TTL access), либо явный чёрный список, за который платим походом в Redis на каждый запрос.

Варианты по возрастанию цены

  1. Просто выбросить токен на клиенте + отозвать refresh на сервере. Access живёт свои 5–15 минут. Для 95 % продуктов этого достаточно, и такой ответ вполне годится.
  2. Чёрный список по jti: SET revoked:jti 1 EX ttl_остаток. Ключ исчезнет сам, когда истечёт токен, поэтому список остаётся маленьким. Платишь за это GET в Redis на каждый запрос.
  3. Версия токенов пользователя (token_version/epoch): в payload кладём номер, в БД держим текущий. «Выйти везде» = инкремент. Проверка сводится к сравнению чисел и кэшируется в памяти инстанса с TTL 30 с. Это на порядок дешевле чёрного списка и покрывает главный сценарий.
  4. Не выдавать access вообще и вернуться к сессиям. Иногда это правильный ответ.
// вариант 3: одна цифра решает 90% задач отзыва
type Claims struct {
    jwt.RegisteredClaims
    Ver int `json:"ver"`
}
// при логауте-везде / смене пароля / бане:
//   UPDATE users SET token_version = token_version + 1 WHERE id = $1
func (a *Auth) check(ctx context.Context, c Claims) error {
    cur, err := a.verCache.Get(ctx, c.Subject) // локальный кэш на 30 секунд
    if err != nil { return err }
    if c.Ver != cur { return ErrRevoked }
    return nil
}

Что сказать, чтобы прозвучало зрело

«Логаут с JWT упирается не в технику, а в решение, какое окно рассинхронизации мы готовы терпеть. Ноль секунд стоит запроса в стор на каждый вызов, то есть возврата к состоянию. Пять минут бесплатны». Дальше отдельно проговори сценарии, где нужен именно ноль: увольнение сотрудника, компрометация устройства, бан за фрод, отзыв прав в админке. Для них обычно делают гибрид: короткий TTL для всех + чёрный список только для критичных ролей или только для эндпоинтов с деньгами.

Мелочи логаута, о которых забывают

Удалить куку правильно (Max-Age=0 и те же Path/Domain, иначе кука останется), отозвать refresh-семью, закрыть открытые WebSocket-соединения (они аутентифицированы один раз при handshake и живут часами), инвалидировать серверные кэши прав.

Суть: OAuth 2.0 — протокол делегирования доступа: приложение получает ограниченный токен к чужому API, не видя пароля пользователя. OIDC — тонкая надстройка над ним, которая добавляет id_token и превращает всё это в аутентификацию («войти через Google»).

Четыре роли

  • Resource Owner — пользователь, владелец данных.
  • Client — приложение, которое хочет доступ (твой бэкенд или SPA).
  • Authorization Server выдаёт токены (Google, Keycloak, Auth0).
  • Resource Server — API, которое проверяет токен и отдаёт данные.

Authorization code flow + PKCE

  1. Клиент редиректит браузер на /authorize с response_type=code, client_id, redirect_uri, scope, state и code_challenge.
  2. Пользователь логинится на стороне провайдера и подтверждает согласие. Твоё приложение пароля не видит, в этом и весь смысл.
  3. Провайдер редиректит обратно на redirect_uri?code=...&state=.... Одноразовый код живёт секунды.
  4. Бэкенд меняет код на токены запросом server-to-server на /token, предъявляя client_secret (или code_verifier для публичных клиентов).
  5. Получает access_token, refresh_token и, в случае OIDC, id_token.

Почему через код, а не сразу токен? Код летит через адресную строку браузера и оседает в истории, в логах прокси, в Referer. Поэтому в редиректе едет одноразовый код, бесполезный сам по себе, а настоящие токены приходят по защищённому каналу, куда браузер не заглядывает. Устаревший implicit flow отдавал токен прямо во фрагменте URL; сейчас его считают небезопасным, и в OAuth 2.1 он запрещён.

Два параметра, о которых спрашивают

  • state — случайное значение: его кладут в сессию и сверяют на возврате. Так флоу защищён от CSRF (иначе атакующий подсунет свой код и привяжет твой аккаунт к своему).
  • В PKCE клиент генерирует code_verifier, шлёт его SHA-256 как code_challenge, а при обмене предъявляет оригинал. Без verifier перехваченный код бесполезен. PKCE обязателен для SPA и мобильных (секрета там нет), а по OAuth 2.1 — вообще для всех.

Чем OIDC отличается

access_token в OAuth говорит «предъявитель имеет право на такие-то действия». Он адресован API, и клиенту разбирать его вообще не положено. id_token в OIDC всегда JWT, он адресован клиенту и говорит «этот пользователь только что аутентифицирован там-то»: sub, iss, aud, nonce, auth_time, профиль. Ещё OIDC стандартизует /.well-known/openid-configuration, JWKS-эндпоинт с публичными ключами и /userinfo. Часто ошибаются, принимая access_token за доказательство личности, а он может оказаться непрозрачной строкой, в которой нет вообще ничего.

Суть: оба — примитивные схемы «предъяви долгоживущий секрет». Уместны для машин, а не для людей: server-to-server интеграции, вебхуки, служебные и внутренние эндпоинты. Для пользовательских сессий не годятся ни тот, ни другой.

Basic auth

Это Authorization: Basic base64(user:pass), то есть пароль в открытом виде в каждом запросе: base64 ни от кого не защищает. Без TLS применять нельзя категорически. Логаута нет, истечения нет, браузер кэширует учётку и подставляет её сам. Уместен он там, где надо закрыть /metrics и /debug/pprof или стенд от посторонних, и в простой интеграции со скриптом на cron, где TLS есть и ротация не нужна. На сервере сравнивать обязательно через subtle.ConstantTimeCompare.

API-ключи

// ключ отдаём один раз при создании, в БД только хеш и префикс
key := "sk_live_" + base64url(random(24))          // 192 бита энтропии
row := APIKey{
    Prefix:    key[:14],                            // для поиска и показа в UI
    Hash:      sha256.Sum256([]byte(key)),          // быстрый хеш ок: секрет высокоэнтропийный
    Scopes:    []string{"orders:read"},
    ExpiresAt: time.Now().AddDate(1, 0, 0),
}
// проверка: находим по префиксу, сравниваем хеши constant-time

Почему для ключа хватает SHA-256, а для пароля нет? Пароль придумывает человек, энтропии в нём мало, и его подбирают по словарю. Случайный 192-битный ключ перебрать нельзя, а медленная KDF только жжёт CPU на каждом запросе.

Правила, которые ждут в ответе

  • Ключ передают в заголовке (Authorization: Bearer или X-API-Key), никогда в query: URL пишется в access-логи, прокси и Referer.
  • У каждого ключа свои скоупы и права, на каждую интеграцию отдельный ключ, чтобы отзыв одного не ломал остальные.
  • Срок жизни и ротация: на время переключения можно держать два активных ключа.
  • Rate limit по ключу, лог использования, дата последнего применения.
  • Для вебхуков наружу лучше не ключ, а HMAC-подпись тела с меткой времени (X-Signature, X-Timestamp): секрет вовсе не передаётся, а метка защищает от повторной отправки.
  • Самый серьёзный вариант для межсервисного трафика — mTLS: клиентский сертификат вместо строки, отзыв через CRL, срок жизни короткий.
Суть: RBAC — права привязаны к роли («менеджер может отменять заказы»), ABAC — решение считается функцией от атрибутов субъекта, объекта и контекста («менеджер может отменять заказы своего филиала в рабочее время»). Middleware закрывает уровень маршрута, но проверка владения объектом живёт в сервисе.

RBAC

Пользователь → роли → права (permissions), права проверяются в коде. Проверять надо право, а не роль. if user.Role == "admin" закладывает бомбу замедленного действия: появится второй тип админа, и придётся править сотню мест. if user.Can("order:cancel") переживёт и новые роли, и их перекройку. RBAC понятен бизнесу и легко ложится на интерфейс администратора.

ABAC

Решение принимает политика по кортежу атрибутов: кто (роль, отдел, стаж), что (владелец объекта, статус, сумма), в каком контексте (время, IP, был ли MFA). Так выражается то, что RBAC не умеет: «сумму больше миллиона согласует только руководитель», «к персональным данным пускать только из офисной сети». Реализуют это кодом в сервисном слое или отдельным движком политик (OPA/Rego, Casbin, Cedar). Платишь за это более сложной отладкой («почему отказ?») и аудитом решений.

Middleware

func RequirePerm(p string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            u, ok := auth.From(r.Context())     // положил middleware аутентификации
            if !ok { fail(w, 401); return }
            if !u.Can(p) { fail(w, 403); return }
            next.ServeHTTP(w, r)
        })
    }
}
r.With(RequirePerm("order:cancel")).Post("/orders/{id}/cancel", h.Cancel)

// а это middleware сделать не может, он не знает, чей это заказ:
func (s *Service) Cancel(ctx context.Context, orderID, userID int64) error {
    o, err := s.repo.Get(ctx, orderID)
    if err != nil { return err }
    if u, _ := auth.From(ctx); o.UserID != userID && !u.Can("order:cancel:any") {
        return ErrForbidden           // отдать наружу как 404, если факт существования секретен
    }
    ...
}

Главные грабли

  • Авторизация только в middleware даёт IDOR: право «отменять заказы» есть, а что заказ чужой, никто не проверил.
  • Fail-open: нет токена → в контексте пустой пользователь → политика «нет запрета» → пускаем. По умолчанию должно быть «запрещено»: безопаснее закрыть роутер целиком и явно пометить публичные маршруты, чем наоборот.
  • Права в JWT удобны (ноль запросов), но протухают до конца TTL; критичные права лучше читать из источника истины.
  • Полезный тест: пройтись по всем зарегистрированным роутам и убедиться, что у каждого есть политика. Иначе однажды новый эндпоинт приедет голым.
Суть: пароль хешируется намеренно медленной функцией с уникальной солью. MD5/SHA спроектированы быстрыми — видеокарта считает миллиарды хешей в секунду, и весь словарь перебирается за часы. Дефолт на сегодня — argon2id, надёжная классика — bcrypt с cost 12.

Почему быстрые хеши не годятся

Стойкость к перебору упирается не в математику, а в стоимость одной попытки. У SHA-256 она измеряется наносекундами, так что утёкшая база с SHA-хешами фактически и есть база паролей: 8-символьные пароли перебираются полностью, остальное ловят словарями с правилами мутаций. Медленная KDF поднимает цену попытки до ~250 мс, и перебор перестаёт окупаться. А memory-hard функции (scrypt, argon2) вдобавок требуют десятки мегабайт на попытку, и у GPU с ASIC пропадает преимущество: памяти на ядро у них мало.

Соль и перец

  • Соль — уникальная случайная строка на каждый пароль, лежит рядом с хешем открыто. Прятать пароль она не должна. Она ломает rainbow-таблицы, даёт одинаковым паролям разные хеши (иначе по базе видно, у кого пароль 123456) и не даёт атаковать всю базу разом.
  • Перец — общий секрет вне БД (Vault, HSM, конфиг). Его подмешивают к паролю или берут ключом HMAC поверх хеша. Если утекла только база, хеши бесполезны. Минус: перец трудно ротировать.
  • В bcrypt соль зашита в саму строку хеша, генерировать её руками не надо, а в argon2 соль и параметры хранишь сам.

Что ещё обязательно назвать

  • Constant-time сравнение результата (subtle.ConstantTimeCompare, у bcrypt — CompareHashAndPassword).
  • User enumeration: для несуществующего логина всё равно прогонять фиктивный хеш и отвечать одинаковым «неверный логин или пароль», иначе аккаунты вычисляются по времени ответа.
  • Ротация параметров: cost хранится в самом хеше; если при успешном логине параметры оказались устаревшими, хеш пересчитывают, ведь только в этот момент открытый пароль есть на руках.
  • Rate limit и блокировка после серии неудач: без них медленная KDF не спасёт от онлайн-перебора: популярных паролей по списку логинов (password spraying) или утёкших с других сайтов пар логин–пароль (credential stuffing).
  • Ограничение bcrypt: он учитывает только первые 72 байта пароля. В Go GenerateFromPassword на более длинном вернёт ErrPasswordTooLong, поэтому длинную парольную фразу заранее сворачивают в SHA-256 или берут argon2id.
  • Не требуй «спецсимвол и смену раз в 90 дней»: актуальный NIST советует длину, проверку по списку скомпрометированных паролей и никакой принудительной ротации.
Суть: к секрету три требования — не попасть в git, не светиться в логах и меняться без релиза. Переменные окружения закрывают первое, Vault или облачный KMS закрывают все три и добавляют аудит, TTL и динамические учётки.

Лестница зрелости

  1. Хардкод в коде — катастрофа: секрет в git навсегда, у всех форков и в CI-кэшах.
  2. Переменные окружения по 12-factor дают минимальный приемлемый уровень. Минусы: видны в /proc/PID/environ, в docker inspect, утекают в краш-репорты; ротация = рестарт.
  3. Файл, смонтированный в контейнер, можно обновить без рестарта, на него действуют права доступа; приложение должно уметь перечитывать его по сигналу.
  4. Kubernetes Secret: RBAC, монтирование как файл или env. Обязательно проговори: это base64, а не шифрование, и без включённого encryption-at-rest в etcd секрет лежит практически открытым.
  5. Vault / KMS дают централизованное хранение, аудит доступа, TTL, автоматическую ротацию, динамические секреты. Взамен получаешь ещё одну зависимость в рантайме и задачу аутентифицировать само приложение.

AppRole в двух предложениях

У сервиса есть role_id (не секрет, лежит в конфиге) и secret_id (короткоживущий, его доставляют отдельно, оркестратором или через response wrapping). Пару меняют на токен с TTL, приложение периодически его продлевает и по нему читает секреты. Артефактов два, чтобы утечка одного не давала доступ. Отдельный козырь Vault — динамические секреты: он сам заводит временного пользователя в PostgreSQL с TTL в час, и красть пароль от базы становится почти бессмысленно.

Ротация как рутина

Ротация должна быть скучной операцией, а не подвигом. Для этого приложение держит два действующих значения одновременно: два ключа подписи с разными kid (выкатили новый на проверку → переключили подпись → сняли старый через окно), старый и новый пароль БД, два API-ключа у интеграции. Тогда секрет меняется без одномоментного рестарта всего.

Что делать, если секрет уже в git

Удалить файл новым коммитом недостаточно: он остался в истории, в форках и в клонированных CI-кэшах. Порядок строго такой: сначала отозвать и перевыпустить секрет, потом (по желанию) чистить историю git filter-repo/BFG. Ротация обязательна, чистка истории — косметика. В пайплайн ставят gitleaks/trufflehog, а секретным типам в коде дают String(), который возвращает "[REDACTED]", чтобы секрет не уехал в лог вместе со структурой конфига.

10.4Безопасность и защита сервиса

Секция, которую многие готовят по списку «OWASP Top 10» и на собеседовании пересказывают заголовки. Спрашивают другое: покажи уязвимый код, объясни, почему он уязвим, и почини — а потом расскажи, что из этого вообще может сделать бэкенд, а что решается на клиенте или на периметре. Половина вопросов здесь упирается в границу ответственности.

Одна мысль, из которой выводится вся глава

Почти каждая уязвимость из этого списка сводится к стёртой границе между данными и кодом или к доверию к тому, что контролирует не сервер. В SQL-инъекции пользовательская строка стала куском SQL, в XSS стала куском HTML, в SSRF превратилась в адрес, куда сервер сам пошёл. Остальное про доверие. При CSRF сервер поверил, что раз кука пришла, значит запрос осознанный. При IDOR он поверил идентификатору из URL, при mass assignment поверил набору полей во входном JSON. Если держать в голове эту рамку, ответы перестают быть списком заклинаний.

Что бэкенд контролирует, а что нет
  • Полностью наш: инъекции всех видов, авторизация и владение объектом, mass assignment, rate limiting, идемпотентность, валидация, конфигурация CORS и заголовков.
  • Наш, но исполняет браузер: CSRF (SameSite, токены), XSS (экранирование при рендере, Content-Type, CSP), CORS. Правила задаём мы, а соблюдает их чужая программа на чужом компьютере.
  • Не наш вовсе: что делает злоумышленник своим curl. Если «защиту» снимает отключённый JavaScript или запрос, собранный руками, это не защита.
Формулировка, которая сразу выдаёт джуна

«Мы валидируем на фронте, поэтому инъекция невозможна» и «CORS защищает наш API от чужих сайтов». Первая ошибка в том, что фронт вообще не граница доверия. Вторая в том, что CORS ослабляет политику браузера, а не защищает сервер; подробности ниже.

SQL-инъекция

Классика, которую всё ещё находят в проде — обычно не в основном CRUD, а в отчётах, поиске, админке и там, где «плейсхолдер не подставляется»: сортировка, имя таблицы, IN (...) с динамическим числом элементов. Механика всегда одна: строка пользователя попадает в текст запроса, СУБД разбирает этот текст целиком и не может отличить, какая часть была задумана как данные.

// Уязвимо. Такое и находят на пентестах.
func (r *Repo) UserByID(ctx context.Context, raw string) (*User, error) {
    q := "SELECT id, email, is_admin FROM users WHERE id = " + raw
    row := r.db.QueryRowContext(ctx, q)
    ...
}

При raw = "1" всё работает, тесты зелёные. При raw = "1 OR 1=1 --" получается WHERE id = 1 OR 1=1 --: условие тождественно истинно, а -- отрезает всё, что шло следом. Дальше атака развивается по накатанной:

  • Расширение выборки. С OR 1=1 вываливается вся таблица.
  • UNION-инъекция подставляет в ту же форму ответа чужие колонки: 1 UNION SELECT id, password_hash, NULL FROM users (типы колонок должны сойтись, отсюда NULL на месте is_admin).
  • Blind / boolean-based. Ответа не видно, зато видно, изменился ли он: 1 AND (SELECT substr(password_hash,1,1) FROM users WHERE id=1)='a', и хеш вытаскивается посимвольно.
  • Time-based обходится без ответа вовсе, ему хватает времени: 1 AND (SELECT 1 FROM pg_sleep(5)) = 1. Работает даже там, где ответ всегда 200 и пустой.
  • Stacked queries проходят, если драйвер разрешает несколько операторов в одном сообщении: 1; DROP TABLE users; --. В PostgreSQL расширенный протокол такое запрещает, а Exec с простым протоколом или MySQL с multiStatements=true пропустят спокойно.
Конкатенация: параметр пересекает границу «данные → код» HTTP-запрос GET /users?id=1+OR+1%3D1-- строка от клиента хендлер / репозиторий q := "SELECT id,email FROM users WHERE id = " + raw склейка — граница стёрта текст, ушедший в СУБД SELECT id,email FROM users WHERE id = 1 OR 1=1 -- это уже не параметр, а SQL парсер и планировщик условие тождественно истинно → в ответе вся таблица users Параметризация: значение вообще не попадает в текст запроса HTTP-запрос GET /users?id=1+OR+1%3D1-- та же самая строка хендлер / репозиторий db.QueryContext(ctx, "… WHERE id = $1", raw) текст константен, значение отдельно протокол СУБД Parse: SELECT id,email FROM users WHERE id = $1 Bind: $1 = '1 OR 1=1 --' (одно значение, тип int4) парсер и планировщик текст разобран ДО значения → 0 строк либо ошибка приведения типа
Путь инъекции. Всё решает шаг, на котором значение встречается с парсером, а вовсе не «фильтрация плохих символов». При конкатенации оно приходит вместе с текстом и разбирается как SQL; при параметризации текст разобран заранее, и значение попадёт только в лист готового дерева — как данные, чем бы оно ни было.

Почему параметризация работает, а экранирование — нет

Плейсхолдер не «умный escape». В расширенном протоколе PostgreSQL клиент шлёт два разных сообщения: Parse с текстом запроса и Bind со значениями. Когда сервер видит значение, синтаксическое дерево уже построено, и оно ляжет в конкретный лист этого дерева. Никакая строка не «дорастёт» до нового предиката: грамматика уже разобрана. Поэтому параметризация риск не смягчает, а устраняет.

Ручное экранирование решает ту же задачу с другого конца — пытается угадать, что именно опасно. И проигрывает сразу по нескольким причинам:

  • Правила зависят от диалекта и настроек. В MySQL при NO_BACKSLASH_ESCAPES обратный слеш перестаёт экранировать; в PostgreSQL поведение E'...' и standard_conforming_strings различается. Функция, написанная под одну конфигурацию, дырявая в другой.
  • Кодировки. Классическая атака на GBK/SJIS: байт 0x5C оказывается второй половиной многобайтового символа, и добавленный слеш «съедается», освобождая кавычку. Экранирование, не знающее charset соединения, ломается.
  • Числовой контекст. Экранируют кавычки, но если значение подставляется без них (WHERE id = 1 OR 1=1), атакующему кавычки и не нужны.
  • Это ручная работа. Хватит одного забытого места — и всё остальное экранирование теряет смысл. Параметризация ошибается «в безопасную сторону»: забыл плейсхолдер, и запрос обычно просто не компилируется или падает на несовпадении числа аргументов.
Правило, которое надо произнести дословно

Пользовательские данные попадают в запрос только через плейсхолдеры. Если значение нельзя передать плейсхолдером — значит, это не значение, а часть синтаксиса, и её надо выбирать из заранее заданного списка на стороне сервера, а не принимать от клиента.

ORDER BY и имена таблиц — куда плейсхолдер не ставится

Плейсхолдер подставляет значение. А имя колонки, направление сортировки, имя таблицы или схемы относятся к идентификаторам, к грамматике запроса, и параметр на их месте не сработает: имя таблицы СУБД не примет, а ORDER BY $1 молча отсортирует по константе, то есть никак. LIMIT $1 и OFFSET $2, наоборот, обычные значения. В зрелых проектах реальные инъекции чаще всего находят именно тут: везде параметризовано, а сортировка склеена. Рабочее решение одно: маппинг из белого списка. Клиент присылает ключ, а сервер превращает его в заранее написанный фрагмент SQL.

// Клиент присылает sort=created_at&order=desc, но в SQL уезжает только то,
// что мы написали сами. Строки пользователя в тексте запроса нет.
var sortable = map[string]string{
    "created_at": "p.created_at",
    "title":      "p.title",
    "views":      "p.views_count",
}

func orderClause(q url.Values) (string, error) {
    col, ok := sortable[q.Get("sort")]
    if !ok {
        if q.Get("sort") == "" {
            return "p.created_at DESC, p.id DESC", nil // детерминированный дефолт
        }
        return "", fmt.Errorf("%w: неизвестное поле сортировки", ErrValidation)
    }
    dir := "ASC"
    if strings.EqualFold(q.Get("order"), "desc") {
        dir = "DESC"
    }
    // tie-breaker по id обязателен, иначе keyset-пагинация «дребезжит»
    return col + " " + dir + ", p.id " + dir, nil
}

Бывает, что без динамического имени таблицы или схемы не обойтись (мультитенантность «схема на клиента», админский конструктор отчётов). Тогда идентификатор не склеивают, а цитируют библиотечной функцией и всё равно проверяют по списку: pgx.Identifier{schema, table}.Sanitize() в pgx или quote_ident() на стороне PostgreSQL. Список существующих тенантов проверяй заранее — цитирование спасает от синтаксической инъекции, но не от «покажи мне схему чужого клиента».

Списки и IN (...)

Склеивать любят и IN с переменным числом элементов. В Go для него есть два безопасных способа. Можно сгенерировать нужное количество плейсхолдеров ($1,$2,…$N) в цикле — список формирует код, а значения всё равно уходят аргументами. Или передать массив одним параметром: WHERE id = ANY($1) с pq.Array(ids) или срезом в pgx. Второй вариант лучше, потому что не плодит уникальных текстов запроса и не выбивает кэш планов.

Что ещё говорят про инъекции на собеседовании

  • ORM сама по себе не защищает. Склеить строку можно и в GORM: Where("name = '" + s + "'"), Raw, Exec, любой конструктор «сырых» фрагментов. Защищает плейсхолдер, а не ORM.
  • Least privilege у роли БД. Приложение ходит под ролью без DROP, без CREATE, без доступа к чужим схемам и без суперправ. Тогда даже успешная инъекция прочитает только то, что и так доступно сервису. Для миграций заводят отдельную роль.
  • Ошибки наружу не отдавать. Текст ошибки СУБД подсказывает атакующему имя таблицы, тип колонки, версию сервера. Клиент получает обезличенный 500/400 и trace_id, детали уходят в лог.
  • Инъекции бывают и помимо SQL. Тот же класс: NoSQL (оператор $ne в JSON-фильтре Mongo), LDAP, command injection через exec.Command("sh", "-c", user), шаблонизаторы, XPath, заголовки писем. Ответ везде один: не собирать команду строкой. В Go с процессами это особенно просто: exec.Command(name, args...) вообще не запускает shell, если явно не позвать sh -c.
  • Обнаружение: линтеры (gosec, правила G201/G202 про конкатенацию в SQL; сам go vet такого не ловит), запрет fmt.Sprintf рядом с db.Query на ревью, WAF как второй эшелон, а не как первый.

XSS: что из этого реально может сделать бэкенд

XSS тоже инъекция, только в HTML: строка пользователя оказывается в разметке, и браузер исполняет её как код. Исполняет у другого пользователя и в контексте нашего origin, то есть с доступом ко всему, что там есть: DOM, localStorage, куки без httpOnly. И, что хуже всего, скрипт может слать запросы к нашему же API от имени жертвы, уже с её сессией.

ТипГде живёт полезная нагрузкаКто чинит
Stored (persistent)в нашей БД: комментарий, имя профиля, название файла; отдаётся всем, кто откроет страницубэкенд — на выводе; самый опасный тип
Reflectedв самом запросе: ?q=<script>…, сервер вернул это в HTML ответабэкенд — на выводе
DOM-basedсервер отдал чистый HTML, но JS сам записал данные из location.hash в innerHTMLфронтенд; бэкенд помогает только CSP

Отсюда и ответ на вопрос «что тут делает бэкенд». Если фронт написан как SPA, а бэкенд отдаёт только JSON, классический XSS чинится на фронте: React/Vue экранируют по умолчанию, и дыра появляется через dangerouslySetInnerHTML/v-html. Но у бэкенда остаётся четыре рычага, и их надо назвать.

1. Экранирование при рендере — если рендерим мы

В Go разница между двумя пакетами стоит инцидента. text/template подставляет строку как есть. html/template учитывает контекст: парсит шаблон, понимает, куда попадает значение (в текст, в атрибут, в href, внутрь <script>, в CSS), и в каждом месте экранирует по-своему. Контекст нужен потому, что экранировать < и > мало, если значение попадает в атрибут без кавычек или в URL со схемой javascript:.

import "html/template"   // не text/template

var page = template.Must(template.New("p").Parse(`
  <p>Привет, {{.Name}}</p>                     <!-- HTML-контекст -->
  <a href="{{.Site}}">сайт</a>                 <!-- URL-контекст -->
  <script>var u = {{.Name}};</script>          <!-- JS-контекст -->
`))

// Name = `<img src=x onerror=alert(1)>` → в HTML станет &lt;img …&gt;,
//   а в <script> станет корректным JS-литералом "\u003cimg src=x …\u003e".
// Site = `javascript:alert(1)`          → html/template подставит #ZgotmplZ.

Обратная сторона: тип template.HTML отключает экранирование. Нужен он в одном случае: когда вставляем HTML, которому доверяем. Пользовательскому HTML доверять нельзя никогда. Если продукту нужно форматирование (комментарии с разметкой), такой HTML пропускают через санитайзер с белым списком тегов и атрибутов — bluemonday в Go. Чёрный список (вырезать <script>) не работает, обходов десятки: <img onerror>, <svg onload>, <iframe srcdoc>, экзотические кодировки, <scr<script>ipt> для наивной однопроходной замены.

2. Content-Type и X-Content-Type-Options

Браузер решает, исполнять ли содержимое как HTML, по заголовку Content-Type. Если его нет или он неправильный, срабатывает MIME-sniffing: браузер угадывает по первым байтам. Загруженный «аватар», который на самом деле начинается с <html><script> и отдаётся без Content-Type, вполне может исполниться как страница на нашем домене. Отсюда два обязательных пункта:

  • всегда выставлять точный Content-Typeapplication/json; charset=utf-8 для API, реальный тип для файлов, а для скачиваемого пользовательского контента ещё и Content-Disposition: attachment;
  • X-Content-Type-Options: nosniff, который запрещает угадывание. Один заголовок закрывает целый класс атак.

Мера посерьёзнее: отдавать пользовательские файлы с другого домена (usercontent.example.net, не поддомен основного). Тогда даже скрипт, который там исполнился, окажется в чужом origin и до сессии на основном домене не дотянется.

3. JSON-эндпоинт как вектор — почему это не паранойя

На фразе «У нас только JSON, XSS невозможен» любят ловить. Превратить JSON-ручку в XSS можно несколькими способами:

  • Неверный Content-Type. Тот же JSON, отданный как text/html (или вообще без типа, с включённым sniffing), исполняется. Хватит того, что в теле есть отражённое значение из запроса: {"error":"неизвестный параметр <script>…</script>"}. Классический сценарий: обработчик ошибок отражает err.Error() в ответ. В Go http.Error тут безопасен: он ставит text/plain и X-Content-Type-Options: nosniff. Опасен самописный хелпер без Content-Type: тип тогда угадывает net/http по первым байтам, и <script> в начале тела сделает ответ text/html.
  • Отражение в ответе плюс скачивание. Если ручка отдаёт файл с именем, взятым из запроса, и браузер открывает его как документ, контекст снова становится HTML.
  • JSONP, легаси-механизм обхода SOP: ответ оборачивается в вызов функции, имя которой пришло в query. Выходит сразу XSS (имя коллбэка попадает в исполняемый код) и полный обход CORS, потому что <script src> уходит куда угодно и с куками. Ответ простой: JSONP не использовать, для кросс-домена есть CORS.
  • Ответ, который потом кладут в DOM. Даже идеальный JSON становится XSS, если фронт вставит поле через innerHTML. Формально виноват фронт, но проектная вина общая: не хранить в БД то, что не прошло валидацию, и не отдавать HTML там, где по контракту текст.
// Ответ API: тип выставляем явно и до записи тела, sniffing запрещаем.
func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json; charset=utf-8")
    w.Header().Set("X-Content-Type-Options", "nosniff")
    w.WriteHeader(status)                       // строго после Header().Set

    enc := json.NewEncoder(w)
    enc.SetEscapeHTML(true)                     // дефолт: < > & → \u003c \u003e \u0026
    if err := enc.Encode(v); err != nil {
        // статус и заголовки уже ушли, поменять их нельзя: только логируем
        slog.Error("encode response", "err", err)
    }
}

Про SetEscapeHTML: по умолчанию encoding/json экранирует <, > и & в юникод-последовательности, чтобы JSON можно было безопасно встроить в HTML. Иногда это отключают ради читаемости — и заодно убирают одну из подстраховок.

4. CSP — второй эшелон

Заголовком Content-Security-Policy сервер сообщает браузеру, откуда странице можно грузить скрипты, стили и картинки и разрешён ли инлайн. Экранирование CSP не заменяет, а страхует: если XSS всё-таки просочился, полезная нагрузка не исполнится или не сможет утечь наружу.

# Строгая политика для отрендеренной сервером страницы: nonce на каждый запрос,
# никакого 'unsafe-inline', запрет фреймов и base-тега.
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-r4nd0m' 'strict-dynamic';
  style-src 'self';
  img-src 'self' data:;
  connect-src 'self' https://api.example.com;
  object-src 'none'; base-uri 'none'; frame-ancestors 'none';
  form-action 'self'

# Чистому JSON-API хватит жёсткого минимума, страниц там нет:
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; sandbox
  • 'unsafe-inline' в script-src почти полностью обесценивает CSP — XSS как раз и исполняется инлайном. Вместо него ставят nonce (случайное значение на каждый ответ, оно проставляется в теге и в заголовке) или хеш скрипта.
  • Внедрять политику разумно через Content-Security-Policy-Report-Only плюс report-to: собрать нарушения на проде, вычистить, потом включить в блокирующем режиме.
  • frame-ancestors 'none' пришёл на смену X-Frame-Options: DENY и защищает от clickjacking. Старый заголовок оставляют для совместимости.
Про httpOnly и «XSS украдёт токен»

httpOnly-кука действительно не читается из JS, и это правильный дефолт. Но говорить, что она «защищает от XSS», нельзя: скрипт на нашем origin может просто слать запросы — куку браузер приложит сам. Токен не украден, зато перевод денег выполнен. httpOnly снижает ущерб (нельзя унести сессию на другой хост и пользоваться ей месяц), но при XSS всё, что доступно пользователю, доступно и атакующему.

CSRF: браузер отправляет запрос за пользователя

Cross-Site Request Forgery держится на автоматическом прикреплении учётных данных. Браузер прикладывает куки к запросу, глядя на адрес назначения, а не на того, кто этот запрос инициировал. Значит, чужая страница может заставить браузер жертвы сходить на наш домен — и запрос придёт полностью аутентифицированным.

Прочитать ответ злоумышленник при этом не может. Same-Origin Policy не даст чужому скрипту увидеть тело, статус или заголовки нашего ответа. Поэтому CSRF бьёт по изменяющим операциям: перевод денег, смена email, смена пароля, удаление, выдача прав. Это «слепой» вызов, результат которого атакующему не нужен.

evil.com браузер жертвы bank.example — наш API жертва уже залогинена: в браузере лежит кука bank.example → session=8f3a 1. жертва открыла страницу атакующего <form action="https://bank.example/transfer" method=POST><script>form.submit()</script> 2. браузер отправляет форму: инициатор — evil.com, адресат — bank.example → это cross-site запрос 3. запрос уходит на наш домен POST /transfer amount=10000&to=attacker Cookie: session=8f3a ← приложена браузером САМИМ 4. подпись куки верна → сервер считает запрос осознанным. Перевод выполнен 5. ответ атакующему не виден (SOP) — он ему и не нужен Где ломается цепочка: атрибут SameSite решает судьбу шага 3 SameSite=Strict кука не уходит ни при каком переходе с чужого сайта − переход по внешней ссылке выглядит как разлогин SameSite=Lax — дефолт в Chrome уходит только на top-level GET-навигацию POST, iframe, img, fetch — куки нет → шаг 4 даёт 401 SameSite=None; Secure уходит всегда, в том числе на cross-site POST атака снова работает — обязателен CSRF-токен
CSRF по шагам. Сессию никто не угадывал: браузер сам прикладывает её по адресу назначения. SameSite меняет именно это правило, и кука перестаёт быть «автоматическим предъявителем» для запросов, которые инициировал чужой сайт. Токен в заголовке Authorization в эту схему вообще не попадает — его никто не проставляет автоматически, и шаг 3 приходит анонимным.

Почему кука уязвима, а Authorization: Bearer — нет

Всё решает то, кто прикрепляет учётные данные. Куку прикрепляет браузер по правилу «домен совпал — приложил», и инициатор запроса ему при этом не важен (без SameSite). Заголовок Authorization проставляет наш JavaScript, который живёт на нашем origin. Чужая страница до него не дотянется — не прочитает токен из localStorage нашего домена и не заставит браузер добавить заголовок. К тому же любой запрос с кастомным заголовком перестаёт быть «простым» и требует CORS-preflight. Это предварительный запрос OPTIONS: им браузер сам, ещё до отправки настоящего запроса, спрашивает у сервера разрешение («можно этому origin такой метод и такие заголовки?»). Чужому Origin мы разрешения не дадим, и настоящий запрос просто не уйдёт. Подробно preflight разобран в разделе про CORS ниже.

Правило выбора защиты
  • Куки как транспорт сессии (классическое веб-приложение, SSR, BFF) → нужна защита от CSRF: SameSite, поверх него штатный http.CrossOriginProtection (Go 1.25, разбор ниже), а токен оставляем для критичных операций и для браузеров без Fetch metadata.
  • Токен в заголовке (SPA на другом домене, мобильное приложение, межсервисные вызовы) → CSRF структурно невозможен, отдельная защита не нужна. Зато дороже обходится XSS — токен лежит там, куда дотягивается JS.
  • Гибрид «кука + токен в заголовке» (кука хранит, JS дублирует в заголовок) называется double submit, см. ниже.

SameSite в деталях

ЗначениеКогда кука прикрепляется к cross-site запросуПрактика
Strictникогдадля refresh-куки и админок. Побочка: перешёл по ссылке из почты — сайт встретил гостем, потому что на первой навигации куки нет
Laxтолько на top-level навигацию «безопасным» методом (клик по ссылке, GET). Не прикрепляется к POST, fetch, iframe, img, XHRрабочий дефолт для сессионной куки. Без явного атрибута его применяют Chrome и другие браузеры на Chromium, Firefox и Safari — нет, так что ставь явно
Noneвсегдатребует Secure. Нужен для честно кросс-доменных сценариев (виджет, SSO, SPA на другом домене) — и возвращает необходимость CSRF-токена
  • «Same-site» ≠ «same-origin». Сравнивается registrable domain (eTLD+1), а не полный origin: app.example.com и api.example.com считаются одним site, схема тоже учитывается. Поэтому SameSite не спасает от атаки с соседнего поддомена, который кто-то захватил (subdomain takeover).
  • Lax защищает не полностью. Форму и fetch он закрывает, а изменяющую операцию, повешенную на GET, нет. Если у нас есть GET /account/delete, атака сводится к простой ссылке или редиректу: top-level навигацию GET-ом Lax пропускает. А где кука без SameSite и браузер не ставит Lax сам, хватит и <img src=…>. Отсюда прямая связь: семантика методов тоже защищает от CSRF.
  • Старые браузеры атрибут игнорируют, поэтому для по-настоящему критичных операций SameSite остаётся первым эшелоном, но не единственным.

CSRF-токены: два рабочих паттерна

Идея у обоих одна: потребовать от запроса значение, которое чужой сайт узнать не может. Читать наш ответ он не умеет (SOP), так что любой секрет, выданный на странице, ему недоступен.

  • Synchronizer token. Сервер генерирует случайный токен, кладёт его в состояние сессии и отдаёт в HTML (скрытое поле формы или мета-тег), а на изменяющем запросе сверяет пришедшее значение с сохранённым. Надёжнее некуда, но нужно состояние на сервере.
  • Double submit cookie обходится без состояния: сервер кладёт случайное значение в отдельную не-httpOnly куку, фронт читает её из JS и дублирует в заголовок X-CSRF-Token, а сервер сравнивает куку и заголовок. Схема работает, потому что чужой сайт не может прочитать куку и тем более проставить заголовок. Слабое место: захваченный поддомен может записать куку на родительский домен. Поэтому в проде берут signed double submit — значение куки привязано к идентификатору сессии через HMAC, и подставить произвольную пару не выйдет.
// Middleware: пропускаем безопасные методы, для остальных сверяем токен.
var safe = map[string]bool{http.MethodGet: true, http.MethodHead: true, http.MethodOptions: true}

func CSRF(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if safe[r.Method] {
            next.ServeHTTP(w, r)
            return
        }
        // CSRF грозит только сессии в куке. Bearer-запросы пропускаем.
        if strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
            next.ServeHTTP(w, r)
            return
        }
        c, err := r.Cookie("csrf_token")
        hdr := r.Header.Get("X-CSRF-Token")
        // Сравниваем за постоянное время, иначе токен подберут побайтово.
        if err != nil || hdr == "" ||
            subtle.ConstantTimeCompare([]byte(c.Value), []byte(hdr)) != 1 {
            problem(w, http.StatusForbidden, "csrf_failed", "CSRF-токен не совпал")
            return
        }
        next.ServeHTTP(w, r)
    })
}

// Кука с токеном: httpOnly=false, иначе наш же JS её не прочитает.
http.SetCookie(w, &http.Cookie{
    Name: "csrf_token", Value: randomToken(32),
    Path: "/", Secure: true, HttpOnly: false, SameSite: http.SameSiteLaxMode,
})

Go 1.25: http.CrossOriginProtection — защита без токенов и кук

Выше была классика: своё состояние, своя кука, свой заголовок, свой код на фронте. В Go 1.25 в net/http появился CrossOriginProtection, готовый middleware из стандартной библиотеки, который решает ту же задачу вообще без токенов. Из-за него сдвинулась рекомендация по умолчанию, и на собесе это хороший свежий ответ: «для современных браузеров хватает штатного middleware, токены остаются страховкой».

Механика держится на Fetch metadata — служебных заголовках, которые к каждому запросу на HTTPS (и на localhost) добавляет сам браузер. Страница-инициатор не может их ни подделать, ни убрать (в отличие от Referer, который вырезается политикой, и от кастомных заголовков, которые ставит JS). Главный из них, Sec-Fetch-Site, отвечает как раз на тот вопрос, которого не хватало серверу: откуда запрос инициирован. Значения: same-origin, same-site, cross-site и none (пользователь сам ввёл адрес или открыл закладку). Само правило умещается в строку: небезопасный метод при Sec-Fetch-Site, не равном same-origin или none, получает 403. Если заголовка нет вовсе, в ход идёт запасной вариант: хост из Origin сверяется с Host, и при несовпадении запрос тоже отклоняется.

cop := http.NewCrossOriginProtection()

// Свой фронт на другом origin добавляем в белый список.
_ = cop.AddTrustedOrigin("https://admin.example.com")

// Ручки, куда браузер не ходит (вебхуки платёжки, колбэки), пускаем в обход.
cop.AddInsecureBypassPattern("/webhooks/")

// Дефолтный отказ: 403 с текстом. Приводим к своему формату ошибок.
cop.SetDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    problem(w, http.StatusForbidden, "cross_origin_denied", "межсайтовый запрос отклонён")
}))

srv := &http.Server{Addr: ":8080", Handler: cop.Handler(mux)}

// Если решение нужно принимать самому (например, логировать, но пропускать на канарейке):
if err := cop.Check(r); err != nil { /* это межсайтовый небезопасный запрос */ }

Полная таблица решений (проверено запуском на go1.27.0, POST на bank.example):

Sec-Fetch-Site: cross-site                      -> 403  "cross-origin request detected from Sec-Fetch-Site header"
Sec-Fetch-Site: same-site  (соседний поддомен)   -> 403  тоже отказ: проверка по ORIGIN, а не по site
Sec-Fetch-Site: same-origin                     -> 200
Sec-Fetch-Site: none       (адрес ввели руками)  -> 200
нет Sec-Fetch-Site и нет Origin                 -> 200  считаем, что это не браузер: curl, мобилка, сервис
нет Sec-Fetch-Site, Origin != Host              -> 403  "...and/or browser is out of date"
cross-site, но Origin в AddTrustedOrigin        -> 200
cross-site, но путь в AddInsecureBypassPattern  -> 200
GET / HEAD / OPTIONS при любом Sec-Fetch-Site   -> 200  безопасные методы не проверяются вообще
Границы: что CrossOriginProtection закрывает, а что нет
  • Запрос без Fetch metadata проходит насквозь. Если нет ни Sec-Fetch-Site, ни Origin, запрос считается небраузерным, и его пропускают. Компромисс осознанный: иначе сломались бы все мобильные клиенты, серверные интеграции и curl. Платит за него браузер, который не шлёт ни одного из двух заголовков (совсем старый или экзотический): он получает нулевую защиту. Браузер без Sec-Fetch-Site, но с Origin Go проверит запасным способом, по Host. Если у тебя банк или админка, где нужно покрыть и такие браузеры, synchronizer token остаётся обязательным.
  • GET, HEAD, OPTIONS пропускаются всегда — по определению, как безопасные методы. Изменяющая операция на GET так и остаётся дырой. Ещё один довод соблюдать семантику методов.
  • Он не заменяет SameSite, а работает вторым эшелоном — и в одном месте строже. SameSite не даёт куке уехать, CrossOriginProtection отбивает уже дошедший запрос. В имени стоит cross-origin, а не cross-site, и это не оговорка — Sec-Fetch-Site: same-site он тоже отвергает. Значит, он закрывает ту самую дыру, в которую проваливается SameSite: атаку с захваченного поддомена, для которого запрос формально same-site. Платишь за это тем, что собственный фронт на соседнем поддомене придётся явно внести через AddTrustedOrigin.
  • Это не CORS. CORS решает, отдать ли ответ чужому скрипту; CrossOriginProtection решает, выполнять ли запрос. Разные задачи, и одно другое не заменяет.
Как отвечать про CSRF в 2026-м

«Механика прежняя: браузер прикладывает куку по адресу назначения, поэтому чужая страница может выполнить изменяющую операцию от имени пользователя. Базой служит SameSite=Lax плюс аккуратная семантика методов. Дальше, если сессия в куке, беру http.CrossOriginProtection из стандартной библиотеки. Он штатный с Go 1.25 и режет межсайтовые небезопасные запросы по Sec-Fetch-Site без состояния, без токенов и без правок на фронте. Токен (signed double submit или synchronizer) оставляю для браузеров без Fetch metadata и для по-настоящему опасных операций — на них же добавляю повторный ввод пароля или 2FA. Для API на Authorization: Bearer CSRF структурно невозможен».

Дополнительные проверки, которые стоит назвать
  • Проверка Origin/Sec-Fetch-Site. Современные браузеры шлют Origin на всех изменяющих запросах, а Sec-Fetch-Site: cross-site прямо сообщает намерение. Линия дешёвая, но не-браузерный клиент её подделает, поэтому она идёт в дополнение к токену, а не вместо него. Руками её писать больше не надо: именно эту проверку и оформили в http.CrossOriginProtection — см. выше.
  • Требовать Content-Type: application/json. HTML-форма умеет отправить только form-urlencoded, multipart и text/plain; JSON без preflight она отправить не может. Поэтому 415 на всё остальное работает как барьер (но не для эндпоинтов, которые принимают формы).
  • Повторный ввод пароля или 2FA на действительно опасных операциях: смена пароля, смена email, вывод средств. Такая проверка не ломается ни при CSRF, ни при XSS.
  • Токен привязывать к сессии и ротировать при логине, иначе получишь session fixation через подставленный CSRF-токен.

SSRF: сервер идёт туда, куда ему сказал клиент

При Server-Side Request Forgery злоумышленник управляет адресом, по которому наш сервис делает исходящий запрос. Атакующему это ценно, потому что наш сервер стоит внутри периметра: он видит приватную сеть, служебные порты и облачные метаданные, недоступные снаружи. Типовые точки входа: «загрузить картинку по URL», вебхуки, импорт из внешнего источника, генерация PDF/превью по ссылке, прокси-ручка, парсинг og:-разметки для красивого превью ссылки.

// Уязвимо: адрес полностью контролирует клиент.
func fetchAvatar(ctx context.Context, rawURL string) ([]byte, error) {
    resp, err := http.Get(rawURL)     // http://169.254.169.254/latest/meta-data/…
    ...                               // http://localhost:6379/  → Redis по HTTP
}                                     // file:///etc/passwd      → зависит от клиента
  • Облачные метаданные. По адресу 169.254.169.254 в AWS/GCP/Azure лежат временные учётные данные роли инстанса. Хватит одного удачного SSRF, и у атакующего ключи от облака. IMDSv2 (обязательный PUT-токен и X-aws-ec2-metadata-token) появился как раз поэтому.
  • Внутренние сервисы — админки без аутентификации «потому что они не торчат наружу», Kubernetes API, Consul, Elasticsearch, Prometheus, панель самого приложения.
  • Сканирование сети. По времени ответа и коду ошибки атакующий картирует внутренние адреса и открытые порты, даже если тело ответа ему не возвращают (blind SSRF).
  • Смена протокола через file://, gopher://, dict://. Если библиотека поддерживает эти схемы, из HTTP-запроса можно собрать произвольный TCP-пакет и, например, отдать команду Redis.

Как защищаться — по убыванию надёжности

  1. Allowlist. В идеале сервис вообще не ходит по произвольным URL: есть список разрешённых хостов или заранее зарегистрированные интеграции, всё остальное отвергается. Только эта защита не проигрывает гонку с обходами.
  2. Отдельный egress-прокси. Весь исходящий трафик сервиса идёт через прокси, у которого своя политика; сам под сидит в сетевой политике, запрещающей прямые соединения во внутренние подсети. Тогда даже ошибка в коде не даёт доступа.
  3. Запрет приватных диапазонов на этапе соединения. Проверять надо не строку URL, а IP, к которому реально подключается сокет. Проверка «распарсили URL → отрезолвили → сравнили» уязвима к DNS rebinding: между проверкой и соединением DNS отдаёт другой адрес (TOCTOU). В Go это решает хук Control у диалера — его вызывают уже с готовым адресом, прямо перед connect.
// Диалер, который отказывается соединяться с приватными и служебными адресами.
// Control зовут после резолва и до connect: DNS rebinding не пройдёт.
var blocked = []netip.Prefix{
    netip.MustParsePrefix("0.0.0.0/8"),      netip.MustParsePrefix("10.0.0.0/8"),
    netip.MustParsePrefix("100.64.0.0/10"),  netip.MustParsePrefix("127.0.0.0/8"),
    netip.MustParsePrefix("169.254.0.0/16"), netip.MustParsePrefix("172.16.0.0/12"),
    netip.MustParsePrefix("192.168.0.0/16"), netip.MustParsePrefix("::1/128"),
    netip.MustParsePrefix("fc00::/7"),       netip.MustParsePrefix("fe80::/10"),
    netip.MustParsePrefix("::/128"),         // [::] ядро соединит с loopback
}

func safeControl(network, address string, _ syscall.RawConn) error {
    if network != "tcp4" && network != "tcp6" {
        return fmt.Errorf("ssrf: сеть %s запрещена", network)
    }
    host, _, err := net.SplitHostPort(address)
    if err != nil {
        return err
    }
    ip, err := netip.ParseAddr(host)
    if err != nil {
        return err
    }
    ip = ip.Unmap()                       // ::ffff:169.254.169.254 → 169.254.169.254
    ip = ip.WithZone("")                  // для fe80::1%lo0 Contains всегда false
    for _, p := range blocked {
        if p.Contains(ip) {
            return fmt.Errorf("ssrf: адрес %s запрещён", ip)
        }
    }
    return nil
}

var safeClient = &http.Client{
    Timeout: 5 * time.Second,
    Transport: &http.Transport{
        DialContext: (&net.Dialer{Timeout: 3 * time.Second, Control: safeControl}).DialContext,
    },
    // Редирект даёт новый URL, выбранный не нами: проверяем каждый прыжок.
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        if len(via) >= 3 {
            return errors.New("ssrf: слишком много редиректов")
        }
        return checkScheme(req.URL)       // только http/https, порт из разрешённых
    },
}
Что забывают почти все
  • Редиректы. Внешний хост из allowlist отвечает 302 на http://169.254.169.254/ — и проверка, сделанная один раз на входе, обойдена. Валидировать надо каждый прыжок (или запретить редиректы вовсе). Хук Control тем и хорош, что срабатывает и на редиректах.
  • Обходы парсинга URL. http://127.0.0.1, http://[::1], http://2130706433 (десятичная запись), http://0177.0.0.1 (восьмеричная), http://localtest.me (публичный домен, резолвящийся в 127.0.0.1), http://user@evil.com@internal. Поэтому проверка по строке заведомо проигрывает, а проверка по итоговому netip.Addr нет.
  • Ответ наружу. Не возвращать клиенту тело, статус и время ответа стороннего ресурса как есть, иначе даже «безопасный» фетч превращается в сканер. Отдавать своё обезличенное сообщение.
  • Ограничить размер и время. Таймаут, io.LimitReader, проверка Content-Type. Без них SSRF заодно становится DoS против нас самих.

IDOR и broken access control — самая частая реальная дыра

В OWASP Top 10 первое место (A01) занимает не инъекция, а сломанный контроль доступа. Причина простая: инъекцию ловит линтер, и выглядит она везде одинаково, а «этот заказ принадлежит этому пользователю» остаётся бизнес-правилом. Его пишут руками в каждом обработчике, и достаточно один раз забыть.

Самый узнаваемый частный случай называется IDOR (Insecure Direct Object Reference): идентификатор объекта приходит из запроса и используется без проверки прав на этот конкретный объект.

// Уязвимо: middleware проверил, что пользователь аутентифицирован,
// но никто не проверил, что заказ принадлежит именно ему.
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
    order, err := h.repo.GetByID(r.Context(), id)   // ← /v1/orders/1235 вместо 1234
    ...
}

// Правильно: владение проверяется в самом запросе, отдельную проверку
// легко забыть. Условие уезжает в WHERE.
func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
    id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64)
    uid := auth.UserID(r.Context())
    order, err := h.repo.GetForUser(r.Context(), id, uid)  // WHERE id=$1 AND user_id=$2
    if errors.Is(err, sql.ErrNoRows) {
        problem(w, http.StatusNotFound, "not_found", "Заказ не найден")
        return
    }
    ...
}
  • Проверять владение в том же запросе к БД, а не отдельным if после чтения. Так забыть проверку структурно труднее, и гонок нет. Для сложных правил пишут явную функцию can(ctx, subject, action, object) и вызывают её в сервисном слое, где объект уже известен.
  • Отдавать 404, а не 403, если даже факт существования объекта выдаёт информацию. 403 подтверждает, что заказ с таким id есть.
  • Непредсказуемые идентификаторы смягчают риск, но не защищают. UUIDv4 вместо автоинкремента убирает тривиальный перебор, но id утекают в логи, в реферер, в шаринг ссылок. «Секретный URL» работает как контроль доступа только там, где так задумано (одноразовые ссылки с TTL).
  • Массовые операции и вложенные ресурсы. PATCH /v1/orders со списком id, GET /v1/users/{uid}/orders/{oid}, экспорт в CSV, GraphQL-резолвер вложенного поля: здесь проверку владения забывают чаще всего, потому что она нужна для каждого элемента.
  • Fail closed. По умолчанию роутер требует авторизацию, а публичные маршруты помечаются явно. Полезный тест: пройтись по всем зарегистрированным маршрутам и убедиться, что у каждого есть политика, иначе новый эндпоинт однажды приедет голым.
  • Функциональный уровень. Во втором частом случае защищать надо не объект, а действие: /admin/* открыт без роли, потому что «ссылки на него нет в интерфейсе». Скрытая кнопка доступ не контролирует.

Mass assignment: почему входной JSON не биндят в доменную модель

Соблазн понятен: структура уже есть, теги json тоже, зачем писать вторую. Но json.NewDecoder(r.Body).Decode заполняет те поля, которые прислали, а прислать могут любые — включая те, о которых клиент знать не должен.

// Доменная модель, она же модель БД, она же тело запроса. Так делать нельзя.
type User struct {
    ID        int64  `json:"id"`
    Email     string `json:"email"`
    IsAdmin   bool   `json:"is_admin"`      // ← {"is_admin": true}
    Balance   int64  `json:"balance"`       // ← {"balance": 999999}
    CreatedAt time.Time `json:"created_at"`
}

func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
    var u User
    json.NewDecoder(r.Body).Decode(&u)      // клиент задаёт любое поле
    h.repo.Update(r.Context(), &u)          // и оно уезжает в UPDATE
}

Правильно заводить отдельный тип на каждое направление: DTO запроса, доменная модель, DTO ответа. Это явное описание контракта, а не «лишний слой ради слоя»: какие поля клиент может задать и какие мы готовы показать.

// Клиенту разрешено прислать ровно два поля.
type updateProfileReq struct {
    DisplayName string `json:"display_name" validate:"required,min=1,max=64"`
    Bio         string `json:"bio"          validate:"max=500"`
}

func (h *Handler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
    var req updateProfileReq
    dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
    dec.DisallowUnknownFields()                   // "is_admin" → явная ошибка 400
    if err := dec.Decode(&req); err != nil {
        problem(w, http.StatusBadRequest, "malformed_json", "Тело запроса неразборчиво")
        return
    }
    if err := h.validate.Struct(req); err != nil { ... }

    // Из DTO в домен поля переносим явно. Про забытое новое поле компилятор
    // не напомнит: это ловят тест или линтер exhaustruct.
    err := h.svc.UpdateProfile(r.Context(), auth.UserID(r.Context()),
        service.ProfilePatch{DisplayName: req.DisplayName, Bio: req.Bio})
    ...
}
Тонкости, которые спрашивают следом
  • DisallowUnknownFields помогает, но не защищает. Она превращает лишнее поле в ошибку, что удобно при разработке, но ломает совместимость: клиент чуть новее — и вся интеграция падает. Защищает сам факт, что в DTO нет полей is_admin и balance.
  • Отличить «поле не прислали» от «прислали нулевое значение». Для PATCH это принципиально: bio: "" значит «очистить», а отсутствие поля значит «не трогать». Решается указателями (*string), обёрткой Optional[T] или json.RawMessage с ручным разбором.
  • Модель ответа тоже отдельная. Иначе однажды в JSON уедет password_hash, внутренний комментарий модератора или deleted_at. Пометка json:"-" работает, пока кто-то не добавит поле без тега.
  • Та же ошибка в обратную сторону: отдавать доменную модель наружу. Тогда любое внутреннее переименование поля ломает API.

CORS: что это на самом деле

Из всей секции CORS понимают хуже всего. Первым делом стоит сказать: CORS ослабляет Same-Origin Policy в браузере и сервер ни от чего не защищает. По умолчанию скрипт со страницы app.example.com не может прочитать ответ от api.example.com. Через CORS сервер говорит браузеру: «этому origin читать мой ответ можно». Запрещать CORS не умеет, только разрешать.

Три следствия
  • От curl, Postman и любого другого не-браузера CORS не защищает. Заголовки Access-Control-* читает только браузер. Закрывать API должны аутентификация и авторизация.
  • SOP блокирует чтение, отправку она пропускает. Запрос без preflight обычно уже дошёл до сервера и выполнился, браузер лишь не отдал ответ в JS. Поэтому от CSRF одна SOP не спасает: «слепая» отправка и есть CSRF.
  • Origin складывается из схемы, хоста и порта. http://x.com и https://x.com считаются разными origin, как и x.com:80 с x.com:8080. Отсюда половина «а почему у меня локально не работает».

Simple request против preflight

Браузер сначала решает, «простой» ли запрос. Простой уходит сразу, а отдавать ли ответ скрипту, браузер решает уже потом. Перед непростым он шлёт отдельный запрос OPTIONS (preflight), и, если политика не подошла, настоящий запрос не уходит вовсе. На практике разница такая: preflight предотвращает побочный эффект, а simple его не останавливает.

Simple request — уходит сразуPreflighted — сначала OPTIONS
МетодGET, HEAD, POSTвсё остальное: PUT, PATCH, DELETE
Content-Typeтолько text/plain, application/x-www-form-urlencoded, multipart/form-dataлюбой другой, в том числе application/json
Заголовкитолько «безопасный список» (Accept, Accept-Language, Content-Language, Range…)любой кастомный: Authorization, X-Request-Id, X-CSRF-Token
Что видит серверсразу боевой запрос, побочный эффект уже случилсяOPTIONS, боевого запроса может не быть вовсе

Выходит, что почти любой современный JSON-API всегда идёт с preflight — Content-Type: application/json и заголовок Authorization выводят запрос из «простых». Поэтому HTML-форма не подделает JSON-запрос, если сервер строго проверяет Content-Type: форма с text/plain умеет прислать тело, похожее на JSON. А токен в заголовке не подвержен CSRF по другой причине: браузер не прикладывает его сам, как куку.

JS на app.example.com браузер api.example.com 1. код вызывает fetch DELETE /v1/orders/42, Authorization: Bearer … 2. запрос НЕ простой: метод DELETE и кастомный заголовок → сначала спросить разрешение 3. PREFLIGHT — служебный запрос, JS о нём не знает OPTIONS /v1/orders/42 Origin: https://app.example.com Request-Method: DELETE Request-Headers: authorization, content-type 4. сервер отвечает одной политикой, тела нет 204 No Content Allow-Origin: https://app.example.com Allow-Methods: GET, POST, DELETE Allow-Headers: authorization, content-type Allow-Credentials: true Max-Age: 600 Vary: Origin 5. политика подходит → решение кладётся в кэш на 600 секунд: для этого origin, URL и режима кук 6. и только теперь уходит настоящий запрос DELETE /v1/orders/42 Authorization: Bearer … 7. на боевом ответе заголовки нужны СНОВА 200 OK Allow-Origin: https://app.example.com Allow-Credentials: true 8. промис резолвится — только здесь JS видит тело В схеме опущен префикс Access-Control- у всех заголовков CORS. Если шаг 4 браузер не устроил Шаг 6 не отправляется вовсе: промис падает с TypeError, а причина видна только в консоли браузера. Сервер при этом ничего не заметил — в его логах есть OPTIONS и нет DELETE. Отсюда ложное «CORS сломал API».
Preflight на оси времени. Браузер задаёт два вопроса: «можно ли этому origin такой метод» и «можно ли такие заголовки», а сервер отвечает одной политикой и сроком её кэширования. На схеме видно, что боевой запрос уходит после разрешения, а заголовок Allow-Origin нужен и на боевом ответе — без него браузер скроет уже полученный ответ.

Заголовки: кто за что отвечает

ЗаголовокКто шлётСмысл
Originбраузер, на всех cross-origin запросахкто спрашивает. Проставляется браузером, из JS не подделывается
Access-Control-Request-Methodбраузер, только в preflightкаким методом собираюсь
Access-Control-Request-Headersбраузер, только в preflightкакие заголовки собираюсь проставить
Access-Control-Allow-Originсервер, в preflight и в боевом ответеконкретный origin либо *. Список нескольких значений не поддерживается
Access-Control-Allow-Methodsсервер, в preflightразрешённые методы
Access-Control-Allow-Headersсервер, в preflightразрешённые к отправке заголовки запроса
Access-Control-Allow-Credentialsсерверtrue — разрешить куки и Basic. Только это значение, false писать бессмысленно
Access-Control-Expose-Headersсервер, в боевом ответекакие заголовки ответа JS разрешено читать. Без него доступны лишь семь базовых — свои X-Request-Id, X-RateLimit-*, Location надо перечислять явно
Access-Control-Max-Ageсервер, в preflightсколько секунд кэшировать решение. Браузеры режут сверху (Chrome — 2 часа)
Vary: Originсерверсообщает кэшам, что ответ зависит от Origin

Типичные ошибки настройки

  • Allow-Origin: * вместе с Allow-Credentials: true. Спецификация это прямо запрещает, и браузер отклонит ответ. Логика простая: «любой сайт» плюс «с куками пользователя» дают произвольной странице право читать личные данные жертвы. Выход один: отдавать конкретный origin.
  • Отражение Origin без проверки. Классическая «починка» предыдущего пункта: w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) плюс Allow-Credentials: true. По сути это тот же * с куками, только спецификацию удалось обмануть: любой сайт получает право читать ответы с сессией жертвы. Одна из самых частых находок на пентесте.
  • Слабое сравнение origin. strings.HasPrefix(o, "https://example.com") пропускает https://example.com.evil.net, а strings.Contains(o, "example.com") пропустит ещё и https://evil-example.com. Сравнивать нужно полный origin, на точное совпадение с заранее заданным множеством.
  • Забытый Vary: Origin. Если ответ кэшируется (CDN, обратный прокси, кэш браузера), без Vary ответ с Allow-Origin: https://a.example.com уйдёт клиенту с origin b.example.com — и наоборот. Смотря в какую сторону промахнулся кэш, получится либо неработающий фронт, либо утечка разрешения. Ставить его надо на всех ответах, где Allow-Origin вычисляется динамически.
  • null в белом списке. Origin null присылают песочницы iframe sandbox, локальные файлы и некоторые редиректы, так что разрешить его значит впустить кого угодно.
  • Preflight не доходит до обработчика. На OPTIONS нужно отвечать до аутентификации: браузер не прикладывает к нему ни кук, ни Authorization, поэтому auth-middleware вернёт 401, и весь запрос упадёт. Выглядит это как «CORS-ошибка, хотя всё настроено».
  • CORS вместо авторизации. Для публичного API разрешить всё (* без credentials) совершенно нормально. Ошибка в другом: считать API «закрытым», раз в CORS перечислены только свои домены. См. первый пункт про curl.
// Аккуратный CORS-middleware: точное сравнение, Vary всегда, OPTIONS до auth.
func CORS(allowed map[string]bool) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            origin := r.Header.Get("Origin")
            // Vary ставим всегда, даже когда origin не разрешён и заголовков не будет:
            // иначе кэш смешает ответы для разных origin.
            w.Header().Add("Vary", "Origin")

            if origin != "" && allowed[origin] {
                w.Header().Set("Access-Control-Allow-Origin", origin) // конкретный, не *
                w.Header().Set("Access-Control-Allow-Credentials", "true")
                w.Header().Set("Access-Control-Expose-Headers",
                    "X-Request-Id, X-RateLimit-Remaining, Retry-After")
            }

            if r.Method == http.MethodOptions &&
                r.Header.Get("Access-Control-Request-Method") != "" {
                w.Header().Add("Vary", "Access-Control-Request-Method")
                w.Header().Add("Vary", "Access-Control-Request-Headers")
                if origin != "" && allowed[origin] {
                    w.Header().Set("Access-Control-Allow-Methods",
                        "GET, POST, PUT, PATCH, DELETE")
                    w.Header().Set("Access-Control-Allow-Headers",
                        "Authorization, Content-Type, X-Request-Id, Idempotency-Key")
                    w.Header().Set("Access-Control-Max-Age", "600")
                }
                w.WriteHeader(http.StatusNoContent) // отвечаем до auth-middleware
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}

Rate limiting

У ограничения частоты несколько задач, и на собеседовании их полезно развести. Оно не даёт перебирать пароли и токены, мешает скрейпить и выкачивать базу, спасает от случайного DoS собственным клиентом с ретраями, честно делит ёмкость между тенантами и, наконец, бережёт деньги, если за нами платный внешний API. От «настоящего» DDoS на канал приложение не защитит — это уровень CDN и провайдера.

Все алгоритмы решают одно: пропустить запрос или отклонить. Различаются они на всплеске, и проще всего это увидеть, если прогнать через все четыре один и тот же трафик.

В двух из четырёх названий стоит слово bucket — «ведро». Это метафора счётчика, и она честная ровно до дна. Token bucket: в ведро фиксированной ёмкости с постоянной скоростью капают разрешения (их и называют «токенами», к токенам аутентификации из главы 10.3 они отношения не имеют). Каждый запрос забирает одно разрешение; ведро пусто — отказ. Пока клиент молчал, ведро наполнялось — отсюда и право на всплеск размером ровно в ёмкость ведра. Leaky bucket: тут ведро наполняют сами запросы, а вытекает оно с постоянной скоростью, поэтому на выходе трафик всегда идеально ровный; переполнилось ведро — отказ. Физически «ведро» хранится в Redis как два числа: сколько в нём сейчас и когда его трогали в прошлый раз.

Один и тот же вход во всех четырёх панелях: лимит 10 запросов в минуту, клиент шлёт 10 запросов в 59-ю секунду и ещё 10 в 61-ю. Fixed window · счётчик на календарное окно лимит 10 10 ✓ 10 ✓ всплеск A t = 59 c всплеск B t = 61 c счётчик привязан к минуте по часам сервера в 60.000 он обнуляется — оба всплеска легальны За 2 секунды прошло 20 запросов — вдвое больше лимита. Это и есть всплеск на границе окна. Плюсы: один INCR, память O(1). Sliding window · log (точный) и counter лимит 10 10 ✓ 10 ✗ всплеск A t = 59 c всплеск B t = 61 c log: метки всех запросов в zset, окно [1 c; 61 c] counter: 10 × 59/60 + 0 ≈ 9.8 → тот же вердикт За любые 60 секунд — ровно 10. Границы окна нет вообще. Цена: log хранит запись на каждый запрос — память O(лимит). Token bucket · ёмкость 10, долив 10 / мин ёмкость 10 10 ✓ 10 ✗ всплеск A t = 59 c всплеск B t = 61 c ведро было полным — всплеск прошёл целиком на t = 61 в ведре 0.33 токена, дальше 1 / 6 c Всплеск разрешён намеренно: ёмкость — это бюджет на burst, скорость долива — средний лимит. Память O(1): токены + отметка. Leaky bucket · очередь 10, слив 1 / 6 c очередь 10 9 ждут 10 ✗ всплеск A t = 59 c всплеск B t = 61 c на выход — строго один запрос каждые 6 секунд всплеск не проходит, а растягивается во времени Бэкенд видит идеально ровную нагрузку — всплеска нет вообще. Цена — задержка: десятый запрос всплеска исполнится через 54 с.
Четыре алгоритма на одном всплеске. Двойной лимит пропускает только fixed window, и лишь потому, что всплеск лёг на границу окна. Sliding window и token bucket дают одинаковый вердикт, но по разным причинам и с разной ценой памяти. Leaky bucket вообще не отвечает «да/нет» мгновенно: он превращает рваный вход в ровный выход и платит за это задержкой.
АлгоритмКак устроенПамять на ключВсплескиГде уместен
Fixed windowINCR ключа rl:{user}:{minute} с TTLO(1), одна командадо 2× лимита на стыке оконгрубые лимиты, где двукратный перелёт неважен; самый дешёвый
Sliding window logzset с меткой времени каждого запроса; чистим старое, считаем оставшеесяO(лимит) — запись на запроснет, лимит точен на любом интерваленебольшие лимиты, где важна честность (логин, оплата)
Sliding window counterдва счётчика (текущее и прошлое окно), взвешенная сумма по доле окнаO(1), два числапогрешность единицы процентоврабочий компромисс для больших лимитов; так делает Cloudflare
Token bucketведро ёмкости B, доливается со скоростью r; запрос забирает токенO(1): токены + время последнего доливаразрешает всплеск до Bнамереннодефолт для API: средняя скорость плюс явный бюджет на burst
Leaky bucketочередь фиксированного размера, из которой запросы вытекают с постоянной скоростьюO(размер очереди)сглаживает: на выходе всплесков нетзащита хрупкого downstream, шейпинг очередей, отправка в сторонний API с жёстким лимитом
Две тонкости
  • Token bucket и leaky bucket не синонимы. Их часто называют «одним и тем же наоборот», но ведут они себя по-разному. Token bucket отвечает мгновенно «да/нет» и разрешает накопленный всплеск. Leaky bucket ставит в очередь и выпускает ровно — всплесков на выходе нет вообще, зато появляется задержка. Первый отвечает за допуск, второй за шейпинг. Есть и вариант leaky bucket «as a meter»: лишнее он сразу отвергает, без очереди, и тогда ведёт себя как token bucket с ёмкостью, равной размеру очереди.
  • Sliding window counter считает приблизительно. Он предполагает, что запросы в прошлом окне шли равномерно, поэтому может отклонить легальный запрос или пропустить лишний. На больших лимитах ошибка теряется в шуме, а на лимите «5 попыток входа» уже заметна.

Локальный лимитер: golang.org/x/time/rate

Готовый token bucket лежит в стандартном расширении. Конструктор rate.NewLimiter(r, b) принимает скорость долива и ёмкость, те самые две ручки из таблицы выше. Спросить его о решении можно тремя способами, и путать их не стоит:

// r — токенов в секунду, b — ёмкость ведра (максимальный всплеск).
lim := rate.NewLimiter(rate.Every(time.Second/10), 20)  // 10 rps, burst 20

lim.Allow()          // не ждать: true/false сразу, для HTTP-хендлера
lim.Wait(ctx)        // ждать токен до дедлайна ctx, для фонового воркера
lim.Reserve()        // забронировать и узнать, сколько ждать: r.Delay()

В HTTP почти всегда нужен Allow. Wait внутри хендлера копит ждущие горутины, а за ними и таймауты: вместо быстрого 429 клиент получает зависший запрос. Wait хорош там, где мы сами исходящая сторона, например когда обходим сторонний API с лимитом 100 rps.

// Лимитер на ключ с вытеснением: без уборки map растёт бесконечно, это утечка.
type Limiters struct {
    mu   sync.Mutex
    m    map[string]*entry
    r    rate.Limit
    b    int
}
type entry struct {
    lim  *rate.Limiter
    seen time.Time
}

func (l *Limiters) Get(key string) *rate.Limiter {
    l.mu.Lock()
    defer l.mu.Unlock()
    e, ok := l.m[key]
    if !ok {
        e = &entry{lim: rate.NewLimiter(l.r, l.b)}
        l.m[key] = e
    }
    e.seen = time.Now()
    return e.lim
}

// Отдельная горутина раз в минуту выкидывает ключи, не встречавшиеся 10 минут.
// Или LRU фиксированного размера: память под нагрузкой предсказуема.

func RateLimit(l *Limiters, keyOf func(*http.Request) string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            if !l.Get(keyOf(r)).Allow() {
                w.Header().Set("Retry-After", "1")
                problem(w, http.StatusTooManyRequests, "rate_limited",
                    "Слишком много запросов, повторите позже")
                return
            }
            next.ServeHTTP(w, r)
        })
    }
}
Подвох локального лимитера

Он считает на инстанс. При 10 подах лимит «100 rps» превращается в 1000 rps суммарно, и цифра плавает при каждом автоскейлинге. Делить лимит на число реплик плохо: балансировщик распределяет неравномерно, а при выкатке число подов временно другое. Локальный лимитер честно решает две задачи: защищает сам процесс (не даёт одному клиенту съесть все горутины и коннекты к БД) и грубо отсекает лишнее. Для точного лимита на пользователя нужно общее состояние.

Распределённый лимитер на Redis

Когда лимит должен быть общим для всех инстансов, состояние переезжает в общее хранилище. Redis тут хорош по двум причинам. Он быстрый, а лимитер стоит на горячем пути каждого запроса. И он однопоточный, поэтому Lua-скрипт выполняется атомарно — между чтением счётчика и его записью никакой другой под не вклинится.

балансировщик один клиент → N подов API pod 1 своя память — не считается API pod 2 своя память — не считается API pod 3 своя память — не считается EVALSHA <sha> 1 rl:tb:user:42 now_ms rate burst cost один round-trip, атомарно Redis — общее состояние HASH rl:tb:user:42 tokens = 7.3 ts = 1756192841203 однопоточный → Lua выполняется целиком Что скрипт возвращает и что с этим делать { 1, remaining = 6, retry_ms = 0 } пропускаем, ставим X-RateLimit-Remaining: 6 { 0, remaining = 0, retry_ms = 4200 } 429 + Retry-After: 5 — округление всегда вверх Почему Lua, а не INCR и GET из Go: между чтением и записью влезает соседний под — классическая гонка read-modify-write. Redis недоступен: fail-open (пропускать, лимит временно не работает) или fail-closed (429/503) — это продуктовое решение, а не техническое.
Распределённый лимитер. Инстансы состояния не хранят, оно лежит в Redis, а решение принимает один атомарный вызов. Про два узких места спросят наверняка: Redis становится зависимостью на горячем пути (нужны жёсткий таймаут и понятная стратегия при отказе) и точкой концентрации нагрузки — по одному вызову на каждый входящий запрос.
// Token bucket в Redis: одно ведро на ключ, весь read-modify-write внутри Lua.
// KEYS[1] — ключ ведра.  ARGV: now_ms, rate (токенов в секунду), burst, cost.
var tokenBucket = redis.NewScript(`
local key   = KEYS[1]
local now   = tonumber(ARGV[1])
local rate  = tonumber(ARGV[2])
local burst = tonumber(ARGV[3])
local cost  = tonumber(ARGV[4])

local st     = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(st[1])
local ts     = tonumber(st[2])

if tokens == nil then          -- первое обращение: ведро полное
  tokens = burst
  ts     = now
end

-- долив за прошедшее время, с потолком в ёмкость ведра
local delta = math.max(0, now - ts) / 1000.0
tokens = math.min(burst, tokens + delta * rate)
ts     = now

local allowed = 0
local retry   = 0
if tokens >= cost then
  tokens  = tokens - cost
  allowed = 1
else
  -- сколько миллисекунд ждать, пока накапает недостающее
  retry = math.ceil(((cost - tokens) / rate) * 1000)
end

redis.call('HSET', key, 'tokens', tokens, 'ts', ts)
-- TTL с запасом на полное восстановление ведра: без него ключи копятся вечно
redis.call('PEXPIRE', key, math.ceil((burst / rate) * 1000) + 1000)

return { allowed, math.floor(tokens), retry }
`)

type Decision struct {
    Allowed   bool
    Remaining int
    RetryIn   time.Duration
}

func (l *RedisLimiter) Allow(ctx context.Context, key string, cost int) (Decision, error) {
    // Жёсткий таймаут: из-за лимитера запрос не должен упасть по таймауту.
    ctx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
    defer cancel()

    res, err := tokenBucket.Run(ctx, l.rdb, []string{"rl:tb:" + key},
        time.Now().UnixMilli(), l.rate, l.burst, cost).Int64Slice()
    if err != nil {
        // fail-open: лимитер сломался, а сервис продолжает работать.
        // Нужны метрика и алерт, иначе поломку никто не заметит.
        l.failures.Inc()
        return Decision{Allowed: true}, err
    }
    return Decision{
        Allowed:   res[0] == 1,
        Remaining: int(res[1]),
        RetryIn:   time.Duration(res[2]) * time.Millisecond,
    }, nil
}
Детали распределённого лимитера
  • Время берём одно. Можно передавать в скрипт now с клиента, и тогда на всех подах важен NTP. А можно брать redis.call('TIME') внутри скрипта — источник единый, но скрипт становится недетерминированным (старым версиям Redis это мешало при репликации командами; современные реплицируют эффекты, так что вариант рабочий).
  • Все ключи скрипта в KEYS. Иначе в Redis Cluster скрипт может обратиться к слоту на другом узле и упасть. Для нескольких ключей нужен hash tag: rl:{user:42}:min и rl:{user:42}:day лягут в один слот.
  • EVALSHA с фолбэком на EVAL. Скрипт кэшируется по SHA; после рестарта Redis кэш пуст, приходит NOSCRIPT, и клиент должен залить скрипт заново. go-redis с redis.NewScript делает это сам.
  • Лимитер добавляет round-trip. На горячем пути это лишний поход по сети к Redis на каждый запрос. Выручает гибридная схема: локальный token bucket с «выкупленным» у Redis пакетом разрешений (батч на 10–100 запросов). Точность немного теряется, зато нагрузка на Redis падает на порядок.
  • Несколько лимитов сразу. Обычно нужен не один: 10/с, 300/мин, 10 000/сутки. Проверять их надо в одном скрипте и списывать, только если прошли все, иначе счётчики разъедутся.

Что отдавать клиенту

Отказ должен быть машиночитаемым, чтобы клиент понял, что делать дальше, не разбирая текст.

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 5                          # секунды либо HTTP-дата
RateLimit-Policy: "default";q=100;w=60  # черновик IETF: квота 100 на 60 секунд
RateLimit: "default";r=0;t=5            # осталось 0, окно освободится через 5 с
X-RateLimit-Limit: 100                  # де-факто стандарт, его понимают все SDK
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1756192846           # unix-время сброса

{"type":"https://api.example.com/errors/rate-limited",
 "title":"Слишком много запросов","status":429,
 "detail":"Лимит 100 запросов в минуту исчерпан","retry_after":5}
  • Retry-After обязателен. Без него клиент начнёт ретраить сразу и сделает только хуже. Тот же заголовок уместен на 503 при перегрузке.
  • Заголовки лимита отдавать и на успешных ответах, чтобы вежливый клиент притормозил до того, как упрётся. Не забыть добавить их в Access-Control-Expose-Headers, иначе браузерный SPA их не увидит.
  • 429 против 503. 429 значит «конкретно ты превысил свою квоту», а 503 (в идеале с Retry-After) значит «плохо всему сервису». Смешивать их вредно: алерты на них настраивают по-разному, и клиенты реагируют по-разному.
  • Лишнего о лимитах не раскрывать. Для анонимных запросов и для эндпоинта логина точные цифры «осталось 0 из 5» подсказывают перебиральщику ритм. Там достаточно 429 и Retry-After.
  • Ответ 429 должен быть дешёвым. Ходить в БД, писать подробный лог на каждый отказ и рендерить тяжёлое тело незачем — иначе лимитер сам становится вектором DoS. Логировать лучше агрегатами, а метрику вести счётчиком с меткой причины.
  • Со стороны клиента на 429 отвечают экспоненциальным backoff с джиттером, соблюдают Retry-After и ставят circuit breaker. Если вся армия клиентов ретраит без джиттера, получается синхронный «стук» ровно через N секунд.

Где ставить и по какому ключу считать

УровеньЧто умеетЧего не умеет
CDN / WAF (Cloudflare, Fastly)отсекать объём до нашего канала, гео- и ASN-правила, защита от L7-DDoSничего не знает про наши сущности: тарифы, тенантов, стоимость операции
Edge: ingress, API-gateway, nginxдёшево, до приложения, единая точка для всех сервисов; грубые лимиты по IP и путине знает, кто пользователь, пока не разобран токен; сложно завязать на бизнес-правила
Приложениезнает пользователя, тариф, стоимость конкретной операции, может списывать разный costработает уже после того, как соединение принято и токен разобран — то есть ресурсы частично потрачены

Правильный ответ: оба уровня. И это не компромисс, у уровней просто разные задачи: периметр отсекает объём и мусор, приложение следит за бизнес-квотами. На собеседовании хорошо звучит так: «грубый лимит — как можно раньше и как можно дешевле; точный лимит — там, где есть контекст».

КлючКогдаПодвох
Пользователь / организациявсё, что за аутентификациейлучший ключ: не бьёт по соседям, переживает смену IP; квота может зависеть от тарифа
API-ключ / client_idмашинные интеграции, партнёрысчитать и по ключу, и по владельцу: по ключу — чтобы одна сломанная интеграция не съела квоту остальных, по владельцу — иначе партнёр обойдёт лимит, заведя десять ключей
IPвсё до аутентификации: логин, регистрация, восстановление пароля, публичные ручкиза одним IP сидит корпоративный NAT или целый мобильный оператор — лимит бьёт по тысячам невиновных; IPv6 надо агрегировать по /64, иначе у атакующего бесконечно много «разных» адресов
Составной: IP + логин, IP + эндпоинтзащита от переборарабочая практика: отдельный жёсткий лимит на пару «этот логин» и на «этот IP», плюс глобальный лимит на неудачные попытки
X-Forwarded-For подделывается тривиально

Заголовок начинает клиент, а каждый прокси по пути дописывает в него своё. Если брать «первый элемент списка», атакующий пишет туда что угодно и получает новый лимит на каждый запрос, а заодно может подставить чужой IP и добиться блокировки невиновного. Правило: доверять можно только тем прокси, которые мы контролируем, и отсчитывать справа налево ровно столько шагов, сколько своих прокси стоит перед приложением.

// trustedProxies — сколько НАШИХ прокси стоит перед сервисом (обычно 1–2).
func clientIP(r *http.Request, trustedProxies int) netip.Addr {
    peer, _ := netip.ParseAddrPort(r.RemoteAddr)   // это подделать нельзя
    if trustedProxies == 0 {
        return peer.Addr()
    }
    // все строки заголовка: прокси может дописать свою отдельной строкой
    parts := strings.Split(strings.Join(r.Header.Values("X-Forwarded-For"), ","), ",")
    idx := len(parts) - trustedProxies             // отсчёт СПРАВА
    if idx < 0 || idx >= len(parts) {
        return peer.Addr()                         // список короче ожидаемого — не верим
    }
    ip, err := netip.ParseAddr(strings.TrimSpace(parts[idx]))
    if err != nil {
        return peer.Addr()
    }
    return ip.Unmap()
}
// Альтернатива, если инфраструктура позволяет: заголовок, который наш edge
// ПЕРЕЗАПИСЫВАЕТ (не дополняет) — CF-Connecting-IP, True-Client-IP,
// либо стандартный Forwarded из RFC 7239. Всё, что пришло снаружи, стирается.

Ретраи-дубли: идемпотентность и дедупликация

Эту задачу часто путают с rate limiting, хотя она отдельная. Rate limiting защищает от слишком многих запросов, идемпотентность — от повторов одного и того же. За повторами почти никогда не стоит злой умысел: оборвался ответ, клиент словил таймаут, сработал автоматический ретрай библиотеки, пользователь дважды нажал кнопку, брокер доставил сообщение at-least-once. Сам запрос при этом выполнился — потерялся только ответ.

Формулировка для ответа

Ненадёжная сеть плюс ретраи гарантируют дубли. Избежать повторов не выйдет, поэтому вопрос в том, как сделать повтор безвредным. Для этого операция должна быть идемпотентной: по своей природе (PUT, DELETE, «установить статус в X») или искусственно, через ключ идемпотентности.

Механику Idempotency-Key разбирали в главе 10.1, здесь посмотрим на неё со стороны безопасности и надёжности. Хватит трёх пунктов.

  • Ключ занимают до внешнего эффекта. Строку в idempotency_keys с UNIQUE и статусом in_progress вставляют раньше, чем идут в платёжный шлюз. Тогда параллельный дубль упрётся в конфликт уникальности и вторую оплату не запустит. Порядок «сходили в банк → записали ключ» не спасает вообще: проблема живёт как раз между этими двумя шагами.
  • Ключ привязан к субъекту и к телу. Уникальна пара (user_id, key), иначе через чужой ключ можно прочитать чужой сохранённый ответ. Плюс хеш тела: тот же ключ с другим телом означает ошибку клиента (422), старый ответ на него не отдают.
  • Сохраняют сам ответ, одного факта мало. Повтор должен получить тот же статус и то же тело, что и оригинал, иначе клиент, потерявший первый ответ, так и не узнает результата.

Есть и второй, независимый механизм: дедупликация по естественному ключу. Она нужна там, где ключ идемпотентности прислать некому: входящие вебхуки, сообщения из брокера, импорт файлов. Логика та же, только идентификатор берут из полезной нагрузки.

// Вебхук от платёжной системы: доставка at-least-once, дубли неизбежны.
// Уникальный индекс делает вторую вставку конфликтом, а не вторым эффектом.
func (s *Service) HandleWebhook(ctx context.Context, ev Event) error {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil { return err }
    defer tx.Rollback()

    // UNIQUE (provider, event_id)
    res, err := tx.ExecContext(ctx, `
        INSERT INTO processed_events (provider, event_id, received_at)
        VALUES ($1, $2, now())
        ON CONFLICT (provider, event_id) DO NOTHING`, ev.Provider, ev.ID)
    if err != nil { return err }

    if n, _ := res.RowsAffected(); n == 0 {
        return nil          // уже обработано: тихо отвечаем 200, иначе провайдер будет ретраить вечно
    }
    if err := s.apply(ctx, tx, ev); err != nil { return err }
    return tx.Commit()      // отметка и эффект коммитятся одной транзакцией
}
  • Дедупликация и эффект живут в одной транзакции. Если отметить «обработано» в Redis, а потом упасть на записи в БД, событие потеряется навсегда. Проще всего сделать это корректно одной транзакцией БД; Redis годится как быстрый фильтр перед ней, но источником истины его делать нельзя.
  • Проверять подпись вебхука (HMAC от тела с общим секретом, constant-time сравнение) и метку времени — иначе перехваченный когда-то запрос можно проиграть заново (replay). Подпись считают по сырому телу, до парсинга JSON: пересериализация меняет байты.
  • TTL у таблицы дедупликации. Хранить ключи вечно нельзя, но окно должно с запасом перекрывать политику ретраев провайдера (Stripe, например, ретраит до трёх суток).
  • Отвечать быстро. Обработку вебхука лучше свести к «проверил подпись → положил в очередь → 200». Если обрабатывать долго и синхронно, провайдер отвалится по таймауту и пришлёт ретрай, то есть медленный обработчик сам плодит дубли.

Валидация входных данных

Валидация не сводится к «проверить, что поле не пустое». Она явно описывает множество допустимых входов и стоит на границе системы. Всё, что не описано, отвергается, поэтому валидация, помимо удобства, ещё и защищает.

Где валидировать

  • На границе, сразу после разбора запроса, проверяют синтаксис и формат: типы, диапазоны, длины, обязательность, регулярки. На ошибку отвечают 400 (тело неразборчиво) или 422 (разобрано, но не проходит правила).
  • В домене живут бизнес-инварианты, которым нужен контекст: «дата окончания позже даты начала», «сумма не превышает остаток», «этот статус недостижим из текущего». Их место в сервисном слое, потому что они нужны любому вызывающему: HTTP, gRPC, CLI-команде, консьюмеру из брокера.
  • В базе — последняя линия: NOT NULL, CHECK, UNIQUE, внешние ключи. Это единственный уровень, который переживёт баг в коде и ручной UPDATE из psql.
  • На фронте проверяют только ради UX: это дублирование, защиты оно не даёт.
Whitelist против blacklist

Чёрный список перечисляет плохое и проигрывает всегда: список обходов бесконечен и растёт быстрее, чем правила. Белый список перечисляет допустимое и ошибается в безопасную сторону — незнакомый вход он отвергает. На практике: не «вырезать <script>», а «имя — от 1 до 64 символов из букв, цифр, пробела и дефиса»; не «запретить ../», а «имя файла соответствует ^[a-zA-Z0-9_-]{1,64}\.(png|jpg)$»; не «отфильтровать опасные схемы URL», а «разрешены только http и https».

go-playground/validator и кастомные правила

type createOrderReq struct {
    Email    string    `json:"email"     validate:"required,email,max=254"`
    Currency string    `json:"currency"  validate:"required,iso4217"`
    Amount   int64     `json:"amount"    validate:"required,gt=0,lte=100000000"`
    Comment  string    `json:"comment"   validate:"max=500"`
    DeliverAt time.Time `json:"deliver_at" validate:"required,gt"`   // gt без числа у времени: позже now
    Items    []item    `json:"items"     validate:"required,min=1,max=100,dive"`
    Phone    string    `json:"phone"     validate:"omitempty,e164"`
}

// Валидатор создаётся один раз: он кэширует разбор тегов через reflect,
// а пересоздание на каждый запрос заметно бьёт по производительности.
var validate = func() *validator.Validate {
    v := validator.New(validator.WithRequiredStructEnabled())

    // Имена полей в ошибках берём из json-тегов, чтобы наружу уезжал
    // контракт API, а не имена полей Go.
    v.RegisterTagNameFunc(func(f reflect.StructField) string {
        name := strings.SplitN(f.Tag.Get("json"), ",", 2)[0]
        if name == "-" { return "" }
        return name
    })

    // Кастомное правило: слаг статьи.
    var slugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
    v.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
        s := fl.Field().String()
        return len(s) <= 80 && slugRe.MatchString(s)
    })

    // Правило уровня структуры: когда одно поле зависит от другого.
    v.RegisterStructValidation(func(sl validator.StructLevel) {
        r := sl.Current().Interface().(createOrderReq)
        if r.Currency == "RUB" && r.Amount < 100 {
            sl.ReportError(r.Amount, "amount", "Amount", "min_rub", "")
        }
    }, createOrderReq{})
    return v
}()

Ошибки валидатора нельзя отдавать наружу как есть: текст Key: 'createOrderReq.amount' Error:Field validation for 'amount' failed on the 'gt' tag раскрывает внутренние имена и нечитаем для клиента. Их превращают в структуру «поле → код ошибки», совместимую с общим форматом ошибок из главы 10.1.

func validationProblem(err error) *Problem {
    var ve validator.ValidationErrors
    if !errors.As(err, &ve) {
        return &Problem{Status: 400, Code: "bad_request", Title: "Некорректный запрос"}
    }
    p := &Problem{Status: 422, Code: "validation_failed", Title: "Проверьте поля формы"}
    for _, fe := range ve {
        p.Errors = append(p.Errors, FieldError{
            Field: fe.Field(),   // "amount" благодаря RegisterTagNameFunc
            Code:  fe.Tag(),     // "gt", "required", "email" — машиночитаемо
            // Текст для человека собираем сами; сообщение библиотеки наружу не отдаём.
            Message: humanize(fe),
        })
    }
    return p
}
Лимиты размеров тоже валидация, и о ней забывают чаще всего
  • Тело запроса: http.MaxBytesReader(w, r.Body, 1<<20). Без него io.ReadAll или json.NewDecoder(r.Body).Decode с радостью примут гигабайт и положат процесс по памяти. MaxBytesReader лучше простого io.LimitReader: он ещё и закрывает соединение и даёт понятную ошибку.
  • Заголовки: Server.MaxHeaderBytes (по умолчанию 1 МиБ).
  • Multipart: r.ParseMultipartForm(n) ограничивает только память под разбор, остальное уезжает во временные файлы на диск, так что общий предел всё равно ставит MaxBytesReader. Реальный тип файла проверяют по сигнатуре (http.DetectContentType), не доверяя расширению и заголовку клиента.
  • Глубина и количество: длина массивов (max=100 и dive), вложенность JSON, число элементов в IN, размер страницы пагинации. Иначе безобидная ручка становится усилителем нагрузки: ?limit=1000000.
  • Регулярные выражения на входных данных: в Go RE2 гарантирует линейное время, поэтому ReDoS нам не грозит (частый каверзный вопрос). Но правило «не строить регулярку из пользовательской строки» остаётся.
  • Строки приводить к NFC и проверять на управляющие символы. Иначе в имя пролезет \r\n (испортит логи и заголовки), нулевой байт или невидимая RTL-метка.

HTTPS everywhere и security-заголовки

Базовая гигиена, про неё спрашивают под конец секции. TLS обязателен везде, включая внутренние вызовы: сеть внутри периметра прослушивается ровно так же, а модель «мягкая внутри, твёрдая снаружи» разваливается от одной скомпрометированной машины. Минимум на практике: TLS 1.2+ (лучше 1.3), автоматические сертификаты, редирект 80 → 443. Межсервисный трафик закрывают mTLS, обычно через service mesh, чтобы не ротировать сертификаты руками.

ЗаголовокЧто делаетЗначение по умолчанию
Strict-Transport-Securityбраузер запоминает: на этот домен — только HTTPS, без возможности кликнуть «продолжить». Убивает атаку с понижением протокола; самый первый визит закрывает только preloadmax-age=31536000; includeSubDomains; preload — только осознанно
X-Content-Type-Optionsзапрещает MIME-sniffingnosniff — всегда
X-Frame-Optionsзащита от clickjacking (наш сайт в прозрачном iframe поверх ловушки)DENY; современная замена — frame-ancestors в CSP
Content-Security-Policyоткуда можно грузить скрипты, стили, картинки; разрешён ли инлайндля API: default-src 'none'; frame-ancestors 'none'
Referrer-Policyне утекать полный URL (с токенами и id) на сторонние сайтыstrict-origin-when-cross-origin
Permissions-Policyотключить ненужные API браузера (камера, геолокация)camera=(), microphone=(), geolocation=()
Cache-Controlчтобы приватный ответ не осел в общем кэше или в истории браузераno-store на всё персональное
func SecurityHeaders(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        h := w.Header()
        // HSTS имеет смысл только на HTTPS-ответе: по http браузер его игнорирует,
        // а на localhost он ещё и мешает разработке.
        if r.TLS != nil {
            h.Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
        }
        h.Set("X-Content-Type-Options", "nosniff")
        h.Set("X-Frame-Options", "DENY")
        h.Set("Referrer-Policy", "strict-origin-when-cross-origin")
        h.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
        h.Set("Cache-Control", "no-store")
        // Server и X-Powered-By net/http сам не ставит. А Del тут не помог бы:
        // он выполняется раньше хендлера, который мог их выставить.
        next.ServeHTTP(w, r)
    })
}
HSTS: две ловушки
  • Первый запрос всё равно уязвим — браузер узнаёт о политике только из ответа, который уже пришёл. Это лечит список preload, зашитый в браузеры.
  • preload и includeSubDomains практически необратимы. Пока max-age не истечёт у каждого посетителя (а из preload-списка домен удаляют месяцами), все поддомены обязаны работать по HTTPS. Один забытый внутренний поддомен на http — и он недоступен для всех, кто хоть раз заходил на основной сайт. Выкатывать надо ступенчато: сначала маленький max-age, потом includeSubDomains, и только потом preload.
Если спросят «а что ещё»
  • Не отдавать наружу детали ошибок: ни стек-трейса, ни текста ошибки СУБД, ни версии сервера. Клиент видит код, обезличенное сообщение и trace_id, подробности идут в лог под тем же id.
  • Не логировать секреты и персональные данные: заголовок Authorization, тела с паролями, номера карт. Маскирует сам логгер: на «не забыть в каждом месте» надежды нет.
  • Зависимости: govulncheck в CI (кроме версий он проверяет, достижим ли уязвимый код), go.sum, обновления по расписанию, а не «когда прижмёт».
  • Аудит-лог значимых действий: смена прав, вход, изменение платёжных реквизитов. Пишется отдельно от прикладного лога, и сервису править его нельзя.
  • Правильные атрибуты куки: Secure, HttpOnly, SameSite, префикс __Host- для сессионной куки (он требует Secure и Path=/ и запрещает Domain).

Вопросы

13
Суть: пользовательская строка попадает в текст запроса и разбирается СУБД как SQL — граница между данными и кодом стёрта. Лечится не фильтрацией символов, а параметризацией: текст запроса уходит отдельно от значений и разбирается до того, как значение вообще увидено.

Уязвимый код и что с ним делают

q := "SELECT id, email FROM users WHERE id = " + raw   // raw = "1 OR 1=1 --"
row := db.QueryRowContext(ctx, q)

Получается WHERE id = 1 OR 1=1 --: условие всегда истинно, хвост запроса закомментирован. Дальше атаки усложняются: UNION SELECT для чужих колонок, boolean-based и time-based blind (AND (SELECT 1 FROM pg_sleep(5)) = 1) там, где ответа не видно, stacked queries (1; DROP TABLE users; --) там, где драйвер разрешает несколько операторов.

Починка

row := db.QueryRowContext(ctx, "SELECT id, email FROM users WHERE id = $1", raw)

В расширенном протоколе клиент шлёт Parse с текстом и Bind со значениями. К моменту, когда сервер видит значение, дерево разбора уже построено, а место подстановки стало конкретным листом этого дерева. Строка не может «дорасти» до нового предиката: грамматика разобрана. Поэтому параметризация не смягчает риск, а устраняет его.

Почему ручное экранирование проигрывает

  • Правила зависят от диалекта и настроек: NO_BACKSLASH_ESCAPES в MySQL, standard_conforming_strings в PostgreSQL.
  • Кодировки: в GBK/SJIS байт 0x5C может быть частью многобайтового символа, добавленный слеш «съедается» и кавычка освобождается.
  • Числовой контекст: если значение подставляется без кавычек, экранировать кавычки бессмысленно.
  • Это ручная работа: достаточно одного забытого места, чтобы всё остальное потеряло смысл. Параметризация ошибается в безопасную сторону.

ORDER BY, имена таблиц и другие места без плейсхолдеров

Плейсхолдер подставляет значение. А идентификатор входит в грамматику, и параметр туда не встанет. Выход один: маппинг из белого списка. Клиент присылает ключ, сервер превращает его в заранее написанный фрагмент SQL.

var sortable = map[string]string{"created_at": "p.created_at", "views": "p.views_count"}
col, ok := sortable[q.Get("sort")]      // неизвестный ключ → 400, а не склейка
dir := "ASC"; if strings.EqualFold(q.Get("order"), "desc") { dir = "DESC" }

Если динамическое имя схемы или таблицы неизбежно (мультитенантность), его цитируют библиотекой (pgx.Identifier{…}.Sanitize()) и всё равно сверяют со списком: цитирование спасает от синтаксической инъекции, но не от «покажи схему чужого клиента». Для списка в IN годятся сгенерированные кодом $1..$N или = ANY($1) с массивом одним параметром.

Частые продолжения

«ORM защищает?» — нет: Where("x = '"+s+"'"), Raw, Exec дают те же грабли, защищает плейсхолдер, а не ORM. «Что ещё сделать?» — least privilege у роли БД (без DROP и чужих схем), не отдавать текст ошибки СУБД наружу, gosec в CI. «Бывают ли другие инъекции?» — NoSQL ($ne в фильтре), command injection, LDAP, шаблонизаторы; принцип везде один: не собирать команду строкой.

Суть: XSS — инъекция в HTML: строка пользователя исполняется браузером другого пользователя в контексте нашего origin. Бэкенд закрывает четыре вещи: экранирование при рендере, точный Content-Type плюс nosniff, CSP и то, что вообще попадает в хранилище.

Три вида

  • Stored: нагрузка лежит в нашей БД (комментарий, имя профиля) и отдаётся всем, кто откроет страницу. Самый опасный вид.
  • Reflected приходит в запросе и возвращается в HTML ответа (?q=<script>…).
  • В DOM-based сервер отдал чистый HTML, но JS сам положил location.hash в innerHTML. Чинит фронт; бэкенд помогает только через CSP.

Рычаги бэкенда

  1. Экранирование при рендере. В Go это html/template, а не text/template: он учитывает контекст и знает, куда попадает значение (текст, атрибут, href, <script>, CSS). template.HTML отключает экранирование — только для доверенного HTML. Пользовательскую разметку чистит санитайзер с белым списком (bluemonday); чёрные списки обходятся <img onerror>, <svg onload> и десятком других вариантов.
  2. Content-Type и X-Content-Type-Options: nosniff. Без точного типа браузер угадывает по байтам, и «аватар», начинающийся с <html><script>, исполняется на нашем домене. Пользовательские файлы отдают с Content-Disposition: attachment, а по-хорошему с отдельного домена.
  3. CSP работает вторым эшелоном: script-src 'self' 'nonce-…' без 'unsafe-inline'. JSON-API хватит default-src 'none'; frame-ancestors 'none'.
  4. Валидация на входе: не хранить в базе ничего, кроме допустимых значений поля.

Да, JSON-эндпоинт тоже бывает вектором

  • Тот же JSON, отданный как text/html или без типа, исполняется. Хватит отражённого значения из запроса в тексте ошибки.
  • В JSONP имя коллбэка из query попадает в исполняемый код, и заодно это полный обход CORS. Не использовать.
  • Даже идеальный JSON становится XSS, если фронт положит поле в innerHTML.
«httpOnly-кука защищает от XSS» — неверная формулировка

Она не даёт прочитать куку, и это правильный дефолт. Но скрипт на нашем origin может просто отправлять запросы — браузер приложит куку сам. Токен не украден, зато перевод выполнен. При XSS всё, что доступно пользователю, доступно и атакующему; httpOnly снижает ущерб, а не устраняет его.

Суть: браузер прикладывает куку исходя из адреса назначения, а не из того, кто инициировал запрос. Значит, чужая страница может заставить браузер жертвы выполнить изменяющую операцию на нашем домене — полностью аутентифицированную. Токен в заголовке Authorization в эту схему не попадает: его никто не проставляет автоматически. Защита: SameSite, с Go 1.25 — штатный http.CrossOriginProtection, и токены там, где нужны гарантии.

Атака по шагам

  1. Жертва залогинена на bank.example — кука сессии в браузере.
  2. Жертва открывает evil.com, где лежит форма action="https://bank.example/transfer" method=POST с автосабмитом (или просто <img src>, если операция висит на GET).
  3. Браузер отправляет POST на наш домен и сам прикладывает куку.
  4. Сервер видит валидную сессию и считает запрос осознанным. Перевод выполнен.
  5. Ответ атакующему недоступен (SOP), но он ему и не нужен: атака бьёт по побочному эффекту, а не по чтению данных.

Почему Authorization: Bearer иммунен

Разница в том, кто прикрепляет учётные данные. Куку прикрепляет браузер по правилу «домен совпал». Заголовок проставляет наш JavaScript на нашем origin, и чужая страница не может ни прочитать токен, ни заставить браузер добавить заголовок. К тому же кастомный заголовок выводит запрос из «простых»: браузер сначала пошлёт preflight, а его для чужого Origin мы не разрешим.

SameSite

ЗначениеКука уходит на cross-site запросПрактика
Strictникогдаrefresh-кука, админка. Побочка: переход по ссылке из почты выглядит как разлогин
Laxтолько на top-level GET-навигациюрабочий дефолт; POST, fetch, iframe, img куку не получат
Noneвсегда, требует Secureчестный кросс-домен (виджет, SSO) — и обязательный CSRF-токен

Оговорки такие: «same-site» сравнивается по eTLD+1, поэтому захваченный поддомен обходит защиту; Lax не закрывает изменяющие операции на GET, так что соблюдение семантики методов тоже защищает от CSRF; старые браузеры атрибут игнорируют.

CSRF-токены

  • Synchronizer token: случайное значение хранится в состоянии сессии, отдаётся в HTML и сверяется на изменяющем запросе. Надёжно, но требует состояния.
  • Double submit кладёт значение в отдельную не-httpOnly куку, фронт дублирует его в заголовок X-CSRF-Token, сервер сравнивает. Работает потому, что чужой сайт куку прочитать не может. Слабое место в поддомене, способном записать куку на родительский домен, поэтому в проде берут signed double submit: значение связано с сессией через HMAC.
  • Сравниваем через subtle.ConstantTimeCompare, безопасные методы пропускаем, токен ротируем при логине.

Go 1.25: http.CrossOriginProtection — то же самое без токенов

В старых гайдах этого нет. В Go 1.25 в net/http появилась штатная защита от CSRF на Fetch metadata — заголовках, которые к каждому запросу на HTTPS и localhost добавляет сам браузер и которые страница-инициатор подделать не может. Главный из них, Sec-Fetch-Site, сообщает, откуда запрос инициирован (same-origin, same-site, cross-site, none). Правило: небезопасный метод при Sec-Fetch-Site, отличном от same-origin и none, получает отказ 403. Ни состояния, ни куки, ни правок на фронте.

cop := http.NewCrossOriginProtection()
_ = cop.AddTrustedOrigin("https://admin.example.com") // честный фронт на другом origin
cop.AddInsecureBypassPattern("/webhooks/")            // ручки, куда браузер не ходит
srv := &http.Server{Handler: cop.Handler(mux)}        // всё, защита включена

Sec-Fetch-Site: same-site он тоже отвергает и потому закрывает атаку с захваченного поддомена, где SameSite бессилен. А запрос без Fetch metadata и без Origin пропускается, иначе сломались бы все не-браузерные клиенты. Обратная сторона: без защиты остаётся браузер, который не шлёт ни одного из двух заголовков. Старый браузер без Sec-Fetch-Site, но с Origin Go проверит запасным способом, по Host. Поэтому штатный middleware становится новой базой, а токен остаётся страховкой для критичных операций и старых клиентов.

Дополнительные линии

Требование Content-Type: application/json (иначе 415) — HTML-форма такой запрос отправить не может. И повторный ввод пароля или 2FA на действительно опасных операциях: его не сломает ни CSRF, ни XSS.

Суть: клиент управляет адресом, по которому наш сервер делает исходящий запрос. Ценность в том, что сервер стоит внутри периметра и видит то, что не видно снаружи: облачные метаданные, внутренние админки, служебные порты. Защита — allowlist, а на техническом уровне — проверка итогового IP перед connect, а не строки URL.

Где появляется и чем опасно

Точки входа: «загрузить картинку по URL», вебхуки, импорт из внешнего источника, генерация превью ссылки, прокси-ручка. Цели атакующего:

  • На 169.254.169.254 лежат метаданные облака с временными ключами роли инстанса. Из-за этого и появился IMDSv2 с обязательным PUT-токеном.
  • Внутренние сервисы без аутентификации «потому что они не торчат наружу»: Kubernetes API, Consul, Elasticsearch, панели.
  • Сканирование сети по времени ответа и коду ошибки (blind SSRF).
  • Смена схемы: file:// читает локальные файлы, а через gopher:// и dict:// собирают произвольный TCP-пакет. http.Client в Go таких схем не знает, но libcurl и конвертеры документов знают.

Защита по убыванию надёжности

  1. Allowlist хостов или заранее зарегистрированные интеграции. Только эта защита не проигрывает гонку с обходами.
  2. Сетевая изоляция: egress через прокси со своей политикой, network policy, запрещающая поду ходить во внутренние подсети напрямую.
  3. Запрет приватных диапазонов на этапе соединения через хук Control у net.Dialer. Хук вызывается уже с готовым адресом, поэтому DNS rebinding (резолв поменялся между проверкой и connect) не проходит.
var safeClient = &http.Client{
    Timeout: 5 * time.Second,
    Transport: &http.Transport{
        DialContext: (&net.Dialer{Control: safeControl}).DialContext, // блокирует 10/8, 127/8, 169.254/16, fc00::/7 …
    },
    CheckRedirect: func(r *http.Request, via []*http.Request) error {
        if len(via) >= 3 { return errors.New("too many redirects") }
        return checkScheme(r.URL)   // за редиректом новый адрес, проверяем каждый прыжок
    },
}
Что забывают
  • Редиректы: разрешённый хост отвечает 302 на 169.254.169.254, и одноразовая проверка на входе обойдена.
  • Обходы парсинга: http://[::1], http://2130706433, http://0177.0.0.1, localtest.me (публичный домен в 127.0.0.1), http://a@evil.com@internal. Строку проверять заведомо проигрышно, а netip.Addr надёжно. И не забыть Unmap() для ::ffff:-адресов.
  • Не возвращать наружу тело, статус и время ответа стороннего ресурса, иначе даже безопасный фетч превращается в сканер сети.
  • Лимиты: таймаут, io.LimitReader, проверка Content-Type. Без них SSRF заодно становится DoS против нас.
Суть: инъекцию ловит линтер и она одинакова везде, а «этот заказ принадлежит этому пользователю» — бизнес-правило, которое надо написать руками в каждом обработчике. Достаточно один раз забыть. Поэтому сломанный контроль доступа стабильно на первом месте в OWASP Top 10 и в отчётах bug bounty.

Как выглядит

// Middleware проверил, что пользователь аутентифицирован.
// Что заказ его, не проверил никто. /v1/orders/1235 вместо 1234.
order, err := repo.GetByID(ctx, id)

// Правильно: владение уезжает в WHERE и его нельзя «забыть проверить».
order, err := repo.GetForUser(ctx, id, auth.UserID(ctx))   // WHERE id=$1 AND user_id=$2

Правила, закрывающие класс целиком

  • Владение проверяется в том же запросе к БД, а не отдельным if после чтения: и забыть труднее, и гонок нет. Сложные правила выносят в явную функцию can(ctx, subject, action, object) в сервисном слое, где объект уже известен.
  • Авторизация не живёт целиком в middleware. Middleware закрывает грубое («роль admin на /admin/*»), объектные права — только там, где известен объект.
  • Fail closed. По умолчанию роутер требует авторизацию, публичные маршруты помечаются явно. Полезный тест: пройтись по всем зарегистрированным маршрутам и убедиться, что у каждого есть политика.
  • 404 вместо 403, если уже сам факт существования объекта раскрывает лишнее.
  • Непредсказуемые id только смягчают проблему. UUID убирает тривиальный перебор, но id утекают в логи, рефереры, шаринг ссылок.
  • Массовые операции, вложенные ресурсы, экспорт, GraphQL-резолверы: тут проверку забывают чаще всего, потому что она нужна для каждого элемента.
  • Скрытая кнопка не заменяет контроль доступа. Второй частый случай не про объект, а про действие: /admin/*, открытый всем, «потому что ссылки нет».
Что говорить про тесты

Убедительнее всего на собеседовании звучит «мы это тестируем». Интеграционный тест, где второй пользователь пытается прочитать и изменить объекты первого и ожидает 404/403, ловит регрессии лучше любого ревью. Хорошо завести шаблон такого теста для каждого нового ресурса.

Суть: json.Decode заполняет те поля, которые прислали, а прислать могут любые — включая is_admin и balance. Лечится не проверкой, а типом: отдельный DTO на запрос, отдельный на ответ, перенос в домен — явными присваиваниями.

Как выглядит дыра

type User struct {
    ID int64 `json:"id"`; Email string `json:"email"`
    IsAdmin bool `json:"is_admin"`; Balance int64 `json:"balance"`
}
var u User
json.NewDecoder(r.Body).Decode(&u)   // {"is_admin": true, "balance": 999999}
repo.Update(ctx, &u)                 // и это уезжает в UPDATE

Как правильно

type updateProfileReq struct {          // ровно то, что клиенту разрешено прислать
    DisplayName string `json:"display_name" validate:"required,max=64"`
    Bio         string `json:"bio"          validate:"max=500"`
}
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10))
dec.DisallowUnknownFields()
// … и перенос в домен поле за полем (забытое новое поле ловят тест или exhaustruct)

Что спрашивают следом

  • «Достаточно ли DisallowUnknownFields Нет. Это помощь на этапе разработки, но она ломает совместимость: клиент чуть новее, и интеграция уже падает. Защищает отсутствие опасных полей в DTO, а не запрет лишних.
  • «Как отличить "не прислали" от "прислали ноль"?» Для PATCH это принципиально: bio: "" — очистить, отсутствие поля — не трогать. Указатели *string, обёртка Optional[T] или json.RawMessage.
  • «А ответ?» Модель ответа тоже нужна отдельная, иначе однажды наружу уедет password_hash или внутренний комментарий модератора. Обратная сторона той же ошибки: отдавая доменную модель, мы делаем любое внутреннее переименование ломающим изменением API.
  • «А если полей много?» Кодогенерация или маппер, но не «сэкономим на типе». Три структуры вместо одной и есть цена явного контракта.
Суть: CORS — не защита сервера, а механизм браузера, позволяющий ослабить Same-Origin Policy. Сервер заголовками разрешает конкретному origin читать свой ответ. Никакого влияния на curl, Postman и любой не-браузер он не имеет.

Три следствия, снимающие все вопросы

  • CORS ничего не закрывает. Если API должен быть закрыт, его закрывают аутентификация и авторизация.
  • SOP блокирует чтение, а не отправку. Простой запрос уже дошёл до сервера и выполнился, браузер просто не отдал JS ответ. Потому CSRF и существует.
  • Origin = схема + хост + порт. http://x и https://x считаются разными origin.

Simple против preflight

«Простым» считается запрос GET/HEAD/POST с Content-Type из трёх разрешённых (text/plain, form-urlencoded, multipart) и без кастомных заголовков. Он уходит сразу, остальным предшествует OPTIONS:

→ OPTIONS /v1/orders/42
  Origin: https://app.example.com
  Access-Control-Request-Method: DELETE
  Access-Control-Request-Headers: authorization, content-type

← 204 No Content
  Access-Control-Allow-Origin: https://app.example.com
  Access-Control-Allow-Methods: GET, POST, DELETE
  Access-Control-Allow-Headers: authorization, content-type
  Access-Control-Allow-Credentials: true
  Access-Control-Max-Age: 600
  Vary: Origin

→ DELETE /v1/orders/42          # только теперь уходит боевой запрос
← 200 OK + Access-Control-Allow-Origin  # заголовок обязателен и здесь

Разница на практике: preflight предотвращает побочный эффект (боевой запрос не уйдёт), а simple нет. Любой JSON-API с Authorization всегда идёт с preflight, поэтому HTML-форма не может подделать JSON-запрос.

Типичные ошибки

  • Allow-Origin: * плюс Allow-Credentials: true — спецификация это запрещает, браузер отклонит. Логика: «любой сайт» + «с куками жертвы».
  • Отражение Origin без проверки даёт ту же дыру, только спецификацию обмануть удалось. Любой сайт читает ответы с сессией жертвы. Одна из самых частых находок на пентесте.
  • Слабое сравнение: HasPrefix(o, "https://example.com") пропускает https://example.com.evil.net. Только точное совпадение с элементом списка.
  • Забытый Vary: Origin — кэш (CDN, прокси, браузер) отдаст ответ с чужим Allow-Origin. В одну сторону — сломанный фронт, в другую — утечка разрешения. Ставить надо всегда, даже когда origin не разрешён.
  • null в белом списке — его присылают песочницы iframe sandbox и локальные файлы; это «впустить кого угодно».
  • Preflight не доходит до обработчика: OPTIONS должен отвечать до auth-middleware, потому что браузер не прикладывает к нему ни кук, ни Authorization. Симптом — «CORS-ошибка, хотя всё настроено».
  • Забытый Access-Control-Expose-Headers: свои X-Request-Id, X-RateLimit-*, Location фронт не увидит, по умолчанию ему доступны только семь базовых заголовков ответа.
Вопрос-ловушка

«У нас CORS настроен только на наш домен — значит, API защищён от чужих?» Нет. Заголовки Access-Control-* интересуют исключительно браузер; curl их проигнорирует и получит ответ. И зеркальный вопрос: «почему запрос дошёл до сервера, хотя браузер показал CORS-ошибку?» Потому что для простых запросов блокируется чтение ответа, а не отправка.

Суть: все отвечают на вопрос «пропустить или отклонить», но по-разному ведут себя на всплеске. Fixed window пропускает до двойного лимита на границе окна; sliding точен, но дороже; token bucket разрешает всплеск намеренно, в пределах ёмкости; leaky bucket не пропускает всплеск, а растягивает его во времени.

Один и тот же вход: лимит 10/мин, 10 запросов в 59-ю секунду и ещё 10 в 61-ю

АлгоритмЧто произойдётПамятьУстройство
Fixed windowпрошли оба всплеска: 20 запросов за 2 секундыO(1)INCR rl:{user}:{minute} с TTL
Sliding window logпервый прошёл, второй отклонён целиком — лимит точен на любом интервалеO(лимит): запись на каждый запросzset с метками времени, чистка старого + ZCARD
Sliding window counterоценка 10 × 59/60 + 0 ≈ 9.8 → тот же вердикт, но это приближениеO(1): два счётчикавзвешенная сумма текущего и прошлого окна
Token bucketпервый прошёл (ведро было полным), второй отклонён; дальше по 1 запросу каждые 6 сO(1): токены + отметка времениёмкость B, долив со скоростью r
Leaky bucketпервый принят в очередь и выпускается по 1 в 6 с; второй отброшен — очередь полнаO(размер очереди)очередь + постоянная скорость слива

Чем именно отличаются token bucket и leaky bucket

Их часто называют «одним и тем же наоборот». Это неточно. Token bucket отвечает мгновенно «да/нет» и разрешает накопленный всплеск: неиспользованная квота копится в ведре. Leaky bucket ставит в очередь и выпускает ровно, поэтому на выходе всплесков нет вообще, зато появляется задержка (десятый запрос всплеска исполнится через 54 секунды). Первый решает, пускать ли, второй занят шейпингом. Есть и вариант leaky bucket «as a meter»: он отвергает вместо очереди и по поведению равен token bucket.

Что выбирать

  • Token bucket — дефолт для публичного API: две понятные ручки (средняя скорость и бюджет на всплеск), константа памяти, дружелюбен к нормальным клиентам.
  • Sliding window log нужен там, где лимит маленький и важна точность: попытки входа, отправка SMS, платежи.
  • Sliding window counter — компромисс для больших лимитов, так делает Cloudflare. Ошибается он на единицы процентов, но на лимите «5 попыток» это заметно.
  • Leaky bucket подходит, когда за нами хрупкий downstream или сторонний API с жёстким лимитом и всплеск нельзя пропускать в принципе.
  • Fixed window годится, если нужен самый дешёвый лимит, а двукратный перелёт не страшен.
Что часто спрашивают в паре

Rate limiting ограничивает частоту, а не одновременность. От «десять тяжёлых отчётов параллельно» защищает не лимитер, а ограничение конкурентности: семафор на N слотов или пул воркеров с очередью. На собеседовании это хорошее уточнение: «а если нужно ограничить не rps, а число одновременных?»

Суть: локальный лимитер защищает процесс и умножается на число реплик; распределённый даёт честный общий лимит, но добавляет зависимость и round-trip на горячем пути. В проде обычно оба: локальный как грубая отсечка, Redis — как бизнес-квота.

Локальный

lim := rate.NewLimiter(rate.Every(time.Second/10), 20) // 10 rps, burst 20 — это token bucket
lim.Allow()     // не ждать: для HTTP-хендлера
lim.Wait(ctx)   // ждать токен: для исходящих вызовов и фоновых воркеров
lim.Reserve()   // забронировать и узнать задержку

В хендлере нужен Allow: с Wait лимитер копит горутины, и вместо быстрого 429 клиент получает зависший запрос. Wait уместен там, где мы исходящая сторона и обязаны соблюдать чужой лимит.

Лимитеры «на ключ» хранятся в map, и без вытеснения (TTL-уборка или LRU) это утечка памяти: каждый новый IP добавляет запись навсегда.

Главный минус локального

Он на инстанс. При 10 подах «100 rps» превращается в 1000 rps, и цифра плавает при автоскейлинге. Делить лимит на число реплик нельзя: балансировщик распределяет неравномерно, а при выкатке подов временно больше. Локальный честно решает две задачи: защищает сам процесс (не даёт одному клиенту съесть горутины и коннекты к БД) и грубо отсекает лишнее.

Распределённый на Redis

Redis подходит потому, что он быстрый и однопоточный: Lua-скрипт выполняется атомарно, и между чтением счётчика и записью никакой другой под не вклинится. Наивное «GET, посчитал в Go, SET» даёт классическую гонку read-modify-write.

// Token bucket целиком внутри одного атомарного вызова.
var tokenBucket = redis.NewScript(`
local st     = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(st[1]); local ts = tonumber(st[2])
local now, rate, burst, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3]), tonumber(ARGV[4])
if tokens == nil then tokens = burst; ts = now end
tokens = math.min(burst, tokens + math.max(0, now - ts) / 1000.0 * rate)
local allowed, retry = 0, 0
if tokens >= cost then tokens = tokens - cost; allowed = 1
else retry = math.ceil(((cost - tokens) / rate) * 1000) end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil((burst / rate) * 1000) + 1000)
return { allowed, math.floor(tokens), retry }
`)
  • Жёсткий таймаут (десятки миллисекунд): лимитер не имеет права стать причиной таймаута запроса.
  • Стратегия при отказе Redis: fail-open (пропускать) или fail-closed (429/503). Выбор продуктовый: публичному API обычно ставят fail-open с метрикой и алертом, антифроду fail-closed.
  • Обязательный TTL у ключа, иначе Redis наберёт миллионы мёртвых ключей от разовых посетителей.
  • Все ключи передаются в KEYS, иначе в Redis Cluster скрипт упадёт на чужом слоте; нескольким ключам нужен общий hash tag rl:{user:42}:min.
  • Несколько лимитов сразу (10/с, 300/мин, 10 000/сут) проверять одним скриптом и списывать, только если прошли все. Иначе счётчики разъедутся.
  • Гибрид: локальное ведро «выкупает» у Redis пакет разрешений на 10–100 запросов. Точность чуть падает, нагрузка на Redis — на порядок.
Суть: оба уровня, и это не компромисс, а разделение задач: периметр отсекает объём как можно раньше и дешевле, приложение реализует бизнес-квоты, для которых нужен контекст (кто пользователь, какой тариф, сколько стоит операция). Ключ по возможности — пользователь или API-ключ; IP — только до аутентификации и с оглядкой на NAT.

Где ставить

УровеньУмеетНе умеет
CDN / WAFотсечь объём до нашего канала, L7-DDoS, гео- и ASN-правиланичего не знает про тарифы, тенантов и стоимость операции
Edge: ingress, API-gateway, nginxдёшево, до приложения, единая точка для всех сервисовне знает пользователя, пока не разобран токен
Приложениезнает пользователя, тариф, стоимость операции; может списывать разный costработает после того, как соединение принято и токен разобран — ресурсы уже частично потрачены

По какому ключу

  • Пользователь или организация — лучший ключ: не бьёт по соседям, переживает смену IP, квота может зависеть от тарифа.
  • API-ключ / client_id нужен для машинных интеграций. Считать надо и по ключу, и по владельцу, иначе партнёр заведёт десять ключей и обойдёт лимит.
  • IP — единственный вариант до аутентификации: логин, регистрация, восстановление пароля, публичные ручки.
  • Составной ключ защищает от перебора: отдельный жёсткий лимит на «этот логин» и на «этот IP» плюс глобальный счётчик неудачных попыток.

Проблемы IP как ключа

  • NAT. За одним адресом сидит корпоративная сеть или целый мобильный оператор, и лимит бьёт по тысячам невиновных. Поэтому IP-лимит держат заметно выше пользовательского и по возможности сразу после логина переходят на ключ по пользователю.
  • IPv6. У клиента может быть /64 целиком, то есть 18 квинтиллионов «разных» адресов. Агрегируют по префиксу /64 (иногда /56).
  • X-Forwarded-For подделывается. Заголовок формирует клиент, каждый прокси лишь дополняет. Если брать «первый элемент», атакующий получит новый лимит на каждый запрос и сможет подставить чужой IP, чтобы заблокировали невиновного.
// Доверяем ровно тому числу своих прокси, которое реально стоит перед сервисом,
// и отсчитываем справа налево. r.RemoteAddr подделать нельзя.
idx := len(parts) - trustedProxies
if idx < 0 || idx >= len(parts) { return peerAddr }   // список короче ожидаемого, не верим

Если инфраструктура позволяет, берут заголовок, который наш edge перезаписывает, а не дополняет (CF-Connecting-IP, True-Client-IP): всё пришедшее снаружи стирается. Есть и стандартный Forwarded из RFC 7239.

Отдельно про эндпоинт логина

Тут лимит по IP обязателен, но его мало: credential stuffing идёт с тысяч адресов по одному запросу на каждый. Нужны минимум три счётчика (по IP, по логину и глобальный по числу неудач) плюс экспоненциальная задержка и капча после порога. И одинаковый ответ «неверный логин или пароль» с одинаковым временем, чтобы лимитер не превратился в оракул существования аккаунтов.

Суть: 429 Too Many Requests плюс обязательный Retry-After, машиночитаемое тело в общем формате ошибок и заголовки X-RateLimit-* — причём и на успешных ответах тоже, чтобы вежливый клиент притормозил до того, как упрётся.

Ответ целиком

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 5                      # желателен: секунды или HTTP-дата
X-RateLimit-Limit: 100              # де-факто стандарт, понимают все SDK
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1756192846       # unix-время сброса
RateLimit-Policy: "default";q=100;w=60   # черновик IETF, постепенно вытесняет X-
RateLimit: "default";r=0;t=5

{"type":"…/errors/rate-limited","title":"Слишком много запросов",
 "status":429,"detail":"Лимит 100 запросов в минуту исчерпан","retry_after":5}

Что важно проговорить

  • Без Retry-After клиент ретраит сразу и делает только хуже.
  • 429503. Первый означает «конкретно ты превысил квоту», второй «сервису плохо целиком». По ним по-разному настраивают алерты и по-разному реагируют клиенты.
  • Заголовки надо добавить в Access-Control-Expose-Headers, иначе браузерный SPA их просто не увидит.
  • Ответ должен быть дешёвым: без похода в БД, без подробного лога на каждый отказ и без тяжёлого тела. Иначе лимитер сам станет вектором DoS. Логируют агрегатами, метрику ведут счётчиком с меткой причины.
  • Не раскрывать лишнего на анонимных ручках и на логине: точное «осталось 0 из 5» подсказывает перебиральщику ритм. Там хватит 429 и Retry-After.
  • Со стороны клиента нужен экспоненциальный backoff с джиттером и уважение к Retry-After. Без джиттера вся армия клиентов постучится синхронно ровно через N секунд.
Суть: ненадёжная сеть плюс ретраи гарантируют дубли, поэтому вопрос не «как избежать повторов», а «как сделать повтор безвредным». Для входящих запросов — Idempotency-Key, для событий и вебхуков — дедупликация по естественному ключу в той же транзакции, что и эффект.

Откуда берутся дубли

Почти никогда не из злого умысла: оборвался ответ, клиент словил таймаут, сработал автоматический ретрай библиотеки, пользователь дважды нажал кнопку, брокер доставил сообщение at-least-once. Запрос при этом реально выполнился, потерялся только ответ. Поэтому повтор обязан вернуть тот же результат, а не выполнить операцию заново.

Idempotency-Key: три вещи, которые надо назвать

  • Ключ занимается до внешнего эффекта. Строку в idempotency_keys с UNIQUE и статусом in_progress вставляют раньше, чем идут в платёжный шлюз. Тогда параллельный дубль упирается в конфликт и не запускает вторую оплату. Порядок «сходили в банк → записали ключ» не спасает вообще.
  • Ключ привязан к субъекту и к телу. Уникальна пара (user_id, key), иначе через чужой ключ можно прочитать чужой сохранённый ответ. Плюс хеш тела: на тот же ключ с другим телом отвечают 422, а не отдают старый ответ.
  • Хранится ответ целиком, факта мало. Повтор получает тот же статус и то же тело, иначе клиент, потерявший первый ответ, так и не узнает результат.

Дедупликация событий

// UNIQUE (provider, event_id): вторая вставка становится конфликтом, а не вторым эффектом.
res, err := tx.ExecContext(ctx, `INSERT INTO processed_events (provider, event_id)
    VALUES ($1,$2) ON CONFLICT DO NOTHING`, ev.Provider, ev.ID)
if err != nil {
    return err            // без проверки res == nil, и RowsAffected ниже упадёт
}
if n, _ := res.RowsAffected(); n == 0 {
    return nil            // уже обработано → тихий 200, иначе провайдер будет ретраить вечно
}
if err := apply(ctx, tx, ev); err != nil { return err }
return tx.Commit()        // отметка и эффект коммитятся одной транзакцией
  • Отметка и эффект живут в одной транзакции. Отметили в Redis, упали на записи в БД, и событие потеряно навсегда. Redis годится как быстрый фильтр перед транзакцией, но не как источник истины.
  • Подпись вебхука (HMAC от сырого тела, constant-time сравнение) и метка времени. Без них перехваченный запрос можно проиграть заново.
  • TTL таблицы дедупликации с запасом перекрывает политику ретраев провайдера (обычно сутки-двое).
  • Отвечать быстро: «проверил подпись → положил в очередь → 200». Долгая синхронная обработка ловит таймаут у провайдера и новый ретрай, то есть размножает сама себя.
Как это связано с rate limiting

Их часто путают. Rate limiting защищает от слишком многих запросов, идемпотентность — от повторов одного и того же. Лимитер, отдающий 429 на легитимный ретрай, проблему дублей не решает, а идемпотентность не спасает от перебора. Нужны оба механизма, и они независимы.

Суть: валидация — это явное описание множества допустимых входов на границе системы (белый список, а не чёрный), плюс бизнес-инварианты в домене и ограничения в БД. Заголовки безопасности — дешёвый второй эшелон: nosniff, HSTS, frame-ancestors, CSP, Referrer-Policy.

Где валидировать

  • На границе, сразу после разбора запроса, проверяют типы, диапазоны, длины, обязательность, форматы. 400 — тело неразборчиво, 422 — разобрано, но не проходит правила.
  • В домене держат инварианты, которым нужен контекст: «дата окончания позже начала», «сумма не превышает остаток». Они обязаны жить в сервисном слое, потому что нужны любому вызывающему: HTTP, gRPC, CLI, консьюмеру из брокера.
  • В базе стоят NOT NULL, CHECK, UNIQUE, внешние ключи. Только этот уровень переживёт баг в коде и ручной UPDATE из psql.
  • На фронте только UX: это дублирование, а не защита.

Whitelist против blacklist

Чёрный список перечисляет плохое и проигрывает всегда: список обходов бесконечен. Белый перечисляет допустимое и ошибается в безопасную сторону. Не «вырезать <script>», а «имя — 1–64 символа из букв, цифр, пробела и дефиса»; не «запретить ../», а «имя файла соответствует ^[a-zA-Z0-9_-]{1,64}\.(png|jpg)$».

Библиотека и кастомные правила

Amount int64 `json:"amount" validate:"required,gt=0,lte=100000000"`
Items  []item `json:"items"  validate:"required,min=1,max=100,dive"`

v.RegisterTagNameFunc(...)        // имена полей в ошибках — из json-тегов, а не из Go
v.RegisterValidation("slug", ...) // своё правило на поле
v.RegisterStructValidation(...)   // правило, где одно поле зависит от другого

Валидатор создаётся один раз (он кэширует разбор тегов через reflect). Ошибки наружу отдаются не текстом библиотеки, а структурой «поле → машиночитаемый код → текст для человека» в общем формате ошибок.

Лимиты размеров — это тоже валидация

  • http.MaxBytesReader(w, r.Body, 1<<20) — без него json.Decode примет гигабайт и положит процесс по памяти.
  • Server.MaxHeaderBytes. Для multipart нужен предел памяти в ParseMultipartForm плюс общий MaxBytesReader, а тип файла проверяют по сигнатуре (http.DetectContentType), а не по расширению.
  • Длина массивов, глубина вложенности, размер страницы пагинации — иначе безобидная ручка становится усилителем нагрузки.
  • Про ReDoS хороший ответ: в Go регулярки на RE2, время линейное — но строить регулярку из пользовательской строки всё равно нельзя.

Заголовки и HTTPS

ЗаголовокЗачем
Strict-Transport-Securityтолько HTTPS на этот домен, без «продолжить всё равно». Убивает понижение протокола
X-Content-Type-Options: nosniffзапрет MIME-sniffing — целый класс атак одним заголовком
X-Frame-Options: DENY / frame-ancestorsclickjacking
Content-Security-Policyвторой эшелон против XSS; для API — default-src 'none'
Referrer-Policyне утекать полный URL с токенами и id на чужие сайты
Cache-Control: no-storeприватный ответ не осядет в общем кэше и в истории браузера

TLS нужен везде, включая внутренние вызовы: модель «мягкая внутри, твёрдая снаружи» разваливается от одной скомпрометированной машины. Минимум: TLS 1.2+, редирект 80 → 443, автоматические сертификаты, а межсервисный трафик идёт по mTLS, обычно через service mesh.

HSTS: две ловушки

Первый запрос всё равно уязвим: о политике браузер узнаёт из уже пришедшего ответа, и помогает тут только preload-список. И preload с includeSubDomains практически необратимы: пока max-age не истечёт у каждого посетителя, все поддомены обязаны работать по HTTPS. Один забытый внутренний поддомен на http — и он недоступен для всех, кто хоть раз заходил на основной сайт. Выкатывать ступенчато: маленький max-ageincludeSubDomainspreload.