Усиление установки

Базовый чек-лист продакшена — в Установке, таблица переменных HSTS и их взаимные ограничения — в Конфигурации (секция Security). Эта страница — свод: что из защиты периметра закрывает обратный прокси перед приложением, что закрывает само приложение, и как проверить обе половины после деплоя.

Граница ответственности

Обратный прокси закрывает то, до чего приложению снизу не дотянуться: терминирует TLS, подменяет собой дефолтные страницы ошибок веб-сервера/панели хостинга (в которых обычно светится версия nginx или Traefik), ограничивает набор HTTP-методов и решает, какие пути вообще видны снаружи. Приложение закрывает то, что живёт внутри самого ответа: security-заголовки на каждой странице, Strict-Transport-Security при HTTPS-GOTCHA_BASE_URL, отсутствие обслуживания TRACE и страницы ошибок без номера версии и стека вызовов. Ни одна половина не подменяет другую — прокси без этих настроек оставляет дыры, которые приложение закрыть не может в принципе (версия nginx уходит до того, как запрос вообще доедет до Go-процесса), а голое приложение без прокси перед ним — это HTTP без TLS и открытые наружу служебные пути.

Обратный прокси

Три настройки, которые стоит выставить в любом прокси перед Gotcha: server_tokens off, чтобы не светить версию самого прокси в заголовках и дефолтных страницах ошибок; собственный error_page вместо страницы nginx/Traefik/панели хостинга по умолчанию; ограничение методов до тех, что реально нужны, — GET, POST, HEAD.

server_tokens off;

location / {
    limit_except GET POST HEAD { deny all; }
    proxy_intercept_errors on;
    error_page 404 500 502 503 504 /error.html;
    proxy_pass http://127.0.0.1:59080;
}

proxy_intercept_errors on обязателен вместе с error_page — без него nginx проксирует готовую страницу ошибки от бэкенда как есть, а не подменяет её своей.

Что не выставлять наружу

/metrics, /version, /healthz, /readyz — служебные ручки, нужные изнутри (оркестратору, системе мониторинга, вам самим по SSH-туннелю), а не публике. Ни одна не требует аутентификации. /version, /healthz и /readyz анонимно отдают точную версию сборки (поля version/commit в теле ответа) — а точная версия сужает атакующему поиск уязвимостей до тех, что закрыты именно в этом релизе, вместо перебора вслепую. Подробнее об этих ручках и о том, что именно они отдают, — Мониторинг самого gotcha.

location ~ ^/(metrics|version|healthz|readyz)$ {
    allow 10.0.0.0/8;
    allow 127.0.0.1;
    deny all;
    proxy_pass http://127.0.0.1:59080;
}
@internal path /metrics /version /healthz /readyz
handle @internal {
    @allowed remote_ip 10.0.0.0/8 127.0.0.1
    handle @allowed { reverse_proxy localhost:59080 }
    respond 403
}

Замените 10.0.0.0/8 на диапазон, из которого реально приходят ваши пробы (оркестратор, Prometheus, ваша сеть) — открытый по умолчанию диапазон бессмысленен как ограничение.

То же относится к базам. Штатный docker-compose.yml не публикует порты PostgreSQL и ClickHouse на хост — до них добираются только контейнеры той же docker-сети, — но пароль у обеих по умолчанию gotcha / gotcha, и он одинаков у каждой установки в мире. Смените его через GOTCHA_COMPOSE_PG_PASSWORD / GOTCHA_COMPOSE_CH_PASSWORD до первого старта (после инициализации тома переменная сама по себе уже ничего не меняет), а на живой установке — сначала ALTER USER в самой базе, потом переменная; команды — в Конфигурации. И не добавляйте базам ports: «для удобства»: с дефолтным паролем это открытая база на публичном адресе.

TLS и HSTS

TLS — не ниже версии 1.2, с редиректом с голого HTTP на HTTPS. HSTS настраивается ровно в ОДНОМ месте — либо на прокси, либо в приложении, никогда в обоих сразу: два источника заголовка на одном ответе не складываются, а просто маскируют друг друга. Если HSTS уже шлёт прокси, приложению задайте GOTCHA_HSTS_ENABLED=false.

Приложение собирает заголовок из четырёх переменных:

