Безопасность¶
Правила обращения с секретами, валидации ввода, обработки токенов и крипто-операций. Это не обзор «всей» безопасности — это набор конкретных запретов и практик, которые должен применять каждый 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.md. - Лимиты на размер тела —
http.MaxBytesReaderна входе:
SQL injection¶
pgx параметризует запросы автоматически. Правило простое:
- Всегда используй плейсхолдеры
$1, $2, ...:
- Никогда
fmt.Sprintfс user input в SQL:
- Никогда string concat в SQL:
Исключение: когда параметризовать нужно имя таблицы / колонку (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(key []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")
mac := hmac.New(sha256.New, key)
mac.Write([]byte(uid + "\n" + role + "\n" + rid))
want := hex.EncodeToString(mac.Sum(nil))
got, _ := hex.DecodeString(sig)
wantBytes, _ := hex.DecodeString(want)
if !hmac.Equal(got, wantBytes) {
writeError(w, http.StatusUnauthorized, "unauthorized", "bad signature")
return
}
// положить uid, role в ctx
next.ServeHTTP(w, r)
})
}
}
Единственный сервис, который валидирует JWT, — тот, что их выдаёт
(см. репозиторий сервиса user, файл internal/middleware/auth.go). Все
остальные сервисы получают уже проверенные заголовки.
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) / rate
Lua-скрипт лежит в 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.md).
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.md.
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.md.
Что не делать¶
- Не пиши свой 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.md— процедура ротации JWT-ключа без downtime.configuration.md— откуда сервис читает секреты.logging.md— маскирование PII и запрет логировать Authorization header.caching.md— Redis под rate-limit'ом, failure modes.../patterns/retry-and-circuit-breaker.md— как retry взаимодействует с rate-limit'ом downstream'а (429 +Retry-After).http-api.md— порядок middleware, включая rate limiting.