From b063b7a7919fd6987012f0c8406bd0aaa7cf7d31 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Sat, 23 May 2026 12:18:03 +0300 Subject: [PATCH] =?UTF-8?q?fix(security):=20caddy/**=20=D0=B2=20deploy=20p?= =?UTF-8?q?aths=20+=20username=20=D0=B8=D0=B7=20401=20=D0=BF=D0=BE=D0=BF?= =?UTF-8?q?=D1=8B=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Проблема 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 отбрасывается, не логируется). --- .forgejo/workflows/deploy.yml | 2 + ops/glitchtip-auth-forwarder/forwarder.py | 51 +++++++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml index 599c8804..b9cd5d1e 100644 --- a/.forgejo/workflows/deploy.yml +++ b/.forgejo/workflows/deploy.yml @@ -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: diff --git a/ops/glitchtip-auth-forwarder/forwarder.py b/ops/glitchtip-auth-forwarder/forwarder.py index e1037550..4bed2ea1 100644 --- a/ops/glitchtip-auth-forwarder/forwarder.py +++ b/ops/glitchtip-auth-forwarder/forwarder.py @@ -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: