From abf56914d2e9b2658b29be96811930ceedac9aa7 Mon Sep 17 00:00:00 2001 From: Kyle Javier [kj_sh604] Date: Sat, 4 Apr 2026 02:12:07 -0400 Subject: [squash] refactor: security improvements and sane defaults (#1) --- src/server.py | 387 ++++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 311 insertions(+), 76 deletions(-) (limited to 'src/server.py') diff --git a/src/server.py b/src/server.py index e82406c..bddee31 100644 --- a/src/server.py +++ b/src/server.py @@ -6,7 +6,7 @@ import http.server import json -import os +import ipaddress import re import secrets import signal @@ -21,20 +21,42 @@ from urllib.parse import urlparse, unquote # config -PORT = int(os.environ.get("KJ_CLIPBOARD_PORT", 5555)) -BIND = os.environ.get("KJ_CLIPBOARD_BIND", "0.0.0.0") +PORT = 5555 +BIND = "0.0.0.0" BASE_DIR = Path(__file__).parent.resolve() DB_PATH = BASE_DIR / "data" / "kj-clipboard.db" RANDOM_ID_LENGTH = 40 # random chars after unix epoch MAX_PASTE_SIZE = 67 * 1024 * 1024 // 10 # 6.7 MiB MAX_PASSPHRASE_SIZE = 512 MAX_LANGUAGE_SIZE = 32 -MAX_DECRYPT_SIZE = 3 * 1024 * 1024 # headroom for decrypt requests with long passphrases +# decrypt requests only need id+passphrase json, keep this small against body-flood abuse. +MAX_DECRYPT_SIZE = 16 * 1024 ID_PATTERN = re.compile(r"^[0-9]{10,}[a-f0-9]{40}$") LANGUAGE_PATTERN = re.compile(r"^[a-z0-9_.+#-]{1,32}$") -REQUESTS_PER_WINDOW = int(os.environ.get("KJ_CLIPBOARD_RATE_LIMIT", "60")) -RATE_WINDOW_SECONDS = int(os.environ.get("KJ_CLIPBOARD_RATE_WINDOW", "60")) +# per-ip post limit (create/decrypt). +REQUESTS_PER_WINDOW = 150 +RATE_WINDOW_SECONDS = 60 + +# sqlite concurrency defaults +SQLITE_BUSY_TIMEOUT_MS = 2500 +SQLITE_WRITE_RETRIES = 5 +SQLITE_READ_RETRIES = 3 +SQLITE_RETRY_BASE_MS = 20 +SQLITE_CACHE_SIZE_KIB = 6144 +SQLITE_MMAP_SIZE_BYTES = 134217728 +SQLITE_WAL_AUTOCHECKPOINT_PAGES = 2000 +SQLITE_JOURNAL_SIZE_LIMIT_BYTES = 67108864 +SQLITE_SYNCHRONOUS = "NORMAL" # OFF | NORMAL | FULL | EXTRA + +# accept short bursts without immediately refusing tcp connections. +HTTP_REQUEST_QUEUE_SIZE = 64 + +TRUST_PROXY = False +TRUSTED_PROXY_IPS = {"127.0.0.1", "::1"} +# hsts off by default to avoid breaking plain-http setups. +ENABLE_HSTS = False +HSTS_MAX_AGE = 31536000 ALLOWED_LANGUAGES = { "1c", @@ -371,11 +393,50 @@ _rate_state = {} # database +class DatabaseBusyError(RuntimeError): + pass + + +def is_sqlite_busy_error(err): + msg = str(err).lower() + return "database is locked" in msg or "database is busy" in msg + + +def sqlite_retry_sleep(attempt): + delay_ms = SQLITE_RETRY_BASE_MS * (2**attempt) + jitter_ms = secrets.randbelow(SQLITE_RETRY_BASE_MS + 1) + time.sleep(min((delay_ms + jitter_ms) / 1000.0, 1.0)) + + +def open_db(): + conn = sqlite3.connect( + str(DB_PATH), + timeout=SQLITE_BUSY_TIMEOUT_MS / 1000.0, + ) + conn.execute(f"PRAGMA busy_timeout={SQLITE_BUSY_TIMEOUT_MS}") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute(f"PRAGMA cache_size=-{SQLITE_CACHE_SIZE_KIB}") + conn.execute("PRAGMA temp_store=MEMORY") + if SQLITE_MMAP_SIZE_BYTES > 0: + try: + conn.execute(f"PRAGMA mmap_size={SQLITE_MMAP_SIZE_BYTES}") + except sqlite3.DatabaseError: + pass + # defense-in-depth: ignore if running on an older sqlite without this pragma. + try: + conn.execute("PRAGMA trusted_schema=OFF") + except sqlite3.DatabaseError: + pass + return conn + + def init_db(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(DB_PATH)) + conn = open_db() conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA synchronous=NORMAL") + conn.execute(f"PRAGMA synchronous={SQLITE_SYNCHRONOUS}") + conn.execute(f"PRAGMA wal_autocheckpoint={SQLITE_WAL_AUTOCHECKPOINT_PAGES}") + conn.execute(f"PRAGMA journal_size_limit={SQLITE_JOURNAL_SIZE_LIMIT_BYTES}") conn.execute( """ CREATE TABLE IF NOT EXISTS pastes ( @@ -404,41 +465,70 @@ def is_valid_paste_id(paste_id): def save_paste(content, language=None, is_code=False, is_encrypted=False): """store a paste in the database, return its id""" - conn = sqlite3.connect(str(DB_PATH)) - try: - for _ in range(5): - paste_id = generate_id() + for write_attempt in range(SQLITE_WRITE_RETRIES + 1): + conn = open_db() + try: + # reserve the write lock early to reduce lock thrash under bursty writes. + conn.execute("BEGIN IMMEDIATE") + for _ in range(5): + paste_id = generate_id() + try: + conn.execute( + "INSERT INTO pastes (id, content, language, is_code, is_encrypted, created_at) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + paste_id, + content, + language, + int(is_code), + int(is_encrypted), + int(time.time()), + ), + ) + conn.commit() + return paste_id + except sqlite3.IntegrityError: + continue + conn.rollback() + raise RuntimeError("failed to generate unique paste id") + except sqlite3.OperationalError as err: try: - conn.execute( - "INSERT INTO pastes (id, content, language, is_code, is_encrypted, created_at) " - "VALUES (?, ?, ?, ?, ?, ?)", - ( - paste_id, - content, - language, - int(is_code), - int(is_encrypted), - int(time.time()), - ), - ) - conn.commit() - return paste_id - except sqlite3.IntegrityError: + conn.rollback() + except sqlite3.DatabaseError: + pass + if is_sqlite_busy_error(err): + if write_attempt >= SQLITE_WRITE_RETRIES: + raise DatabaseBusyError("database is busy; retry shortly") from err + sqlite_retry_sleep(write_attempt) continue - raise RuntimeError("failed to generate unique paste id") - finally: - conn.close() + raise + finally: + conn.close() + + raise DatabaseBusyError("database is busy; retry shortly") def get_paste(paste_id): """retrieve a paste by id, returns dict or None""" - conn = sqlite3.connect(str(DB_PATH)) - conn.row_factory = sqlite3.Row - row = conn.execute("SELECT * FROM pastes WHERE id = ?", (paste_id,)).fetchone() - conn.close() - if row: - return dict(row) - return None + for read_attempt in range(SQLITE_READ_RETRIES + 1): + conn = open_db() + try: + conn.row_factory = sqlite3.Row + row = conn.execute("SELECT * FROM pastes WHERE id = ?", (paste_id,)).fetchone() + if row: + return sanitize_paste_record(dict(row)) + return None + except sqlite3.OperationalError as err: + if is_sqlite_busy_error(err): + if read_attempt >= SQLITE_READ_RETRIES: + raise DatabaseBusyError("database is busy; retry shortly") from err + sqlite_retry_sleep(read_attempt) + continue + raise + finally: + conn.close() + + raise DatabaseBusyError("database is busy; retry shortly") # mojicrypt helpers @@ -487,26 +577,36 @@ def landing_page(): return (BASE_DIR / "index.html").read_text(encoding="utf-8") -def paste_page(paste): +def paste_page(paste, csp_nonce): """render the view page for a paste""" paste_id = paste["id"] content = paste["content"] - is_code = paste["is_code"] - is_encrypted = paste["is_encrypted"] - language = paste["language"] or "" - created = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(paste["created_at"])) + is_code = coerce_bool_flag(paste.get("is_code", 0)) + is_encrypted = coerce_bool_flag(paste.get("is_encrypted", 0)) + language = normalize_language(paste.get("language", "")) or "" + created = time.strftime("%Y-%m-%d %H:%M UTC", time.gmtime(int(paste["created_at"]))) + + escaped_paste_id = html_escape(paste_id) + escaped_paste_id_attr = html_escape_attr(paste_id) + escaped_language = html_escape(language) + + paste_id_json = json.dumps(paste_id) + code_lang_class = f"language-{language}" if language else "" + code_lang_class_json = json.dumps(code_lang_class) + + script_nonce_attr = f' nonce="{html_escape_attr(csp_nonce)}"' if is_encrypted: # show decrypt form instead of content content_block = f"""
- -""" + highlight_js = f""" + """ return f""" @@ -578,22 +683,22 @@ def paste_page(paste): -