Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from services.link_checker import check_url, limiter as link_limiter
from services.phone_checker import check_phone, limiter as phone_limiter
from services.abuse_reporter import generate_report, get_all_templates
from services.breach_checker import check_password
from config import ADMIN_TOKEN, BLACKLIST_ENTRY_TYPES

db = Database()
Expand Down Expand Up @@ -195,6 +196,14 @@ async def api_stats(_: None = Depends(require_admin_token)):

# === Rate Limit Status ===

@app.get("/api/breach/password")
async def api_check_password(
password: str = Query(..., min_length=1, max_length=256, description="Password to check"),
_: None = Depends(require_admin_token),
):
return await check_password(password)


@app.get("/api/ratelimit")
async def api_ratelimit_status(_: None = Depends(require_admin_token)):
return {
Expand Down
46 changes: 46 additions & 0 deletions backend/services/breach_checker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Breach checker — HIBP k-anonymity for passwords (no account needed)."""
import hashlib
import httpx
from typing import Dict


async def check_password(password: str) -> Dict:
"""Check if a password appears in known breaches via HIBP k-anonymity.

Only the first 5 chars of the SHA-1 hash are sent to the API.
The full hash never leaves the client.
"""
if not password:
return {"error": "empty password"}

sha1 = hashlib.sha1(password.encode("utf-8")).hexdigest().upper()
prefix = sha1[:5]
suffix = sha1[5:]

try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"https://api.pwnedpasswords.com/range/{prefix}",
headers={"Add-Padding": "true"},
)
if resp.status_code != 200:
return {"error": f"HIBP returned {resp.status_code}"}
except httpx.HTTPError as e:
return {"error": f"network error: {e}"}

count = 0
for line in resp.text.splitlines():
parts = line.strip().split(":")
if len(parts) == 2 and parts[0] == suffix:
count = int(parts[1])
break

return {
"breached": count > 0,
"count": count,
"message": (
f"Password found in {count} breach(es) — change it immediately!"
if count > 0
else "Password not found in known breaches."
),
}
Loading