ПеременнаяДефолтСмысл
GOTCHA_HSTS_ENABLEDtrueОтправлять ли Strict-Transport-Security вообще (только на https-ответах).
GOTCHA_HSTS_MAX_AGE_SECONDS31536000На сколько секунд браузеру запомнить требование HTTPS (дефолт — год); 0 — не «выключено», а осознанный аварийный откат, см. ниже.
GOTCHA_HSTS_INCLUDE_SUBDOMAINSfalseРаспространять требование HTTPS на все поддомены хоста из GOTCHA_BASE_URL.
GOTCHA_HSTS_PRELOADfalseПомечать инстанс кандидатом на списки предзагрузки браузеров.

Точные правила отказа старта, поведение при MAX_AGE_SECONDS=0 и почему выключение HSTS не снимает уже выданный браузером пин — в Конфигурации.

Включайте includeSubDomains, только если контролируете (или уже проверили HTTPS на) весь родительский домен целиком: gotcha.example.com с этим флагом требует HTTPS не только от себя, а от каждого сервиса на example.com, включая те, что вы не администрируете и которые могут быть не готовы к HTTPS.

Preload — билет в один конец: попав в список предзагрузки, домен зашивается в релизы браузеров на месяцы вперёд, и снять его оттуда — вопрос месяцев, а не минут. Выход при аварии — строго в этом порядке, иначе приложение откажется стартовать (валидация конфига требует max-age не меньше года, пока PRELOAD=true, — см. Конфигурацию):

  1. GOTCHA_HSTS_PRELOAD=false — снять требование годового max-age, которое иначе не даст уйти в шаг 2.
  2. GOTCHA_HSTS_MAX_AGE_SECONDS=0, оставив GOTCHA_HSTS_ENABLED=true — заголовок с нулевым max-age реально уходит клиентам и снимает пин.
  3. Дождаться, пока пин истечёт у уже посетивших инстанс клиентов.
  4. Только теперь GOTCHA_HSTS_ENABLED=false, если заголовок больше не нужен вовсе.

Выключенный HSTS пин сам по себе не снимает — он лишь перестаёт продлеваться, поэтому шаг 4 без шагов 1–3 не отменяет аварию, а замораживает её на срок ранее выданного max-age.

security.txt

Приложение не отдаёт /.well-known/security.txt само — это осознанное решение, не недоработка. Контакт для сообщений об уязвимостях — свойство ДОМЕНА, а не конкретного приложения на нём: значительная часть установок Gotcha живёт на поддомене чужого домена (общий хостинг, корпоративный портал), и владеет контактом безопасности владелец домена, а не разработчик Gotcha. Положите файл на прокси:

location = /.well-known/security.txt {
    default_type text/plain;
    return 200 "Contact: mailto:security@example.com\nExpires: 2027-01-01T00:00:00.000Z\n";
}

Самопроверка

После деплоя проверьте обе половины периметра — прокси и приложение — набором curl:

# security-заголовки приложения на странице входа
curl -sI https://gotcha.example/login | grep -Ei 'content-security-policy|x-frame-options|strict-transport'

# HSTS есть на https-инстансе...
curl -sI https://gotcha.example/login | grep -i strict-transport
# ...и его нет на голом http-деплое, независимо от конфига
# (пусто, если только ваш прокси сам не вешает HSTS на редиректе http->https —
# рекомендуемая топология выше это допускает; тогда заголовок на 301 ожидаем)
curl -sI http://gotcha.example/login | grep -i strict-transport   # ожидается: пусто

# служебные пути закрыты прокси
curl -s -o /dev/null -w '%{http_code}\n' https://gotcha.example/metrics   # ожидается 403
curl -s -o /dev/null -w '%{http_code}\n' https://gotcha.example/version  # ожидается 403

# TRACE не обслуживается
curl -s -o /dev/null -w '%{http_code}\n' -X TRACE https://gotcha.example/login  # ожидается 404

Про TRACE отдельно: ожидается именно 404, а не 405. Веб-слой перехватывает любой метод на этом пути общим catch-all’ом и всегда отвечает стилизованной страницей 404 — отсутствие 405 здесь не признак того, что метод TRACE чем-то обслуживается, это признак того, что маршрут вообще не различает методы на уровне mux.