mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Dedupe auth.unauthorized floods from unauthenticated page loads.
Keep one 401 audit per IP+path window, and hide historical unauthorized noise from the default business view. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ea3d45ddc2
commit
d35d3992c2
5 changed files with 82 additions and 10 deletions
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .config import settings
|
||||
|
|
@ -18,6 +19,13 @@ _lock = threading.Lock()
|
|||
_counter = 0
|
||||
_dropped = 0
|
||||
|
||||
# Dedupe unauthenticated request floods (SPA fires many parallel 401s).
|
||||
_unauth_lock = threading.Lock()
|
||||
_unauth_recent: dict[str, float] = {}
|
||||
_UNAUTH_DEDUP_GET_SEC = 60.0
|
||||
_UNAUTH_DEDUP_WRITE_SEC = 15.0
|
||||
_UNAUTH_RECENT_MAX = 4000
|
||||
|
||||
# Middleware-tagged HTTP wrappers that duplicate semantic business audits.
|
||||
_MIDDLEWARE_NOISE_ACTIONS = frozenset(
|
||||
{
|
||||
|
|
@ -61,6 +69,32 @@ def _sample_ok() -> bool:
|
|||
return (_counter % n) == 0
|
||||
|
||||
|
||||
def should_audit_unauthorized(*, client_ip: str, method: str, path: str) -> bool:
|
||||
"""Rate-limit auth.unauthorized writes: one row per IP+method+path per window.
|
||||
|
||||
Unauthenticated page loads often fan out dozens of GETs in the same second;
|
||||
keeping every 401 drowns real login/security events.
|
||||
"""
|
||||
method_u = str(method or "GET").upper()
|
||||
window = (
|
||||
_UNAUTH_DEDUP_WRITE_SEC
|
||||
if method_u in ("POST", "PUT", "PATCH", "DELETE")
|
||||
else _UNAUTH_DEDUP_GET_SEC
|
||||
)
|
||||
key = f"{client_ip or '-'}|{method_u}|{path or '/'}"
|
||||
now = time.monotonic()
|
||||
with _unauth_lock:
|
||||
global _unauth_recent
|
||||
last = float(_unauth_recent.get(key) or 0.0)
|
||||
if now - last < window:
|
||||
return False
|
||||
_unauth_recent[key] = now
|
||||
if len(_unauth_recent) > _UNAUTH_RECENT_MAX:
|
||||
cutoff = now - max(_UNAUTH_DEDUP_GET_SEC * 5, 300.0)
|
||||
_unauth_recent = {k: v for k, v in _unauth_recent.items() if float(v) >= cutoff}
|
||||
return True
|
||||
|
||||
|
||||
def audit_should_persist(
|
||||
*,
|
||||
action: str,
|
||||
|
|
|
|||
|
|
@ -113,16 +113,20 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
|||
try:
|
||||
resolved = resolve_user_from_token(db, token) if token else None
|
||||
if resolved is None:
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.unauthorized",
|
||||
method=request.method,
|
||||
path=path,
|
||||
status_code=401,
|
||||
client_ip=_client_ip(request),
|
||||
user_agent=str(request.headers.get("user-agent") or "")[:512],
|
||||
detail={},
|
||||
)
|
||||
client_ip = _client_ip(request)
|
||||
from .audit_async import should_audit_unauthorized
|
||||
|
||||
if should_audit_unauthorized(client_ip=client_ip, method=request.method, path=path):
|
||||
write_audit(
|
||||
db,
|
||||
action="auth.unauthorized",
|
||||
method=request.method,
|
||||
path=path,
|
||||
status_code=401,
|
||||
client_ip=client_ip,
|
||||
user_agent=str(request.headers.get("user-agent") or "")[:512],
|
||||
detail={},
|
||||
)
|
||||
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
|
||||
user, via, scopes, token_id, jti = resolved
|
||||
if bool(getattr(user, "must_change_password", False)):
|
||||
|
|
|
|||
|
|
@ -747,6 +747,7 @@ def list_audit_logs(
|
|||
"api_tokens.get",
|
||||
"auth.me",
|
||||
"auth.sessions",
|
||||
"auth.unauthorized",
|
||||
)
|
||||
q = q.filter(~AuditLog.action.like("http.%"))
|
||||
q = q.filter(~AuditLog.action.in_(noise_actions))
|
||||
|
|
|
|||
|
|
@ -60,5 +60,33 @@ class AuditShouldPersistTests(unittest.TestCase):
|
|||
self.assertFalse(audit_should_persist(action="ume.token.get", method="GET", status_code=200))
|
||||
|
||||
|
||||
class UnauthorizedDedupeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
from netx_api import audit_async as aa
|
||||
|
||||
with aa._unauth_lock:
|
||||
aa._unauth_recent.clear()
|
||||
|
||||
def test_dedupes_same_ip_path(self) -> None:
|
||||
from netx_api.audit_async import should_audit_unauthorized
|
||||
|
||||
self.assertTrue(
|
||||
should_audit_unauthorized(client_ip="127.0.0.1", method="GET", path="/v1/topology")
|
||||
)
|
||||
self.assertFalse(
|
||||
should_audit_unauthorized(client_ip="127.0.0.1", method="GET", path="/v1/topology")
|
||||
)
|
||||
# Different path still recorded once.
|
||||
self.assertTrue(
|
||||
should_audit_unauthorized(client_ip="127.0.0.1", method="GET", path="/v1/managed-ne")
|
||||
)
|
||||
|
||||
def test_different_ip_not_deduped(self) -> None:
|
||||
from netx_api.audit_async import should_audit_unauthorized
|
||||
|
||||
self.assertTrue(should_audit_unauthorized(client_ip="1.1.1.1", method="GET", path="/v1/x"))
|
||||
self.assertTrue(should_audit_unauthorized(client_ip="2.2.2.2", method="GET", path="/v1/x"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -87,6 +87,11 @@ export function auditSummary(
|
|||
});
|
||||
}
|
||||
if (action.startsWith("auth.")) {
|
||||
if (action === "auth.unauthorized") {
|
||||
const method = row?.method || "GET";
|
||||
const path = row?.path || "";
|
||||
return path ? `${method} ${path}` : "unauthorized";
|
||||
}
|
||||
return action.replace(/^auth\./, "");
|
||||
}
|
||||
if (action.startsWith("http.") || action.startsWith("ume.")) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue