sec: FIX: doc_id precheck to prevent app DoS #26
This commit is contained in:
parent
44dc0ee6b6
commit
25b7b21412
2 changed files with 26 additions and 4 deletions
|
|
@ -23,7 +23,7 @@ The goal is to keep it simple! For feature-rich solutions please check out [hedg
|
||||||
**Available**:
|
**Available**:
|
||||||
- live collab notepad
|
- live collab notepad
|
||||||
- line numbers
|
- line numbers
|
||||||
- custom path `{pad_id}` for more privacy
|
- custom path `{pad_id}` for more privacy (1–64 chars, `[a-zA-Z0-9_-]`)
|
||||||
- optional caching with valkey/redis
|
- optional caching with valkey/redis
|
||||||
- pad creation with HTTP post requests with curl (see *Usage*)
|
- pad creation with HTTP post requests with curl (see *Usage*)
|
||||||
- `{pad_id}/raw` HTTP endpoint
|
- `{pad_id}/raw` HTTP endpoint
|
||||||
|
|
@ -117,6 +117,7 @@ The following environment variables can be configured:
|
||||||
| `MAX_TEXT_SIZE` | `5` | Maximum text size in MB (5MB default) |
|
| `MAX_TEXT_SIZE` | `5` | Maximum text size in MB (5MB default) |
|
||||||
| `MAX_CONNECTIONS_PER_IP` | `10` | Maximum concurrent connections per IP address |
|
| `MAX_CONNECTIONS_PER_IP` | `10` | Maximum concurrent connections per IP address |
|
||||||
| `RETENTION_HOURS` | `48` | How long to retain pads in hours after last access |
|
| `RETENTION_HOURS` | `48` | How long to retain pads in hours after last access |
|
||||||
|
| `MAX_ROOMS` | `10000` | Maximum number of pads kept in memory; new pads are refused (WS close 1008) when full until cleanup reclaims space |
|
||||||
| `DESCRIPTION` | `powered by aukpad.com` | Instance description shown on info page |
|
| `DESCRIPTION` | `powered by aukpad.com` | Instance description shown on info page |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
27
app.py
27
app.py
|
|
@ -1,7 +1,7 @@
|
||||||
# aukpad.py
|
# aukpad.py
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request, HTTPException
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse, FileResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse, FileResponse
|
||||||
import json, secrets, string, time, os, threading, asyncio, hashlib
|
import json, re, secrets, string, time, os, threading, asyncio, hashlib
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
|
@ -14,8 +14,14 @@ VALKEY_URL = os.getenv("VALKEY_URL", "redis://localhost:6379/0")
|
||||||
MAX_TEXT_SIZE = int(os.getenv("MAX_TEXT_SIZE", "5")) * 1024 * 1024 # 5MB default
|
MAX_TEXT_SIZE = int(os.getenv("MAX_TEXT_SIZE", "5")) * 1024 * 1024 # 5MB default
|
||||||
MAX_CONNECTIONS_PER_IP = int(os.getenv("MAX_CONNECTIONS_PER_IP", "10"))
|
MAX_CONNECTIONS_PER_IP = int(os.getenv("MAX_CONNECTIONS_PER_IP", "10"))
|
||||||
RETENTION_HOURS = int(os.getenv("RETENTION_HOURS", "48")) # Default 48 hours
|
RETENTION_HOURS = int(os.getenv("RETENTION_HOURS", "48")) # Default 48 hours
|
||||||
|
MAX_ROOMS = int(os.getenv("MAX_ROOMS", "10000"))
|
||||||
DESCRIPTION = os.getenv("DESCRIPTION", "powered by aukpad.com")
|
DESCRIPTION = os.getenv("DESCRIPTION", "powered by aukpad.com")
|
||||||
|
|
||||||
|
DOC_ID_RE = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
|
||||||
|
|
||||||
|
def is_valid_doc_id(doc_id: str) -> bool:
|
||||||
|
return bool(DOC_ID_RE.match(doc_id))
|
||||||
|
|
||||||
# Valkey/Redis client (initialized later if enabled)
|
# Valkey/Redis client (initialized later if enabled)
|
||||||
redis_client = None
|
redis_client = None
|
||||||
|
|
||||||
|
|
@ -632,12 +638,16 @@ async def create_pad_with_content(request: Request):
|
||||||
|
|
||||||
@app.get("/{doc_id}/", response_class=HTMLResponse)
|
@app.get("/{doc_id}/", response_class=HTMLResponse)
|
||||||
def pad(doc_id: str):
|
def pad(doc_id: str):
|
||||||
|
if not is_valid_doc_id(doc_id):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid pad ID")
|
||||||
# Update access time when pad is accessed
|
# Update access time when pad is accessed
|
||||||
update_room_access_time(doc_id)
|
update_room_access_time(doc_id)
|
||||||
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, 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
|
# Load room into memory if needed
|
||||||
if doc_id not in rooms:
|
if doc_id not in rooms:
|
||||||
cached_data = get_room_data_from_cache(doc_id)
|
cached_data = get_room_data_from_cache(doc_id)
|
||||||
|
|
@ -687,14 +697,19 @@ async def _broadcast(doc_id: str, message: dict, exclude: WebSocket | None = Non
|
||||||
|
|
||||||
@app.websocket("/ws/{doc_id}")
|
@app.websocket("/ws/{doc_id}")
|
||||||
async def ws(doc_id: str, ws: WebSocket):
|
async def ws(doc_id: str, ws: WebSocket):
|
||||||
|
# Validate doc_id format
|
||||||
|
if not is_valid_doc_id(doc_id):
|
||||||
|
await ws.close(code=1008, reason="Invalid pad ID")
|
||||||
|
return
|
||||||
|
|
||||||
# Get client IP for connection limiting
|
# Get client IP for connection limiting
|
||||||
client_ip = ws.client.host if ws.client else "unknown"
|
client_ip = ws.client.host if ws.client else "unknown"
|
||||||
|
|
||||||
# Check connection limit per IP
|
# Check connection limit per IP
|
||||||
if connections_per_ip[client_ip] >= MAX_CONNECTIONS_PER_IP:
|
if connections_per_ip[client_ip] >= MAX_CONNECTIONS_PER_IP:
|
||||||
await ws.close(code=1008, reason="Too many connections from this IP")
|
await ws.close(code=1008, reason="Too many connections from this IP")
|
||||||
return
|
return
|
||||||
|
|
||||||
await ws.accept()
|
await ws.accept()
|
||||||
connections_per_ip[client_ip] += 1
|
connections_per_ip[client_ip] += 1
|
||||||
|
|
||||||
|
|
@ -712,6 +727,12 @@ async def ws(doc_id: str, ws: WebSocket):
|
||||||
"pw_salt": cached_data.get("pw_salt"),
|
"pw_salt": cached_data.get("pw_salt"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Refuse to create new rooms when at capacity
|
||||||
|
if doc_id not in rooms and len(rooms) >= MAX_ROOMS:
|
||||||
|
await ws.close(code=1008, reason="Server at capacity")
|
||||||
|
connections_per_ip[client_ip] = max(0, connections_per_ip[client_ip] - 1)
|
||||||
|
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})
|
||||||
room["peers"].add(ws)
|
room["peers"].add(ws)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue