sec: CHANGE pbkdf2_hmac iteration from 200k to 600k and password rate limit 10 per 60 seconds #28
This commit is contained in:
parent
e9d195a2ec
commit
5eeddecf7d
1 changed files with 44 additions and 10 deletions
54
app.py
54
app.py
|
|
@ -38,15 +38,24 @@ def get_client_ip(conn) -> str:
|
||||||
# Valkey/Redis client (initialized later if enabled)
|
# Valkey/Redis client (initialized later if enabled)
|
||||||
redis_client = None
|
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] = {}
|
rooms: dict[str, dict] = {}
|
||||||
|
|
||||||
# Rate limiting: {ip: [timestamp, timestamp, ...]}
|
# Rate limiting: {ip: [timestamp, timestamp, ...]}
|
||||||
rate_limits: dict[str, list] = defaultdict(list)
|
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}
|
# Connection tracking: {ip: connection_count}
|
||||||
connections_per_ip: dict[str, int] = defaultdict(int)
|
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:
|
def random_id(n: int = 8) -> str:
|
||||||
alphabet = string.ascii_lowercase + string.digits
|
alphabet = string.ascii_lowercase + string.digits
|
||||||
return "".join(secrets.choice(alphabet) for _ in range(n))
|
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()),
|
"last_access": room.get("last_access", time.time()),
|
||||||
"pw_hash": room["pw_hash"].hex() if room.get("pw_hash") else None,
|
"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_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))
|
redis_client.setex(f"room:{doc_id}", RETENTION_HOURS * 3600, json.dumps(data))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -137,18 +147,27 @@ def cleanup_old_rooms():
|
||||||
def check_rate_limit(client_ip: str) -> bool:
|
def check_rate_limit(client_ip: str) -> bool:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
hour_ago = now - 3600
|
hour_ago = now - 3600
|
||||||
|
|
||||||
# Clean old entries
|
# Clean old entries
|
||||||
rate_limits[client_ip] = [t for t in rate_limits[client_ip] if t > hour_ago]
|
rate_limits[client_ip] = [t for t in rate_limits[client_ip] if t > hour_ago]
|
||||||
|
|
||||||
# Check limit (50 per hour)
|
# Check limit (50 per hour)
|
||||||
if len(rate_limits[client_ip]) >= 50:
|
if len(rate_limits[client_ip]) >= 50:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Add current request
|
# Add current request
|
||||||
rate_limits[client_ip].append(now)
|
rate_limits[client_ip].append(now)
|
||||||
return True
|
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 = """<!doctype html>
|
HTML = """<!doctype html>
|
||||||
<meta charset="utf-8"/>
|
<meta charset="utf-8"/>
|
||||||
<title>aukpad</title>
|
<title>aukpad</title>
|
||||||
|
|
@ -640,7 +659,7 @@ async def create_pad_with_content(request: Request):
|
||||||
|
|
||||||
doc_id = random_id()
|
doc_id = random_id()
|
||||||
rooms[doc_id] = {"text": text_content, "ver": 1, "peers": set(), "authed_peers": set(),
|
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 to cache if enabled
|
||||||
save_room_data_to_cache(doc_id, rooms[doc_id])
|
save_room_data_to_cache(doc_id, rooms[doc_id])
|
||||||
|
|
@ -658,7 +677,7 @@ def pad(doc_id: str):
|
||||||
return HTMLResponse(HTML)
|
return HTMLResponse(HTML)
|
||||||
|
|
||||||
@app.get("/{doc_id}/raw", response_class=PlainTextResponse)
|
@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):
|
if not is_valid_doc_id(doc_id):
|
||||||
raise HTTPException(status_code=400, detail="Invalid pad ID")
|
raise HTTPException(status_code=400, detail="Invalid pad ID")
|
||||||
# Load room into memory if needed
|
# Load room into memory if needed
|
||||||
|
|
@ -673,6 +692,7 @@ def get_raw_pad_content(doc_id: str, pw: str = ""):
|
||||||
"last_access": time.time(),
|
"last_access": time.time(),
|
||||||
"pw_hash": cached_data.get("pw_hash"),
|
"pw_hash": cached_data.get("pw_hash"),
|
||||||
"pw_salt": cached_data.get("pw_salt"),
|
"pw_salt": cached_data.get("pw_salt"),
|
||||||
|
"pw_iter": cached_data.get("pw_iter"),
|
||||||
}
|
}
|
||||||
|
|
||||||
if doc_id not in rooms:
|
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 room.get("pw_hash"):
|
||||||
if not pw:
|
if not pw:
|
||||||
raise HTTPException(status_code=403, detail="This pad is password protected. Use ?pw=<password>")
|
raise HTTPException(status_code=403, detail="This pad is password protected. Use ?pw=<password>")
|
||||||
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"]:
|
if candidate != room["pw_hash"]:
|
||||||
|
record_auth_failure(client_ip)
|
||||||
raise HTTPException(status_code=403, detail="Wrong password")
|
raise HTTPException(status_code=403, detail="Wrong password")
|
||||||
|
|
||||||
update_room_access_time(doc_id)
|
update_room_access_time(doc_id)
|
||||||
|
|
@ -738,6 +763,7 @@ async def ws(doc_id: str, ws: WebSocket):
|
||||||
"last_access": time.time(),
|
"last_access": time.time(),
|
||||||
"pw_hash": cached_data.get("pw_hash"),
|
"pw_hash": cached_data.get("pw_hash"),
|
||||||
"pw_salt": cached_data.get("pw_salt"),
|
"pw_salt": cached_data.get("pw_salt"),
|
||||||
|
"pw_iter": cached_data.get("pw_iter"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Refuse to create new rooms when at capacity
|
# Refuse to create new rooms when at capacity
|
||||||
|
|
@ -747,7 +773,8 @@ async def ws(doc_id: str, ws: WebSocket):
|
||||||
return
|
return
|
||||||
|
|
||||||
room = rooms.setdefault(doc_id, {"text": "", "ver": 0, "peers": set(), "authed_peers": set(),
|
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)
|
room["peers"].add(ws)
|
||||||
|
|
||||||
# Update access time
|
# 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,
|
"type": "init", "text": room["text"], "ver": room["ver"], "protected": False,
|
||||||
}))
|
}))
|
||||||
else:
|
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(
|
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"]:
|
if candidate == room["pw_hash"]:
|
||||||
authed = True
|
authed = True
|
||||||
|
|
@ -794,6 +825,7 @@ async def ws(doc_id: str, ws: WebSocket):
|
||||||
"type": "init", "text": room["text"], "ver": room["ver"], "protected": True,
|
"type": "init", "text": room["text"], "ver": room["ver"], "protected": True,
|
||||||
}))
|
}))
|
||||||
else:
|
else:
|
||||||
|
record_auth_failure(client_ip)
|
||||||
await ws.send_text(json.dumps({"type": "error", "message": "Wrong password"}))
|
await ws.send_text(json.dumps({"type": "error", "message": "Wrong password"}))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -827,11 +859,13 @@ async def ws(doc_id: str, ws: WebSocket):
|
||||||
pw = str(data.get("password", ""))
|
pw = str(data.get("password", ""))
|
||||||
if pw:
|
if pw:
|
||||||
salt = os.urandom(16)
|
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_salt"] = salt
|
||||||
|
room["pw_iter"] = PBKDF2_ITERATIONS
|
||||||
else:
|
else:
|
||||||
room["pw_hash"] = None
|
room["pw_hash"] = None
|
||||||
room["pw_salt"] = None
|
room["pw_salt"] = None
|
||||||
|
room["pw_iter"] = None
|
||||||
save_room_data_to_cache(doc_id, room)
|
save_room_data_to_cache(doc_id, room)
|
||||||
await _broadcast(doc_id, {"type": "protected_changed", "protected": room["pw_hash"] is not None})
|
await _broadcast(doc_id, {"type": "protected_changed", "protected": room["pw_hash"] is not None})
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue