diff --git a/app.py b/app.py index 8f456a8..46d29ee 100644 --- a/app.py +++ b/app.py @@ -38,15 +38,24 @@ def get_client_ip(conn) -> str: # Valkey/Redis client (initialized later if enabled) redis_client = None -# In-memory rooms: {doc_id: {"text": str, "ver": int, "peers": set[WebSocket], "authed_peers": set[WebSocket], "last_access": float, "pw_hash": bytes|None, "pw_salt": bytes|None}} +# In-memory rooms: {doc_id: {"text": str, "ver": int, "peers": set[WebSocket], "authed_peers": set[WebSocket], "last_access": float, "pw_hash": bytes|None, "pw_salt": bytes|None, "pw_iter": int|None}} rooms: dict[str, dict] = {} # Rate limiting: {ip: [timestamp, timestamp, ...]} rate_limits: dict[str, list] = defaultdict(list) +# Failed password attempts per IP (for auth brute-force / CPU-DoS limiting) +failed_auth_attempts: dict[str, list] = defaultdict(list) + # Connection tracking: {ip: connection_count} connections_per_ip: dict[str, int] = defaultdict(int) +# Password hashing parameters +PBKDF2_ITERATIONS = 600_000 # OWASP 2023 recommendation for PBKDF2-SHA256 +LEGACY_PBKDF2_ITERATIONS = 200_000 # backward-compat for pads hashed before the bump +MAX_AUTH_FAILURES = 10 # failed password attempts allowed per window +AUTH_FAILURE_WINDOW = 60 # seconds + def random_id(n: int = 8) -> str: alphabet = string.ascii_lowercase + string.digits return "".join(secrets.choice(alphabet) for _ in range(n)) @@ -91,6 +100,7 @@ def save_room_data_to_cache(doc_id: str, room: dict): "last_access": room.get("last_access", time.time()), "pw_hash": room["pw_hash"].hex() if room.get("pw_hash") else None, "pw_salt": room["pw_salt"].hex() if room.get("pw_salt") else None, + "pw_iter": room.get("pw_iter"), } redis_client.setex(f"room:{doc_id}", RETENTION_HOURS * 3600, json.dumps(data)) except Exception as e: @@ -137,18 +147,27 @@ def cleanup_old_rooms(): def check_rate_limit(client_ip: str) -> bool: now = time.time() hour_ago = now - 3600 - + # Clean old entries rate_limits[client_ip] = [t for t in rate_limits[client_ip] if t > hour_ago] - + # Check limit (50 per hour) if len(rate_limits[client_ip]) >= 50: return False - + # Add current request rate_limits[client_ip].append(now) return True +def check_auth_rate_limit(client_ip: str) -> bool: + now = time.time() + cutoff = now - AUTH_FAILURE_WINDOW + failed_auth_attempts[client_ip] = [t for t in failed_auth_attempts[client_ip] if t > cutoff] + return len(failed_auth_attempts[client_ip]) < MAX_AUTH_FAILURES + +def record_auth_failure(client_ip: str): + failed_auth_attempts[client_ip].append(time.time()) + HTML = """ aukpad @@ -640,7 +659,7 @@ async def create_pad_with_content(request: Request): doc_id = random_id() rooms[doc_id] = {"text": text_content, "ver": 1, "peers": set(), "authed_peers": set(), - "last_access": time.time(), "pw_hash": None, "pw_salt": None} + "last_access": time.time(), "pw_hash": None, "pw_salt": None, "pw_iter": None} # Save to cache if enabled save_room_data_to_cache(doc_id, rooms[doc_id]) @@ -658,7 +677,7 @@ def pad(doc_id: str): return HTMLResponse(HTML) @app.get("/{doc_id}/raw", response_class=PlainTextResponse) -def get_raw_pad_content(doc_id: str, pw: str = ""): +def get_raw_pad_content(doc_id: str, request: Request, pw: str = ""): if not is_valid_doc_id(doc_id): raise HTTPException(status_code=400, detail="Invalid pad ID") # Load room into memory if needed @@ -673,6 +692,7 @@ def get_raw_pad_content(doc_id: str, pw: str = ""): "last_access": time.time(), "pw_hash": cached_data.get("pw_hash"), "pw_salt": cached_data.get("pw_salt"), + "pw_iter": cached_data.get("pw_iter"), } if doc_id not in rooms: @@ -684,8 +704,13 @@ def get_raw_pad_content(doc_id: str, pw: str = ""): if room.get("pw_hash"): if not pw: raise HTTPException(status_code=403, detail="This pad is password protected. Use ?pw=") - candidate = hashlib.pbkdf2_hmac("sha256", pw.encode(), room["pw_salt"], 200_000) + client_ip = get_client_ip(request) + if not check_auth_rate_limit(client_ip): + raise HTTPException(status_code=429, detail="Too many failed attempts. Try again later.") + iters = room.get("pw_iter") or LEGACY_PBKDF2_ITERATIONS + candidate = hashlib.pbkdf2_hmac("sha256", pw.encode(), room["pw_salt"], iters) if candidate != room["pw_hash"]: + record_auth_failure(client_ip) raise HTTPException(status_code=403, detail="Wrong password") update_room_access_time(doc_id) @@ -738,6 +763,7 @@ async def ws(doc_id: str, ws: WebSocket): "last_access": time.time(), "pw_hash": cached_data.get("pw_hash"), "pw_salt": cached_data.get("pw_salt"), + "pw_iter": cached_data.get("pw_iter"), } # Refuse to create new rooms when at capacity @@ -747,7 +773,8 @@ async def ws(doc_id: str, ws: WebSocket): return room = rooms.setdefault(doc_id, {"text": "", "ver": 0, "peers": set(), "authed_peers": set(), - "last_access": time.time(), "pw_hash": None, "pw_salt": None}) + "last_access": time.time(), "pw_hash": None, "pw_salt": None, + "pw_iter": None}) room["peers"].add(ws) # Update access time @@ -783,8 +810,12 @@ async def ws(doc_id: str, ws: WebSocket): "type": "init", "text": room["text"], "ver": room["ver"], "protected": False, })) else: + if not check_auth_rate_limit(client_ip): + await ws.send_text(json.dumps({"type": "error", "message": "Too many failed attempts. Try again later."})) + continue + iters = room.get("pw_iter") or LEGACY_PBKDF2_ITERATIONS candidate = hashlib.pbkdf2_hmac( - "sha256", str(data.get("password", "")).encode(), room["pw_salt"], 200_000 + "sha256", str(data.get("password", "")).encode(), room["pw_salt"], iters ) if candidate == room["pw_hash"]: authed = True @@ -794,6 +825,7 @@ async def ws(doc_id: str, ws: WebSocket): "type": "init", "text": room["text"], "ver": room["ver"], "protected": True, })) else: + record_auth_failure(client_ip) await ws.send_text(json.dumps({"type": "error", "message": "Wrong password"})) continue @@ -827,11 +859,13 @@ async def ws(doc_id: str, ws: WebSocket): pw = str(data.get("password", "")) if pw: salt = os.urandom(16) - room["pw_hash"] = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, 200_000) + room["pw_hash"] = hashlib.pbkdf2_hmac("sha256", pw.encode(), salt, PBKDF2_ITERATIONS) room["pw_salt"] = salt + room["pw_iter"] = PBKDF2_ITERATIONS else: room["pw_hash"] = None room["pw_salt"] = None + room["pw_iter"] = None save_room_data_to_cache(doc_id, room) await _broadcast(doc_id, {"type": "protected_changed", "protected": room["pw_hash"] is not None})