fix: dual-key rate limiting for auth + websocket reconnect guard
- Pass client IP (X-Real-IP header) through Flask to daemon for both password login and WebAuthn authenticate-finish endpoints - Rate limiter now checks both IP and username buckets: IP layer catches enumeration/brute-force attacks across multiple usernames; username layer protects against single-account targeting from multiple IPs - Add _wsRefreshing flag to prevent double-scheduling reconnect when onclose fires during token refresh; simplify async IIFE to .then()/.catch() - Reset _wsRefreshing on websocket onopen for safety
This commit is contained in:
@@ -90,7 +90,8 @@ def auth_login(_request: Any, body: Any) -> dict[str, Any]:
|
|||||||
if not username or not password:
|
if not username or not password:
|
||||||
raise ValueError("username and password are required")
|
raise ValueError("username and password are required")
|
||||||
|
|
||||||
if not check_login_rate(username):
|
client_ip = body.get("client_ip")
|
||||||
|
if not check_login_rate(username, client_ip):
|
||||||
raise ValueError("Too many login attempts. Please try again later.")
|
raise ValueError("Too many login attempts. Please try again later.")
|
||||||
|
|
||||||
user = verify_user_password(username, password)
|
user = verify_user_password(username, password)
|
||||||
@@ -449,11 +450,12 @@ def webauthn_authenticate_finish(_request: Any, body: Any) -> dict[str, Any]:
|
|||||||
username = body.get("username")
|
username = body.get("username")
|
||||||
assertion_response = body.get("assertion_response")
|
assertion_response = body.get("assertion_response")
|
||||||
auth_options = body.get("auth_options")
|
auth_options = body.get("auth_options")
|
||||||
|
client_ip = body.get("client_ip")
|
||||||
|
|
||||||
if not username or not assertion_response or not auth_options:
|
if not username or not assertion_response or not auth_options:
|
||||||
raise ValueError("username, assertion_response, and auth_options are required")
|
raise ValueError("username, assertion_response, and auth_options are required")
|
||||||
|
|
||||||
if not check_webauthn_rate(username):
|
if not check_webauthn_rate(username, client_ip):
|
||||||
raise ValueError("Too many WebAuthn attempts. Please try again later.")
|
raise ValueError("Too many WebAuthn attempts. Please try again later.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+11
-2
@@ -394,25 +394,34 @@ _login_limiter = RateLimiter(max_attempts=10, window_seconds=300)
|
|||||||
_webauthn_limiter = RateLimiter(max_attempts=5, window_seconds=600)
|
_webauthn_limiter = RateLimiter(max_attempts=5, window_seconds=600)
|
||||||
|
|
||||||
|
|
||||||
def check_login_rate(username: str) -> bool:
|
def check_login_rate(username: str, client_ip: str | None = None) -> bool:
|
||||||
"""Check if login is rate-limited for the given username.
|
"""Check if login is rate-limited for the given username.
|
||||||
|
|
||||||
|
Uses dual-key tracking: always records by IP (catches enumeration attacks),
|
||||||
|
additionally records by username (catches legitimate users who forget password).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: The login attempt username.
|
username: The login attempt username.
|
||||||
|
client_ip: The client IP address (from X-Real-IP header).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the attempt is allowed, False if rate limited.
|
True if the attempt is allowed, False if rate limited.
|
||||||
"""
|
"""
|
||||||
|
if client_ip and not _login_limiter.is_allowed(client_ip):
|
||||||
|
return False
|
||||||
return _login_limiter.is_allowed(username)
|
return _login_limiter.is_allowed(username)
|
||||||
|
|
||||||
|
|
||||||
def check_webauthn_rate(username: str) -> bool:
|
def check_webauthn_rate(username: str, client_ip: str | None = None) -> bool:
|
||||||
"""Check if WebAuthn authentication is rate-limited for the given username.
|
"""Check if WebAuthn authentication is rate-limited for the given username.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
username: The WebAuthn attempt username.
|
username: The WebAuthn attempt username.
|
||||||
|
client_ip: The client IP address (from X-Real-IP header).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if the attempt is allowed, False if rate limited.
|
True if the attempt is allowed, False if rate limited.
|
||||||
"""
|
"""
|
||||||
|
if client_ip and not _webauthn_limiter.is_allowed(client_ip):
|
||||||
|
return False
|
||||||
return _webauthn_limiter.is_allowed(username)
|
return _webauthn_limiter.is_allowed(username)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ def login():
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
|
body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr
|
||||||
return _ok(post(POST_AUTH_LOGIN, body))
|
return _ok(post(POST_AUTH_LOGIN, body))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("Login failed: %s", exc)
|
logger.error("Login failed: %s", exc)
|
||||||
@@ -289,6 +290,7 @@ def webauthn_authenticate_finish():
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
|
body["client_ip"] = request.headers.get("X-Real-IP") or request.remote_addr
|
||||||
return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH, body))
|
return _ok(post(POST_AUTH_WEBAUTHN_AUTHENTICATE_FINISH, body))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error("WebAuthn authenticate finish failed: %s", exc)
|
logger.error("WebAuthn authenticate finish failed: %s", exc)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { tryRefreshToken } from './api.js?v=12';
|
|||||||
let _wsConn = null;
|
let _wsConn = null;
|
||||||
let _wsReconnectMs = 0;
|
let _wsReconnectMs = 0;
|
||||||
let _wsFailCount = 0;
|
let _wsFailCount = 0;
|
||||||
|
let _wsRefreshing = false;
|
||||||
|
|
||||||
/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */
|
/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */
|
||||||
const _directHandlers = [];
|
const _directHandlers = [];
|
||||||
@@ -44,28 +45,24 @@ function _wsConnect() {
|
|||||||
_wsConn.onopen = () => {
|
_wsConn.onopen = () => {
|
||||||
_wsReconnectMs = 0;
|
_wsReconnectMs = 0;
|
||||||
_wsFailCount = 0;
|
_wsFailCount = 0;
|
||||||
|
_wsRefreshing = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
_wsConn.onclose = () => {
|
_wsConn.onclose = () => {
|
||||||
if (!window.__auth_token__) return;
|
if (!window.__auth_token__) return;
|
||||||
_wsFailCount++;
|
_wsFailCount++;
|
||||||
|
|
||||||
if (_wsFailCount >= 3) {
|
if (_wsFailCount >= 3 && !_wsRefreshing) {
|
||||||
// Attempt token refresh after repeated failures. The reconnect
|
_wsRefreshing = true;
|
||||||
// is handled inside the IIFE to avoid double-scheduling when
|
tryRefreshToken().then(ok => {
|
||||||
// refresh succeeds.
|
_wsRefreshing = false;
|
||||||
(async () => {
|
_wsFailCount = 0;
|
||||||
const ok = await tryRefreshToken();
|
_wsReconnectMs = 0;
|
||||||
if (ok) {
|
_wsConn = null;
|
||||||
_wsFailCount = 0;
|
setTimeout(_wsConnect, 100);
|
||||||
_wsReconnectMs = 0;
|
}).catch(() => {
|
||||||
_wsConn = null;
|
_wsRefreshing = false;
|
||||||
setTimeout(_wsConnect, 100);
|
});
|
||||||
} else {
|
|
||||||
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
|
|
||||||
setTimeout(_wsConnect, _wsReconnectMs);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user