Skip to content

Commit 0af0a3a

Browse files
committed
security: rate limiting on auth endpoints + rpcbind disabled (F-01/02/05/09)
F-01 — API key brute-force protection: per-IP failure counter in auth.py tracks failed attempts in a 60s sliding window; returns 429 + Retry-After after 20 failures from the same IP. F-02 — Login brute-force protection: @limiter.limit("10/minute; 30/hour") on POST /api/v1/auth/login via flask-limiter 4.1.1 (added to requirements.txt, Limiter instance in extensions.py, init_app() in create_app()). F-05 — Webhook exemption documented with explicit IMPORTANT comment warning future developers that new webhook handlers must implement their own auth. F-09 — rpcbind service and socket unit disabled on LXC CT101 (pve-node1); port 111 confirmed closed via nmap from Kali VM.
1 parent 063a700 commit 0af0a3a

File tree

5 files changed

+59
-2
lines changed

5 files changed

+59
-2
lines changed

backend/app.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
from flask import Flask
1313

14-
from extensions import socketio
14+
from extensions import limiter, socketio
1515

1616
LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
1717

@@ -180,6 +180,9 @@ def create_app(testing=False):
180180

181181
register_error_handlers(app)
182182

183+
# Initialize rate limiter (in-memory; swap storage_uri for Redis in multi-worker setups)
184+
limiter.init_app(app)
185+
183186
# Initialize authentication
184187
from auth import init_auth
185188
from routes.auth_ui import auth_ui_bp

backend/auth.py

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,38 @@
88
import functools
99
import hmac
1010
import logging
11+
import threading
12+
import time
13+
from collections import defaultdict
1114

1215
from flask import jsonify, request
1316

1417
from config import get_settings
1518

1619
logger = logging.getLogger(__name__)
1720

21+
# Per-IP rate limiting for failed API key attempts.
22+
# Tracks timestamps of failures; entries older than _WINDOW are discarded.
23+
_failed_lock = threading.Lock()
24+
_failed_attempts: dict[str, list[float]] = defaultdict(list)
25+
_FAIL_LIMIT = 20 # max failed attempts per window
26+
_FAIL_WINDOW = 60 # seconds
27+
28+
29+
def _is_rate_limited(ip: str) -> bool:
30+
"""Return True if ip has exceeded the failed-auth rate limit."""
31+
now = time.monotonic()
32+
with _failed_lock:
33+
cutoff = now - _FAIL_WINDOW
34+
_failed_attempts[ip] = [t for t in _failed_attempts[ip] if t > cutoff]
35+
return len(_failed_attempts[ip]) >= _FAIL_LIMIT
36+
37+
38+
def _record_failure(ip: str) -> None:
39+
"""Record a failed auth attempt for ip."""
40+
with _failed_lock:
41+
_failed_attempts[ip].append(time.monotonic())
42+
1843

1944
def require_api_key(f):
2045
"""Decorator to enforce API key authentication on a route."""
@@ -70,16 +95,36 @@ def check_api_key():
7095
if path == "/api/v1/health":
7196
return None
7297

73-
# Skip auth for webhook endpoints (they use their own auth)
98+
# Skip auth for webhook endpoints — each handler performs its own
99+
# HMAC-based auth (see routes/webhooks.py). IMPORTANT: any new webhook
100+
# route added under /api/v1/webhook/ MUST implement auth manually;
101+
# there is no fallback enforcement here.
74102
if path.startswith("/api/v1/webhook/"):
75103
return None
76104

105+
# Skip auth for UI auth endpoints (login, setup, status, logout)
106+
# These handle their own authentication logic
107+
if path.startswith("/api/v1/auth/"):
108+
return None
109+
110+
ip = request.remote_addr or "unknown"
111+
112+
# Reject IPs that have exceeded the failed-auth rate limit
113+
if _is_rate_limited(ip):
114+
logger.warning("Rate limit exceeded for API key auth from %s", ip)
115+
resp = jsonify({"error": "Too many failed attempts. Try again later."})
116+
resp.headers["Retry-After"] = str(_FAIL_WINDOW)
117+
return resp, 429
118+
77119
provided_key = request.headers.get("X-Api-Key") or request.args.get("apikey")
78120

79121
if not provided_key:
122+
_record_failure(ip)
80123
return jsonify({"error": "API key required"}), 401
81124

82125
if not hmac.compare_digest(provided_key, current_settings.api_key):
126+
_record_failure(ip)
127+
logger.warning("Invalid API key from %s", ip)
83128
return jsonify({"error": "Invalid API key"}), 401
84129

85130
return None

backend/extensions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,16 @@
77
for graceful degradation during the transition period.
88
"""
99

10+
from flask_limiter import Limiter
11+
from flask_limiter.util import get_remote_address
1012
from flask_socketio import SocketIO
1113

1214
socketio = SocketIO()
1315

16+
# Rate limiter — unbound, init_app() called in create_app()
17+
# Default storage: in-memory (sufficient for single-process Gunicorn)
18+
limiter = Limiter(key_func=get_remote_address, default_limits=[])
19+
1420
try:
1521
from flask_migrate import Migrate
1622
from flask_sqlalchemy import SQLAlchemy

backend/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
flask==3.1.3
22
flask-socketio==5.6.1
3+
flask-limiter==4.1.1
34
Flask-SQLAlchemy==3.1.1
45
Flask-Migrate==4.1.0
56
SQLAlchemy==2.0.46

backend/routes/auth_ui.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from flask import Blueprint, jsonify, request, session
1414

1515
import ui_auth
16+
from extensions import limiter
1617

1718
logger = logging.getLogger(__name__)
1819

@@ -65,6 +66,7 @@ def setup():
6566

6667

6768
@auth_ui_bp.post("/login")
69+
@limiter.limit("10/minute; 30/hour")
6870
def login():
6971
if not ui_auth.is_ui_auth_enabled():
7072
session["ui_authenticated"] = True

0 commit comments

Comments
 (0)