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:
parent
de1652a6d8
commit
b063b7a791
2 changed files with 49 additions and 4 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue