Перейти к содержанию

Безопасность

Правила обращения с секретами, валидации ввода, обработки токенов и крипто-операций. Это не обзор «всей» безопасности — это набор конкретных запретов и практик, которые должен применять каждый backend-инженер.

Содержание

Секреты

  • Никогда не коммить секреты в 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 на входе:
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 emailhtml/template, не text/template. html/template автоматически экранирует user input по контексту (attr, URL, JS).
  • SMS / plain texttext/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-трафик локальный (внутри кластера, ClusterIP service) — там 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 — может содержать что угодно, не используй для авторизации.
  • Host header — может быть подделан на внутренних сетях без 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.

См. также