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/**"
|
- "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:
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue