fix(security): caddy/** в deploy paths + username из 401 попытки

Проблема 1: PR #433 (caddy/users.caddy.snippet) не запустил deploy —
путь caddy/** не был в on.push.paths и changes.infra filter.
Итог: на VPS остался старый snippet без юзера kopylov → 401.

Проблема 2: GlitchTip events показывали user_id="(none)" всегда.
Caddy не пишет username провалившейся basic_auth попытки в user_id.
Решение: парсим Authorization: Basic header, base64-decode,
берём только username (password отбрасывается, не логируется).
This commit is contained in:
lekss361 2026-05-23 12:18:03 +03:00
parent de1652a6d8
commit b063b7a791
2 changed files with 49 additions and 4 deletions

View file

@ -12,6 +12,7 @@ on:
- "frontend/**"
- "docker-compose.prod.yml"
- "Caddyfile"
- "caddy/**"
- ".forgejo/workflows/deploy.yml"
- "data/sql/**"
- "ops/glitchtip-auth-forwarder/**"
@ -47,6 +48,7 @@ jobs:
infra:
- 'docker-compose.prod.yml'
- 'Caddyfile'
- 'caddy/**'
- '.forgejo/workflows/deploy.yml'
build-backend:

View file

@ -37,6 +37,7 @@ Throttle: при >10 401 events за 60s — однократный digest event
}
"""
import base64
import json
import os
import signal
@ -113,6 +114,44 @@ def is_monitor_ua(ua: str) -> bool:
return any(ua.startswith(prefix) for prefix in KNOWN_MONITOR_UAS)
def _extract_attempted_username(entry: dict) -> str | None: # type: ignore[type-arg]
"""
Извлекает username из Authorization header (Basic auth) из попытки входа.
Возвращает None если:
- Authorization header отсутствует
- Формат не Basic
- base64 не декодируется
- после decode нет ':'
Никогда не логирует / не возвращает пароль.
"""
req = entry.get("request") or {}
headers = req.get("headers") or {}
auth_values = headers.get("Authorization") or headers.get("authorization")
if not auth_values:
return None
if isinstance(auth_values, list):
if not auth_values:
return None
auth = auth_values[0]
else:
auth = auth_values
if not isinstance(auth, str) or not auth.lower().startswith("basic "):
return None
try:
decoded = base64.b64decode(auth[6:], validate=True).decode("utf-8", errors="replace")
except Exception:
return None
if ":" not in decoded:
return None
username = decoded.split(":", 1)[0]
# Sanity check: только разумная длина
if not username or len(username) > 128:
return None
return username
def emit_event(entry: dict) -> None: # type: ignore[type-arg]
"""Отправляет одиночный 401 event в GlitchTip."""
req = entry.get("request", {})
@ -127,11 +166,14 @@ def emit_event(entry: dict) -> None: # type: ignore[type-arg]
ua = ua_list[0] if ua_list else "?"
ts = entry.get("ts")
user_id = entry.get("user_id", "")
attempted_username = _extract_attempted_username(entry)
with sentry_sdk.new_scope() as scope:
scope.set_tag("event_type", "basic_auth_failed")
scope.set_tag("http_method", method)
scope.set_tag("remote_ip", remote_ip)
if attempted_username:
scope.set_tag("attempted_username", attempted_username)
scope.set_context(
"caddy_log",
{
@ -142,12 +184,13 @@ def emit_event(entry: dict) -> None: # type: ignore[type-arg]
"user_agent": ua,
"caddy_ts": ts,
"user_id": user_id or "(none)",
"attempted_username": attempted_username or "(none)",
},
)
sentry_sdk.capture_message(
f"basic_auth 401 — {method} {path} from {remote_ip}",
level="warning",
)
message = f"basic_auth 401 — {method} {path} from {remote_ip}"
if attempted_username:
message += f" (tried: {attempted_username})"
sentry_sdk.capture_message(message, level="warning")
def emit_digest(count: int, window_s: int, monitor_count: int) -> None: