Безопасность
Правила обращения с секретами, валидации ввода, обработки токенов и крипто-операций. Это не обзор «всей» безопасности — это набор конкретных запретов и практик, которые должен применять каждый backend-инженер.
Содержание
- Секреты
- Валидация входа
- SQL injection
- Templating
- Аутентификация в сервисе
- Авторизация
- Internal endpoints
- CORS
- HTTPS
- Rate limiting
- Зависимости
- Логи без PII
- Error messages клиенту
- Never trust
- Хранение паролей
- Crypto
- Rotation секретов
- Что не делать
- См. также
Секреты
- Никогда не коммить секреты в git. Ни в
.env, ни вdocker-compose.yml, ни в код, ни в тесты, ни в миграции, ни в README. Ни в какие файлы. .env— в.gitignore. В репозиторий коммитится только.env.exampleс placeholder’ами (change_me,_dev_,replace_with_real).- В
docker-compose.ymlдопустимы значения с явными dev-маркерами (notification_dev_password,change_me) — это помощь локальной разработке, в prod эти значения не идут. - В prod секреты подаются как env-переменные из внешнего secret manager
(Vault / KMS / Kubernetes Secret, заполняемый внешним источником).
Сервис читает их только через
os.Getenv/envconfigна старте. - Для dev-секретов, которые удобнее иметь в репозитории (например, staging-подписи), используй SOPS-encrypted файлы.
Валидация входа
-
Валидация происходит на handler boundary. Service-слой доверяет, что всё, что к нему пришло, уже прошло формальные проверки (тип, длина, формат).
-
Service проверяет бизнес-инварианты:
ratingв 1-5, text не пустой после trim, нет ли у пользователя открытой блокировки. Это не дублирование handler’а — это другой уровень проверок. -
Для handler-level валидации —
github.com/go-playground/validator/v10. См.../conventions/http-api. -
Лимиты на размер тела —
http.MaxBytesReaderна входе:r.Body = http.MaxBytesReader(nil, r.Body, 64<<10)
SQL injection
pgx параметризует запросы автоматически. Правило простое:
-
Всегда используй плейсхолдеры
$1, $2, ...:r.pool.QueryRow(ctx, `SELECT id FROM users WHERE email = $1`, email) -
Никогда
fmt.Sprintfс user input в SQL:// ЗАПРЕЩЕНО q := fmt.Sprintf("SELECT id FROM users WHERE email = '%s'", email) -
Никогда string concat в SQL:
// ЗАПРЕЩЕНО q := "SELECT id FROM users WHERE email = '" + email + "'"
Исключение: когда параметризовать нужно имя таблицы / колонку (DDL в миграции). Тогда значение должно приходить из кода, а не из пользовательского ввода, и проходить whitelist-проверку.
Templating
- HTML email —
html/template, неtext/template.html/templateавтоматически экранирует user input по контексту (attr, URL, JS). - SMS / plain text —
text/template. Любой user input, который попадает в template, должен быть предварительно очищен (убраны управляющие символы, ограничена длина). - Никогда не конкатенируй user input в шаблон строкой:
template.New("x").Parse("Hello " + userName + "!"). Это не шаблон, это уязвимость — в userName может прийти{{...}}и выполниться.
Аутентификация в сервисе
Сервис не валидирует JWT на каждом запросе. Это делает API Gateway.
В сервис приходят HMAC-подписанные headers от gateway:
| Header | Значение |
|---|---|
X-User-ID | bigint пользователя |
X-User-Role | "user", "admin", … |
X-Request-Id | request id |
X-Gateway-Signature | HMAC-SHA256 от предыдущих полей |
Middleware валидирует подпись:
func GatewayAuth(primary, previous []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
uid := r.Header.Get("X-User-ID")
role := r.Header.Get("X-User-Role")
rid := r.Header.Get("X-Request-Id")
sig := r.Header.Get("X-Gateway-Signature")
rawSig, err := hex.DecodeString(sig)
if err != nil || len(rawSig) == 0 {
writeError(w, http.StatusUnauthorized, "unauthorized", "bad signature")
return
}
canonical := []byte(uid + "\n" + role + "\n" + rid)
if !verifyHMAC(primary, canonical, rawSig) &&
!(len(previous) > 0 && verifyHMAC(previous, canonical, rawSig)) {
writeError(w, http.StatusUnauthorized, "unauthorized", "bad signature")
return
}
// положить uid, role в ctx
next.ServeHTTP(w, r)
})
}
}
func verifyHMAC(key, canonical, sig []byte) bool {
mac := hmac.New(sha256.New, key)
mac.Write(canonical)
return hmac.Equal(sig, mac.Sum(nil))
}Ключевое: сравниваем raw bytes, не hex-строки. hex.DecodeString
делается один раз для подписи из header’а; канонический HMAC
вычисляется на raw bytes и сравнивается constant-time через
hmac.Equal. Dual-key (PRIMARY + PREVIOUS) проверяется обоими
ключами — см. ../authentication-flow
и §Rotation секретов.
Единственный сервис, который валидирует JWT, — тот, что их выдаёт
(см. репозиторий сервиса user, файл internal/middleware/auth.go). Все
остальные сервисы получают уже проверенные заголовки.
Авторизация
GatewayAuth отвечает только на вопрос «кто запрашивает» — после него
в ctx лежит user_id и role. Проверка «что ему разрешено» —
отдельная задача, с двумя уровнями. Разграничение этих уровней —
осознанное: role-проверки одинаковы для всех экземпляров ресурса, их
логично выносить в middleware; resource-level-проверки требуют загрузки
конкретной строки, их место — в service-слое.
Role-based — middleware
Для правила «endpoint доступен только admin / moderator / owner-роли»
используется middleware.RequireRole. Ставится на sub-router после
GatewayAuth и до handler’а:
r.Route("/v1/admin", func(r chi.Router) {
r.Use(middleware.RequireRole("admin"))
r.Get("/users", h.Admin.ListUsers)
r.Post("/users/{id}/ban", h.Admin.BanUser)
})
r.Route("/v1/moderation", func(r chi.Router) {
r.Use(middleware.RequireRole("admin", "moderator"))
r.Post("/reviews/{id}/hide", h.Moderation.HideReview)
})Реализация:
func RequireRole(allowed ...string) func(http.Handler) http.Handler {
set := make(map[string]struct{}, len(allowed))
for _, r := range allowed {
set[r] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := authctx.FromCtx(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized", "no user in context")
return
}
if _, allowed := set[u.Role]; !allowed {
writeError(w, http.StatusForbidden, "forbidden", "insufficient role")
return
}
next.ServeHTTP(w, r)
})
}
}Порядок в chain (см. http-api):
GatewayAuth → RequireRole → RateLimit (опц.) → handler. RequireRole
— после GatewayAuth, потому что без authctx.User в контексте ему
нечего проверять, и до RateLimit, чтобы 403 не считался в бюджет
лимита.
Resource-level — в service-слое
Проверки типа «user может редактировать только свой ревью», «moderator
может скрыть ревью только в своём городе», «owner места может отвечать
только на ревью к своему месту» — не role check. Они требуют
загрузки конкретной строки из БД и сравнения её полей с user_id из
контекста.
Такие проверки живут в service-слое, не в middleware и не в handler’е. Причина: handler не должен дублировать бизнес-правила, а middleware не имеет доступа к БД (и не должен — иначе auth начнёт участвовать в performance-profiling каждого endpoint’а).
func (s *ReviewService) Update(ctx context.Context, id int64, patch UpdatePatch) (*domain.Review, error) {
u, ok := authctx.FromCtx(ctx)
if !ok {
return nil, service.ErrUnauthorized
}
rev, err := s.repo.Get(ctx, id)
if err != nil {
return nil, err // ErrNotFound пробрасывается как есть
}
// Ownership check — доменный invariant, не role.
if rev.UserID != u.ID && u.Role != "admin" {
return nil, service.ErrForbidden
}
// ... применить patch
}Правила:
- Ошибки авторизации — всегда sentinel’ы
service.ErrForbidden/service.ErrUnauthorized, маппятся в 403/401 черезmapServiceError(см.http-api). - Никогда не возвращай 404 вместо 403 «чтобы скрыть существование ресурса», если только это явно не требование продукта. Обычный режим — честный 403 на own-ресурсе чужого пользователя.
- Сначала load, потом authz. Нельзя проверять ownership по ID из URL без загрузки строки — admin мог удалить ресурс между запросами, получишь 403 вместо 404.
- Не дублируй ownership-проверку в handler’е «на всякий случай». Один invariant — в одном месте (service). Handler — только парсит вход и мапит ошибки.
Что запрещено
- Доверять
X-User-Roleиз запроса безGatewayAuth. Клиент может прислать любой header; без HMAC-проверки это дыра. - Role-check в handler’е вместо middleware.
if u.Role != "admin"повторяется в пяти handler’ах — один забыл — дыра. Middleware даёт sub-router-wide гарантию. - Resource-check в middleware через БД. Middleware не ходит в Postgres. Иначе один slow query тормозит весь pipeline auth.
- «Soft-deny» (200 OK с пустым результатом) вместо 403. Клиент должен получать явный код, не гадать по пустому массиву.
Internal endpoints
/internal/* защищены отдельным токеном:
import "crypto/subtle"
func InternalToken(expected string) func(http.Handler) http.Handler {
want := []byte(expected)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got := []byte(r.Header.Get("X-Internal-Token"))
if len(got) == 0 || subtle.ConstantTimeCompare(got, want) != 1 {
writeError(w, http.StatusUnauthorized, "unauthorized", "unauthorized")
return
}
next.ServeHTTP(w, r)
})
}
}subtle.ConstantTimeCompare— чтобы не протекать по тайм-сайду. Неgot == wantи неbytes.Equal.- Плюс NetworkPolicy на Kubernetes:
/internal/*слушает, но снаружи кластера недоступен. - Gateway не роутит
/internal/*в публичный Интернет.
Никогда не экспонируй /internal/* наружу, даже если «временно для
отладки».
CORS
Сервис не ставит CORS-заголовки. Это задача Gateway.
Причина: если backend-сервис случайно окажется экспонирован напрямую, его permissive CORS превратит ситуацию из «одного сервиса виден» в «одного сервиса виден всем сайтам с любого origin’а». Меньше всего мы хотим усугублять misconfiguration на уровне сети.
HTTPS
- В prod — только HTTPS. TLS terminate на gateway.
- В самом сервисе HTTP-трафик локальный (внутри кластера,
ClusterIPservice) — там mTLS (если подключён service-mesh) или plain HTTP внутри NetworkPolicy. - Cookies (где применимо, в основном gateway / user-service) —
SameSite=Strict; Secure; HttpOnly. - Напоминание frontend-команде: не храни JWT в
localStorage. Это не влияет на backend, но когда frontend-коллега спросит — указывай на это.
Rate limiting
- Глобальный DDoS-щит — на gateway.
- Per-user / per-IP rate-limit на hot endpoint’ах (auth, upload, search) — в самом сервисе.
Где жить middleware
Middleware лежит в internal/middleware/ratelimit.go. Конструктор —
per-endpoint (у каждого endpoint’а свой бюджет, свой ключ):
r.Route("/v1/auth", func(r chi.Router) {
r.Use(middleware.RateLimit(rdb, middleware.RateLimitConfig{
Key: middleware.KeyByUserOrIP,
Limit: cfg.Rate.LoginPerMin,
Window: time.Minute,
}))
r.Post("/login", h.Login)
})Выбор алгоритма
| Алгоритм | Когда | Почему |
|---|---|---|
| Fixed window (INCR + EXPIRE) | Default. Hot endpoints с умеренным трафиком | Один Redis-вызов, работает без настройки |
| Token bucket | Когда нужны burst’ы (upload: 10 файлов подряд + 1/сек в среднем) | Даёт burst аллокацию |
| Sliding window | Если fixed даёт ложные срабатывания на границе окна | Точнее, дороже (2× INCR) |
Fixed window — чаще всего достаточно:
func RateLimit(rdb *redis.Client, cfg RateLimitConfig) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := "rl:" + cfg.Name + ":" + cfg.Key(r)
ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
defer cancel()
pipe := rdb.TxPipeline()
incr := pipe.Incr(ctx, key)
pipe.Expire(ctx, key, cfg.Window)
if _, err := pipe.Exec(ctx); err != nil {
// fail-open: см. ниже
log.FromCtx(ctx).Warn("ratelimit redis unavailable",
"key", cfg.Name, "err", err)
next.ServeHTTP(w, r)
return
}
if incr.Val() > int64(cfg.Limit) {
w.Header().Set("Retry-After", strconv.Itoa(int(cfg.Window.Seconds())))
writeError(w, http.StatusTooManyRequests, "rate_limited",
"too many requests")
return
}
next.ServeHTTP(w, r)
})
}
}Token bucket — когда клиент по задаче может сделать burst:
-- local в Lua, атомарно в Redis:
-- bucket: { tokens: N, last_refill: T }
-- tokens = min(capacity, tokens + (now - last_refill) * rate)
-- if tokens >= 1 then tokens--, allow else deny, retry_after = (1 - tokens) / rateLua-скрипт лежит в pkg/ratelimit/token_bucket.lua, подключается
через rdb.EvalSha. Используется в media-upload, где допустимо «10
файлов подряд, потом 1 в секунду».
Ключ
RateLimitConfig.Key — функция, возвращающая стринг-ключ для
Redis. Стандартные варианты:
// По аутентифицированному user_id; для анонимов fallback на IP.
KeyByUserOrIP = func(r *http.Request) string {
if uid := ctxutil.UserID(r.Context()); uid != 0 {
return "u:" + strconv.FormatInt(uid, 10)
}
return "ip:" + r.RemoteAddr
}
// Только IP (для pre-auth endpoint'ов типа /login).
KeyByIP = func(r *http.Request) string {
return "ip:" + r.RemoteAddr
}
// Endpoint-global (не per-client, а глобальный лимит).
KeyGlobal = func(_ *http.Request) string { return "global" }r.RemoteAddr работает только если chimw.RealIP уже отработал в
middleware-цепочке до rate-limit’а (см.
http-api).
Fail-open vs fail-closed
При недоступности Redis:
- Fail-open (default) — пропусти запрос, залогируй WARN. Обоснование: rate-limit — защита от abuse, не от downtime. Лучше пропустить потенциальный spike, чем отвалить весь сервис из-за недоступности Redis.
- Fail-closed — только на endpoint’ах, где rate-limit критичен для
безопасности (например,
/reset-password): без Redis возвращаем 503.
Дефолт в helper’е — fail-open. Fail-closed — через явный флаг в config.
Ответ
Всегда с Retry-After header’ом:
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{"error": {"code": "rate_limited", "message": "too many requests",
"request_id": "01HZ..."}}Клиент сам решает, подождать и повторить или показать пользователю ошибку.
Таргеты по endpoint’ам
Базовые рекомендации (настраиваются через env, cfg.Rate.*):
| Endpoint | Лимит | Ключ |
|---|---|---|
POST /v1/auth/login | 10 / минуту | IP |
POST /v1/auth/reset-password | 3 / час | IP + email |
POST /v1/media/upload | 30 / минуту | user |
GET /v1/search | 60 / минуту | user |
POST /v1/reviews | 20 / час | user |
Нет универсального «лимит на всё» — каждый endpoint настраивается под свой профиль использования.
Зависимости
go mod tidyперед каждым коммитом — убирай неиспользуемые зависимости.govulncheck ./...в CI — отлавливает CVE в используемых версиях.- SBOM —
syftна Docker-образе в CI. - Сканирование образа —
trivy image <image>перед push в registry. CI блокирует merge при HIGH / CRITICAL уязвимостях.
Логи без PII
Строгое правило: email, phone, токены, device_id в логи в открытом
виде не попадают. Маскируй через pkg/pii/. Подробнее — в
../conventions/logging.
Error messages клиенту
Внутренние детали в публичный ответ не утекают:
// ПЛОХО
writeError(w, http.StatusInternalServerError, "internal", err.Error())
// ХОРОШО
log.FromCtx(ctx).Error("db query failed", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "internal server error")Клиент получает generic сообщение + request_id, по которому на support-
стороне находят полную историю в логах.
Исключения, где детали можно показывать:
- Validation errors: «поле rating вне диапазона 1-5» — это полезно клиенту.
- Conflict: «resource already exists» — тоже ожидаемо.
SELECT ... FROM users / путь к файлу / DSN / stack trace — в ответ
никогда не попадают.
Never trust
Атрибуты запроса, которые клиент может подделать:
User-Agent— может содержать что угодно, не используй для авторизации.Hostheader — может быть подделан на внутренних сетях без proxy.X-Forwarded-For— учитывай только через trusted-proxy config (chimw.RealIPнастроенный на trusted-сеть gateway), иначе клиент пропишет в него что угодно.Content-Typeдля upload’а — не доверяй, валидируй magic number файла (первые байты), а не заголовок.filenameдля upload’а — всегда считай hostile. Нормализуй, убирай../,..\, NUL-байты, ограничивай по whitelist символов, генерируй storage-имя сам (например,<ulid>.<extension>). Не используй user-filename как путь на диске / S3.- Значения, которые пользователь сам заполняет в профиле (name, bio), — sanitize перед тем, как отрендерить в HTML на другой странице.
Хранение паролей
-
Только argon2id. Никаких bcrypt, scrypt, PBKDF2, MD5, SHA-*.
-
Параметры (дефолт):
argon2.IDKey(password, salt, time: 3, // iterations memory: 64 * 1024, // 64 MiB threads: 4, keyLen: 32, ) -
Соль — 16 байт из
crypto/rand, уникальная на пользователя. -
Хранить как строку:
$argon2id$v=19$m=65536,t=3,p=4$<salt>$<hash>— формат argon2-phc-string, чтобы параметры ехали рядом с хешем.
Crypto
- Используй стандартные пакеты
crypto/*и хорошо известные библиотеки (golang.org/x/crypto/...). Никаких «а я сам зашифрую XOR-ом». - Randomness:
crypto/rand.Reader.math/rand— только для некриптографических целей (jitter, dice roll в игре — нет у нас, но правило общее). - AEAD (когда нужно шифровать данные):
crypto/aes+ GCM изcrypto/cipherлибоchacha20poly1305изx/crypto. Всегда с nonce изcrypto/rand, nonce не переиспользуется с тем же ключом. - Ключи не держи в коде. Только из конфига/secret manager.
- Никаких self-rolled схем шифрования, подписи, key-derivation. Если не знаешь, что использовать, — спроси в backend-канале.
Rotation секретов
Любой ключ подписи (HMAC для gateway-headers, JWT sign key в user-service) должен поддерживать две версии одновременно в сервисе, потребляющем:
type Verifier struct {
primary []byte // текущий
previous []byte // предыдущий, действителен до конца TTL токенов
}
func (v *Verifier) Verify(sig, payload []byte) bool {
if hmacEqual(v.primary, sig, payload) {
return true
}
if len(v.previous) > 0 && hmacEqual(v.previous, sig, payload) {
return true
}
return false
}Подпись при выпуске всегда идёт primary ключом. Verify принимает оба. Ротация: заменили primary, старый стал previous, дождались истечения всех токенов — удалили previous из конфига.
Подробнее — в ../how-to/rotate-jwt-key.
Что не делать
- Не пиши свой password hash / session store / CSRF-token generator. Используй готовое.
- Не выключай валидацию сертификатов в HTTP-клиенте
(
InsecureSkipVerify: true). Если «надо для отладки» — вынеси в dev-флаг с явной проверкой окружения. - Не логируй содержимое Authorization header’а целиком. Даже «для отладки».
- Не давай сервису права
SUPERUSER/ALL PRIVILEGESв Postgres. Отдельный user на каждый сервис с минимумом прав на его собственную БД (не на другие БД кластера). - Не создавай API-ключи с бесконечным TTL. Любой ключ — с expiry и rotation plan.
См. также
../how-to/rotate-jwt-key— процедура ротации JWT-ключа без downtime.configuration— откуда сервис читает секреты.logging— маскирование PII и запрет логировать Authorization header.caching— Redis под rate-limit’ом, failure modes.../patterns/retry-and-circuit-breaker— как retry взаимодействует с rate-limit’ом downstream’а (429 +Retry-After).http-api— порядок middleware, включая rate limiting.