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

Merged
lekss361 merged 1 commit from fix/auth-forwarder-paths-and-username into main 2026-05-23 09:25:41 +00:00
2 changed files with 49 additions and 4 deletions

View file

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

View file

@ -37,6 +37,7 @@ Throttle: при >10 401 events за 60s — однократный digest event
} }
""" """
import base64
import json import json
import os import os
import signal import signal
@ -113,6 +114,44 @@ def is_monitor_ua(ua: str) -> bool:
return any(ua.startswith(prefix) for prefix in KNOWN_MONITOR_UAS) 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] def emit_event(entry: dict) -> None: # type: ignore[type-arg]
"""Отправляет одиночный 401 event в GlitchTip.""" """Отправляет одиночный 401 event в GlitchTip."""
req = entry.get("request", {}) 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 "?" ua = ua_list[0] if ua_list else "?"
ts = entry.get("ts") ts = entry.get("ts")
user_id = entry.get("user_id", "") user_id = entry.get("user_id", "")
attempted_username = _extract_attempted_username(entry)
with sentry_sdk.new_scope() as scope: with sentry_sdk.new_scope() as scope:
scope.set_tag("event_type", "basic_auth_failed") scope.set_tag("event_type", "basic_auth_failed")
scope.set_tag("http_method", method) scope.set_tag("http_method", method)
scope.set_tag("remote_ip", remote_ip) scope.set_tag("remote_ip", remote_ip)
if attempted_username:
scope.set_tag("attempted_username", attempted_username)
scope.set_context( scope.set_context(
"caddy_log", "caddy_log",
{ {
@ -142,12 +184,13 @@ def emit_event(entry: dict) -> None: # type: ignore[type-arg]
"user_agent": ua, "user_agent": ua,
"caddy_ts": ts, "caddy_ts": ts,
"user_id": user_id or "(none)", "user_id": user_id or "(none)",
"attempted_username": attempted_username or "(none)",
}, },
) )
sentry_sdk.capture_message( message = f"basic_auth 401 — {method} {path} from {remote_ip}"
f"basic_auth 401 — {method} {path} from {remote_ip}", if attempted_username:
level="warning", message += f" (tried: {attempted_username})"
) sentry_sdk.capture_message(message, level="warning")
def emit_digest(count: int, window_s: int, monitor_count: int) -> None: def emit_digest(count: int, window_s: int, monitor_count: int) -> None: