Веб-сервисы на 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 | Список постов | 200 | 400 (плохой курсор/сортировка) | — / {items:[…], next_cursor, has_more} |
POST /v1/posts | Создать пост | 201 + Location: /v1/posts/42 | 400, 401, 403, 422, 429 | {title, body, tags, status} / созданный ресурс |
GET /v1/posts/{id} | Один пост | 200 + ETag | 404, 304 (если If-None-Match) | — / ресурс |
PUT /v1/posts/{id} | Заменить целиком | 200 (или 204) | 404, 409, 412, 422 | полный ресурс / обновлённый ресурс |
PATCH /v1/posts/{id} | Частичное изменение | 200 | 404, 409, 422 | только изменяемые поля / ресурс |
DELETE /v1/posts/{id} | Удалить | 204 | 403, 404 (или тоже 204) | — / — |
POST /v1/posts/{id}/publish | Смена состояния (не CRUD) | 200 / 202 | 404, 409 (уже опубликован) | — / ресурс со статусом |
GET /v1/posts/{id}/comments | Комментарии поста | 200 | 404 | — / страница комментариев |
POST /v1/posts/{id}/comments | Добавить комментарий | 201 + Location | 401, 404, 422, 429 | {body, reply_to} / комментарий |
DELETE /v1/comments/{id} | Удалить комментарий | 204 | 403, 404 | — / — |
PUT /v1/posts/{id}/likes/me | Поставить лайк | 204 | 401, 404 | — / — (идемпотентно by design) |
DELETE /v1/posts/{id}/likes/me | Снять лайк | 204 | 401 | — / — |
/likes/meчерезPUT, а неPOST /like. Лайк сводится к тому, существует ли ресурс «мой лайк на этом посте».PUTидемпотентен: два клика подряд не сломают счётчик, ретрай безопасен.- Действия, которые не ложатся на CRUD (
publish,cancel,refund) оформляются как под-ресурс сPOST. REST это не нарушает, приём давно устоялся (так делают Stripe и GitHub). 201обязательно сLocation: так клиент узнаёт URL созданного ресурса и не вычисляет его по формуле.422vs400: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 Failed | If-Match не совпал | Основа безопасного concurrent-update |
413 Content Too Large | Тело больше лимита | Его пишет хендлер, получив от MaxBytesReader ошибку *http.MaxBytesError |
415 Unsupported Media Type | Не тот Content-Type | Полезно как защита от CSRF-формы |
422 Unprocessable Content | Валидация не прошла | Здесь отдают список ошибок по полям |
429 Too Many Requests | Rate 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 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
}
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. Сервер хранит ключ вместе с ответом.
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» |
Сменить код ответа (200 → 202) | да | Клиент проверяет == 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 снять лайк
Что обязательно проговорить
PUTvsPATCH.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, а не физическое удаление.
/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-схему: новые поля с новыми тегами совместимы по определению.
Два вида пагинации
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и в кортеже курсора. Без него пагинация недетерминирована.
Idempotency-Key, сервер атомарно занимает ключ до списания, сохраняет
ответ и на повторе отдаёт сохранённое, не выполняя операцию.Определение и таблица методов
Операция идемпотентна, если N одинаковых вызовов оставляют систему в том же состоянии, что
и один. GET/HEAD/OPTIONS safe и идемпотентны,
PUT и DELETE идемпотентны, но не safe. POST ни то
ни другое, PATCH — как напишешь. У DELETE второй вызов
может вернуть 404, и идемпотентности это не отменяет: сравнивают состояние
сервера, а не тело ответа.
Зачем это платежам
Клиент, получивший таймаут, не знает, дошёл ли запрос. Сеть теряет ответ, под перезапускается посреди обработки, балансировщик ретраит сам. Без защиты каждый такой случай оборачивается вторым списанием. При этом просто «не ретраить» нельзя: тогда часть платежей теряется.
Механика ключа — по шагам
- Клиент генерирует UUIDv4 на бизнес-операцию (одна кнопка «оплатить» — один ключ) и переиспользует его во всех ретраях. Если генерировать ключ на каждый HTTP-запрос, толку от него нет.
- Сервер пытается вставить строку
(key, user_id, endpoint, sha256(body), in_progress). Уникальный индекс поkeyделает шаг атомарным: параллельную гонку выигрывает ровно один запрос. - Вставка удалась → выполняем операцию и идём в PSP со своим идемпотентным
идентификатором (
merchant_order_id), чтобы и там ретрай не задвоился. - Записываем
status_codeи тело ответа, переводим ключ вdone, в идеале в той же транзакции, где меняется баланс. - Повторный запрос с тем же ключом:
done→ отдаём сохранённый ответ (полезно добавитьIdempotent-Replay: true);in_progress→409 ConflictсRetry-After; тот же ключ, но другойsha256(body)→422: это ошибка клиента, а не ретрай. - Ключи живут ограниченное время (у 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не нужен: они идемпотентны по протоколу.
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. То же с
403vs404: разный код подтверждает существование объекта. - Тайминг тоже утечка. Если «нет пользователя» отвечает за 5 мс, а «неверный пароль» за 80 мс (там честно считается bcrypt), разницу видно. Лечится так: хеш считают от заглушки, даже если пользователя нет.
Наружу отдаём класс ошибки и trace id, внутрь пишем всё: исходную ошибку с
%w-цепочкой, SQL, параметры (кроме секретов), стек. Клиент приходит в поддержку с
trace_id, и по нему в логах находится ровно этот
запрос. Это и безопасно, и удобнее, чем текст ошибки в ответе.
Совместимые изменения
- Новое необязательное поле в ответе.
- Новый необязательный query-параметр или поле запроса с дефолтом.
- Новая ручка, новый метод у существующего пути.
- Ослабление валидации (было максимум 100 символов, стало 200).
- Новый заголовок ответа.
Ломающие изменения
- Удаление или переименование поля ответа. Клиент получает zero value и не отличает его от «пусто».
- Смена типа:
"42"→42, число → объект, скаляр → массив.encoding/jsonвернёт ошибку, статически типизированные клиенты упадут. - Новое обязательное поле запроса или новый обязательный заголовок.
- Сужение допустимых значений: свободная строка стала enum, лимит уменьшился, формат даты стал строже.
- Новое значение существующего enum в ответе ломает клиентов с полным перебором вариантов. Формально это добавление, а на деле авария. Поэтому в контракте изначально пишут: «неизвестные значения трактуйте как unknown».
- Смена кода ответа или превращение синхронной ручки в асинхронную
(
200→202). - Изменение семантики при том же имени и типе:
amountиз рублей стал копейками,timeoutиз секунд стал миллисекундами,dateсменил таймзону. Тихо испорченные данные хуже падения. - Изменение поведения: новый rate limit, ужесточённый
maxLimit, другой порядок элементов, другая длина или формат идентификатора (клиент положил его вvarchar(36), а ты выдал 40 символов). - Удаление ручки или изменение пути.
Закон Хайрама и его практический вывод
«При достаточном числе пользователей 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). Дальше начинается
цикл, который стоит уметь рассказать пошагово.
l.Accept()в бесконечном цикле в горутине, вызвавшейServe. ЕслиAcceptвернул временную ошибку (например, кончились файловые дескрипторы), сервер не падает, а спит по нарастающей (5 мс, 10, 20… до 1 с) и пробует снова.- На принятое соединение создаётся
c := srv.newConn(rw)и сразу запускаетсяgo c.serve(connCtx). Пула воркеров нет: одна горутина на одно TCP-соединение. В HTTP/1.x не на запрос, а именно на соединение; HTTP/2 внутри соединения запускает ещё по горутине на каждый поток. - Внутри
c.serveдля HTTPS сначала идёт TLS-handshake (там же согласуется ALPN:h2илиhttp/1.1), потом цикл чтения запросов. c.readRequest(ctx)парсит стартовую строку и заголовки в*http.Request. Тело не читается: вr.Bodyкладётся обёртка над буферизованным ридером соединения, и байты потекут, только когда хендлер сам вызоветRead.serverHandler{srv}.ServeHTTP(w, req)приsrv.Handler == nilподставляетhttp.DefaultServeMux. Затем роутер выбирает нужныйHandlerи вызывает егоServeHTTP. Это уже твой код.- Когда хендлер вернулся,
w.finishRequest()сбрасывает буфер ответа в сокет, дочитывает и выбрасывает остаток тела запроса (иначе соединение нельзя переиспользовать) и закрывает chunked-поток. - Если клиент и сервер согласны на keep-alive и ошибок не было, цикл делает
continue, и та же горутина ждёт следующий запрос на том же соединении. Иначе вызываетсяc.close(), и горутина завершается.
В 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. В
ServeMuxmiddleware либо один на весь сервер, либо навешивается вручную на каждый хендлер. Можно завести отдельныйmuxпод/admin/плюсhttp.StripPrefix— работает, но громоздко. - Ограничений на параметры (
{id:[0-9]+}) нет, валидируй сам. - Опциональные сегменты, необязательный завершающий слэш: мукс сам редиректит
/xна зарегистрированный/x/— и это бывает сюрпризом для клиентов, которые не следуют редиректам на POST. До Go 1.26 это был301, и он был вдвойне опасен: браузер кэширует «навсегда», а на301POST исторически превращается в GET с потерей тела. С Go 1.26 код сменили на307: такой ответ не кэшируется и обязан сохранять метод и тело (проверено на go1.27.0:POST /x→307 Location: /x/). Тот же307теперь приходит и при чистке пути, вроде/a//b→/a/b. - Кастомные
404/405-хендлеры, интроспекция списка роутов, генерация URL по имени роута. - Биндинг и валидация тела, рендеринг, контекст фреймворка (
*gin.Context).
| Критерий | net/http ServeMux (1.22+) | chi | gin | echo |
|---|---|---|---|---|
| Зависимости | ноль, stdlib | ноль сторонних (только stdlib) | несколько (binding, валидатор) | несколько |
Совместимость с http.Handler | полная | полная — весь экосистемный middleware работает | своя сигнатура func(*gin.Context), нужен адаптер | своя func(echo.Context) error |
| Алгоритм | дерево сегментов + правило специфичности | radix trie | radix trie | radix 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.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)))))
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)),
)
})
}
}
В интерфейсе 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()работает только внутри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
}
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 монотонно растёт и ложится в хвост.
- Ключом делают неэкспортируемый тип (
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, и он небезопасен: одно медленное или злонамеренное
соединение может держаться вечно, занимая горутину, дескриптор и буферы. Каждый таймаут
режет свою фазу.
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.
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 задаёт один общий дедлайн на всю операцию, включая чтение тела
ответа. Он работает через дедлайн контекста запроса и таймер отмены, поэтому обрывает и медленный
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
}
Недостаточно. Close() на недочитанном теле заставляет транспорт закрыть
соединение, а не вернуть его в пул: остаток тела всё ещё в сокете, и переиспользовать такой
сокет нельзя. Особенно больно на ветках ошибок, где тело обычно игнорируют: пул
«худеет» ровно во время инцидента, когда апстрим сыплет пятисотками. Поэтому сначала
дочитать в io.Discard (с LimitReader), потом закрыть. Второй частый промах:
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 и не зависит от того, знал ли апстрим длину заранее.
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 нет
}
}
До 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.
connection refused
вместо мягкой деградации.- Сказать миру, что мы уходим: readiness-проба отдаёт
503, ждём 5–10 секунд (илиpreStop: sleepв манифесте), пока балансировщик и service mesh перестанут слать трафик. - Перестать принимать новое:
Shutdownзакрывает listener и все idle keep-alive-соединения. - Дождаться текущего:
Shutdownблокируется, пока активные запросы не завершатся, и добавляет в их ответыConnection: close. - Только потом закрывать ресурсы: БД, брокер, кэш, экспортёры трейсов и метрик.
Если закрыть
dbдоShutdown, доигрывающие хендлеры получатsql: database is closedи отдадут500. - Таймаут
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 */ }
}
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постепенно забивается.
Вопросы
8Accept в одной горутине, на каждое принятое
соединение запускается отдельная горутина conn.serve, которая в цикле читает
запросы и вызывает Handler.ServeHTTP. Handler — интерфейс с одним
методом, HandlerFunc — функциональный тип с методом ServeHTTP,
вызывающим сам себя.Пошагово
ListenAndServe→net.Listen("tcp", addr)→srv.Serve(l).- В цикле
l.Accept(). Временные ошибки не роняют сервер: ретрай с экспоненциальной паузой от 5 мс до 1 с. c := srv.newConn(rw);go c.serve(connCtx)запускает горутину на соединение. Пула воркеров нет, лимита на число соединений тоже.- Горутина делает TLS-handshake (если нужно) и входит в цикл
c.readRequest(): разбирает стартовую строку и заголовки. Тело не читается,r.Bodyостаётся ленивым ридером поверх сокета. serverHandler{srv}.ServeHTTP(w, req); еслиsrv.Handler == nil, подставляетсяhttp.DefaultServeMux. Роутер выбирает хендлер.- Когда хендлер вернулся,
finishRequestсбрасывает буфер и дочитывает остаток тела (иначе соединение не переиспользовать). - 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 и нужны: без них соединение (а с ним и горутина) висит бесконечно.
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 вешай на отдельный внутренний порт.
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,
механика та же.
Ключом делают неэкспортируемый тип, а не строку. В контекст кладут только
request-scoped метаданные (request id, trace id, user id, локаль), зависимостям там не место:
репозиторий и логгер передаются явно. Читают всегда через
v, ok := ctx.Value(k).(T).
| Таймаут | Что ограничивает | С какого момента |
|---|---|---|
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())
Порядок и почему он такой
- Объявить о выходе: readiness →
503, подождать 5–10 с. Kubernetes помечает Pod неготовым в EndpointSlice асинхронно с отправкойSIGTERM: если закрыть listener сразу, часть трафика упрётся вconnection refused. Тот же эффект даёт встроенное действиеpreStop: sleepв манифесте;exec sleep 10в distroless-образе не сработает — там нет бинарникаsleep. - Перестать принимать новое:
Shutdownзакрывает listener и все idle keep-alive-соединения. - Дождаться текущих:
Shutdownблокируется до завершения активных запросов и добавляет им в ответConnection: close, чтобы клиент не пытался переиспользовать соединение. - Потом ресурсы: вызовешь
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) при этом
работают по-старому, так что существующий код не ломается.
Бюджет: terminationGracePeriodSeconds (по умолчанию 30 с) = preStop sleep +
таймаут Shutdown + закрытие ресурсов, и всё это должно уложиться с запасом,
иначе прилетит SIGKILL посреди уборки. Отдельно проговори, что процесс
обязан сам обрабатывать SIGTERM: если приложение запущено через
shell-обёртку, сигнал может до него не дойти (PID 1 и отсутствие проброса) — отсюда
и «необъяснимые» обрывы при выкатке.
Unmarshal требует всё тело в памяти,
Decoder читает потоком. В хендлере — всегда
http.MaxBytesReader + Decoder с
DisallowUnknownFields и проверкой, что после объекта в теле ничего нет.json.Unmarshal | json.Decoder | |
|---|---|---|
| Вход | []byte — нужен io.ReadAll | io.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+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.
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
прочитает кто угодно.
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.
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 / sessionStorage | httpOnly + 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 + refresh | access живёт 5–15 мин; при logout удаляем refresh — обновиться уже не выйдет | ноль, это и так нужно | до конца TTL access |
Blacklist по jti | Redis 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 Server | API, которое принимает access-токен | наш API, GitHub API |
Клиенты делятся на confidential (серверные, умеют хранить client_secret) и
public (SPA и мобильные: секрет там спрятать негде, любой выковыряет его из бандла
или apk). Для public-клиентов PKCE и придумали.
code_verifier, которого у перехватчика нет.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 — где уместны
| Механизм | Как выглядит | Где уместен | Чего не умеет |
|---|---|---|---|
| Basic | Authorization: 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) | сложнее для клиента |
- Ключ берут из
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) решение считается функцией от атрибутов субъекта,
объекта, действия и среды. Гибко, но отлаживать труднее, и понять, «почему отказали», тоже.
| RBAC | ABAC | |
|---|---|---|
| Модель | user → roles → permissions | policy(subject, object, action, env) → allow/deny |
| Пример | «редактор может удалять посты» | «автор может удалять свой пост в первые 24 часа» |
| Плюсы | предсказуемость, простой аудит, легко объяснить бизнесу | выражает любые правила, не плодит роли |
| Минусы | взрыв числа ролей (editor_ru_premium_readonly) | сложность, риск «почему нет доступа?» без внятного ответа |
| Инструменты | таблицы в БД, Casbin | OPA/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 знает про маршрут, но ничего не знает про объект. Он пропустит
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
// в БД: соль + все параметры + хеш,
// иначе не сможешь их поднять при ротации
- Соль своя у каждого пароля и лежит рядом с хешем открыто. Прятать пароль она
не должна: соль ломает rainbow-таблицы и делает так, что два одинаковых пароля
дают разные хеши (иначе видно, у кого пароль
123456). - Перец (pepper) — общий секрет, который живёт не в БД, а в HSM/Vault или в конфиге приложения. Его подмешивают к паролю или берут ключом HMAC поверх хеша. Если утекла только база, хеши без перца бесполезны. Минус в том, что перец просто так не сменишь.
- Сравнение за constant-time. Обычное
==для строк идёт побайтово с ранним выходом и в теории позволяет подбирать значение байт за байтом. Для токенов и HMACsubtle.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 час, и «украсть пароль от БД» становится почти бессмысленно.
- Нельзя:
.envс боевыми значениями, приватные ключи,kubeconfig, дампы с продовыми данными,docker-compose.ymlс реальными паролями. В репозитории живёт только.env.exampleс пустышками. - Удалить файл новым коммитом недостаточно: секрет остался в истории и в форках.
Порядок строгий: сначала отозвать и перевыпустить секрет, потом
(по желанию) чистить историю
git filter-repo/BFG. Ротация обязательна, чистка истории — косметика. - В CI ставят
gitleaks/trufflehogна pre-commit и в пайплайн. - Секрет не должен попадать в логи: не логируй целиком конфиг и заголовки, дай секретным
типам
String(), который возвращает"[REDACTED]", а в структурированном логгере заведи список замаскированных полей. - Ротация должна быть рутиной, а не подвигом: держишь два активных ключа с
kid, выкатываешь новый, переключаешь подпись, через окно снимаешь старый.
Вопросы
10401, второе —
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 так и делает.
Сравнение по осям
| Ось | Сессия (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 JWT-система не умеет разлогинить со всех устройств, показать «активные сессии», немедленно забанить пользователя, отозвать доступ при смене пароля, снизить права. Всё это продуктовые требования, они появляются на втором году жизни сервиса и возвращают состояние.
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 с публичным ключом в роли секрета.
/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 на каждый запрос.Варианты по возрастанию цены
- Просто выбросить токен на клиенте + отозвать refresh на сервере. Access живёт свои 5–15 минут. Для 95 % продуктов этого достаточно, и такой ответ вполне годится.
- Чёрный список по
jti:SET revoked:jti 1 EX ttl_остаток. Ключ исчезнет сам, когда истечёт токен, поэтому список остаётся маленьким. Платишь за этоGETв Redis на каждый запрос. - Версия токенов пользователя (
token_version/epoch): в payload кладём номер, в БД держим текущий. «Выйти везде» = инкремент. Проверка сводится к сравнению чисел и кэшируется в памяти инстанса с TTL 30 с. Это на порядок дешевле чёрного списка и покрывает главный сценарий. - Не выдавать 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 и живут часами), инвалидировать серверные кэши прав.
id_token и превращает всё это в
аутентификацию («войти через Google»).Четыре роли
- Resource Owner — пользователь, владелец данных.
- Client — приложение, которое хочет доступ (твой бэкенд или SPA).
- Authorization Server выдаёт токены (Google, Keycloak, Auth0).
- Resource Server — API, которое проверяет токен и отдаёт данные.
Authorization code flow + PKCE
- Клиент редиректит браузер на
/authorizeсresponse_type=code,client_id,redirect_uri,scope,stateиcode_challenge. - Пользователь логинится на стороне провайдера и подтверждает согласие. Твоё приложение пароля не видит, в этом и весь смысл.
- Провайдер редиректит обратно на
redirect_uri?code=...&state=.... Одноразовый код живёт секунды. - Бэкенд меняет код на токены запросом server-to-server на
/token, предъявляяclient_secret(илиcode_verifierдля публичных клиентов). - Получает
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 за
доказательство личности, а он может оказаться непрозрачной строкой, в которой нет вообще
ничего.
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
Пользователь → роли → права (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; критичные права лучше читать из источника истины.
- Полезный тест: пройтись по всем зарегистрированным роутам и убедиться, что у каждого есть политика. Иначе однажды новый эндпоинт приедет голым.
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 навсегда, у всех форков и в CI-кэшах.
- Переменные окружения по 12-factor дают минимальный приемлемый уровень. Минусы: видны
в
/proc/PID/environ, вdocker inspect, утекают в краш-репорты; ротация = рестарт. - Файл, смонтированный в контейнер, можно обновить без рестарта, на него действуют права доступа; приложение должно уметь перечитывать его по сигналу.
- Kubernetes Secret: RBAC, монтирование как файл или env. Обязательно проговори: это base64, а не шифрование, и без включённого encryption-at-rest в etcd секрет лежит практически открытым.
- Vault / KMS дают централизованное хранение, аудит доступа, TTL, автоматическую ротацию, динамические секреты. Взамен получаешь ещё одну зависимость в рантайме и задачу аутентифицировать само приложение.
AppRole в двух предложениях
У сервиса есть role_id (не секрет, лежит в конфиге) и secret_id
(короткоживущий, его доставляют отдельно, оркестратором или через response wrapping).
Пару меняют на токен с TTL, приложение периодически его продлевает и по нему
читает секреты. Артефактов два, чтобы утечка одного не давала доступ. Отдельный козырь
Vault — динамические секреты: он сам заводит временного пользователя в PostgreSQL
с TTL в час, и красть пароль от базы становится почти бессмысленно.
Ротация как рутина
Ротация должна быть скучной операцией, а не подвигом. Для этого приложение держит
два действующих значения одновременно: два ключа подписи с разными
kid (выкатили новый на проверку → переключили подпись → сняли старый через
окно), старый и новый пароль БД, два API-ключа у интеграции. Тогда секрет меняется без
одномоментного рестарта всего.
Удалить файл новым коммитом недостаточно: он остался в
истории, в форках и в клонированных 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пропустят спокойно.
Почему параметризация работает, а экранирование — нет
Плейсхолдер не «умный 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 станет <img …>,
// а в <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-Type—application/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()в ответ. В Gohttp.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-кука действительно не читается из JS, и это
правильный дефолт. Но говорить, что она «защищает от XSS», нельзя: скрипт на
нашем origin может просто слать запросы — куку браузер приложит сам. Токен не украден,
зато перевод денег выполнен. httpOnly снижает ущерб (нельзя унести сессию на
другой хост и пользоваться ей месяц), но при XSS всё, что доступно пользователю, доступно и
атакующему.
CSRF: браузер отправляет запрос за пользователя
Cross-Site Request Forgery держится на автоматическом прикреплении учётных данных. Браузер прикладывает куки к запросу, глядя на адрес назначения, а не на того, кто этот запрос инициировал. Значит, чужая страница может заставить браузер жертвы сходить на наш домен — и запрос придёт полностью аутентифицированным.
Прочитать ответ злоумышленник при этом не может. Same-Origin Policy не даст чужому скрипту увидеть тело, статус или заголовки нашего ответа. Поэтому CSRF бьёт по изменяющим операциям: перевод денег, смена email, смена пароля, удаление, выдача прав. Это «слепой» вызов, результат которого атакующему не нужен.
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, но сOriginGo проверит запасным способом, по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решает, выполнять ли запрос. Разные задачи, и одно другое не заменяет.
«Механика прежняя: браузер прикладывает куку по адресу назначения, поэтому чужая страница
может выполнить изменяющую операцию от имени пользователя. Базой служит
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.
Как защищаться — по убыванию надёжности
- Allowlist. В идеале сервис вообще не ходит по произвольным URL: есть список разрешённых хостов или заранее зарегистрированные интеграции, всё остальное отвергается. Только эта защита не проигрывает гонку с обходами.
- Отдельный egress-прокси. Весь исходящий трафик сервиса идёт через прокси, у которого своя политика; сам под сидит в сетевой политике, запрещающей прямые соединения во внутренние подсети. Тогда даже ошибка в коде не даёт доступа.
- Запрет приватных диапазонов на этапе соединения. Проверять надо не строку 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 по другой причине: браузер не прикладывает его сам, как куку.
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уйдёт клиенту с originb.example.com— и наоборот. Смотря в какую сторону промахнулся кэш, получится либо неработающий фронт, либо утечка разрешения. Ставить его надо на всех ответах, гдеAllow-Originвычисляется динамически. nullв белом списке. Originnullприсылают песочницы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 как два числа: сколько в нём сейчас и когда его трогали в прошлый раз.
| Алгоритм | Как устроен | Память на ключ | Всплески | Где уместен |
|---|---|---|---|---|
| Fixed window | INCR ключа rl:{user}:{minute} с TTL | O(1), одна команда | до 2× лимита на стыке окон | грубые лимиты, где двукратный перелёт неважен; самый дешёвый |
| Sliding window log | zset с меткой времени каждого запроса; чистим старое, считаем оставшееся | 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-скрипт выполняется атомарно — между чтением счётчика и его записью никакой другой под не вклинится.
// 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: это дублирование, защиты оно не даёт.
Чёрный список перечисляет плохое и проигрывает всегда:
список обходов бесконечен и растёт быстрее, чем правила. Белый список перечисляет
допустимое и ошибается в безопасную сторону — незнакомый вход он отвергает.
На практике: не «вырезать <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, без возможности кликнуть «продолжить». Убивает атаку с понижением протокола; самый первый визит закрывает только preload | max-age=31536000; includeSubDomains; preload — только осознанно |
X-Content-Type-Options | запрещает MIME-sniffing | nosniff — всегда |
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)
})
}
- Первый запрос всё равно уязвим — браузер узнаёт о политике только из ответа,
который уже пришёл. Это лечит список
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Уязвимый код и что с ним делают
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, шаблонизаторы;
принцип везде один: не собирать команду строкой.
Content-Type плюс
nosniff, CSP и то, что вообще попадает в хранилище.Три вида
- Stored: нагрузка лежит в нашей БД (комментарий, имя профиля) и отдаётся всем, кто откроет страницу. Самый опасный вид.
- Reflected приходит в запросе и возвращается в HTML ответа
(
?q=<script>…). - В DOM-based сервер отдал чистый HTML, но JS сам положил
location.hashвinnerHTML. Чинит фронт; бэкенд помогает только через CSP.
Рычаги бэкенда
- Экранирование при рендере. В Go это
html/template, а неtext/template: он учитывает контекст и знает, куда попадает значение (текст, атрибут,href,<script>, CSS).template.HTMLотключает экранирование — только для доверенного HTML. Пользовательскую разметку чистит санитайзер с белым списком (bluemonday); чёрные списки обходятся<img onerror>,<svg onload>и десятком других вариантов. - Content-Type и
X-Content-Type-Options: nosniff. Без точного типа браузер угадывает по байтам, и «аватар», начинающийся с<html><script>, исполняется на нашем домене. Пользовательские файлы отдают сContent-Disposition: attachment, а по-хорошему с отдельного домена. - CSP работает вторым эшелоном:
script-src 'self' 'nonce-…'без'unsafe-inline'. JSON-API хватитdefault-src 'none'; frame-ancestors 'none'. - Валидация на входе: не хранить в базе ничего, кроме допустимых значений поля.
Да, JSON-эндпоинт тоже бывает вектором
- Тот же JSON, отданный как
text/htmlили без типа, исполняется. Хватит отражённого значения из запроса в тексте ошибки. - В JSONP имя коллбэка из query попадает в исполняемый код, и заодно это полный обход CORS. Не использовать.
- Даже идеальный JSON становится XSS, если фронт положит поле в
innerHTML.
Она не даёт прочитать куку, и это правильный дефолт.
Но скрипт на нашем origin может просто отправлять запросы — браузер приложит куку
сам. Токен не украден, зато перевод выполнен. При XSS всё, что доступно пользователю,
доступно и атакующему; httpOnly снижает ущерб, а не устраняет его.
Authorization в эту схему не попадает: его никто не проставляет
автоматически. Защита: SameSite, с Go 1.25 — штатный
http.CrossOriginProtection, и токены там, где нужны гарантии.Атака по шагам
- Жертва залогинена на
bank.example— кука сессии в браузере. - Жертва открывает
evil.com, где лежит формаaction="https://bank.example/transfer" method=POSTс автосабмитом (или просто<img src>, если операция висит наGET). - Браузер отправляет
POSTна наш домен и сам прикладывает куку. - Сервер видит валидную сессию и считает запрос осознанным. Перевод выполнен.
- Ответ атакующему недоступен (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.
Где появляется и чем опасно
Точки входа: «загрузить картинку по URL», вебхуки, импорт из внешнего источника, генерация превью ссылки, прокси-ручка. Цели атакующего:
- На
169.254.169.254лежат метаданные облака с временными ключами роли инстанса. Из-за этого и появился IMDSv2 с обязательным PUT-токеном. - Внутренние сервисы без аутентификации «потому что они не торчат наружу»: Kubernetes API, Consul, Elasticsearch, панели.
- Сканирование сети по времени ответа и коду ошибки (blind SSRF).
- Смена схемы:
file://читает локальные файлы, а черезgopher://иdict://собирают произвольный TCP-пакет.http.Clientв Go таких схем не знает, но libcurl и конвертеры документов знают.
Защита по убыванию надёжности
- Allowlist хостов или заранее зарегистрированные интеграции. Только эта защита не проигрывает гонку с обходами.
- Сетевая изоляция: egress через прокси со своей политикой, network policy, запрещающая поду ходить во внутренние подсети напрямую.
- Запрет приватных диапазонов на этапе соединения через хук
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 против нас.
Как выглядит
// 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 ничего не закрывает. Если 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-ошибку?» Потому что для простых запросов
блокируется чтение ответа, а не отправка.
Один и тот же вход: лимит 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, а число одновременных?»
Локальный
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 tagrl:{user:42}:min. - Несколько лимитов сразу (10/с, 300/мин, 10 000/сут) проверять одним скриптом и списывать, только если прошли все. Иначе счётчики разъедутся.
- Гибрид: локальное ведро «выкупает» у Redis пакет разрешений на 10–100 запросов. Точность чуть падает, нагрузка на Redis — на порядок.
Где ставить
| Уровень | Умеет | Не умеет |
|---|---|---|
| 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клиент ретраит сразу и делает только хуже. 429≠503. Первый означает «конкретно ты превысил квоту», второй «сервису плохо целиком». По ним по-разному настраивают алерты и по-разному реагируют клиенты.- Заголовки надо добавить в
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 защищает от слишком многих
запросов, идемпотентность — от повторов одного и того же. Лимитер, отдающий
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-ancestors | clickjacking |
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.
Первый запрос всё равно уязвим: о политике браузер
узнаёт из уже пришедшего ответа, и помогает тут только preload-список.
И preload с includeSubDomains практически необратимы:
пока max-age не истечёт у каждого посетителя, все поддомены обязаны работать
по HTTPS. Один забытый внутренний поддомен на http — и он недоступен для всех, кто хоть
раз заходил на основной сайт. Выкатывать ступенчато: маленький max-age →
includeSubDomains → preload.