From d77c14b3287cfb6e2c5b06548e4410e3bfd7d45d Mon Sep 17 00:00:00 2001 From: Viktor Date: Tue, 19 May 2026 11:17:01 +0000 Subject: [PATCH] fix: validate Content-Length + add paste expiry/TTL Fixes #1 and #2: 1. Content-Length validation (security): - Reject negative Content-Length values that cause rfile.read(-1) to hang - Handle non-integer Content-Length gracefully 2. Paste expiry / TTL: - Add expires_at column to pastes table - Default TTL: 30 days (configurable via --max-age or FORGE_PASTE_MAX_AGE) - Opportunistic cleanup on each paste creation (at most once/hour) - Expired pastes excluded from reads immediately - Auto-migration for existing databases (backfills expiry on existing pastes) - Expiry info shown in paste viewer UI - --max-age 0 disables expiry entirely Closes #1 Closes #2 --- forge-paste.py | 76 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 68 insertions(+), 8 deletions(-) diff --git a/forge-paste.py b/forge-paste.py index 7485af8..4d21f19 100644 --- a/forge-paste.py +++ b/forge-paste.py @@ -4,7 +4,7 @@ Forge Paste — A lightweight, self-hosted paste server. Hastebin-compatible API with a clean web UI. Usage: - python3 forge-paste.py [--port PORT] [--host HOST] [--db PATH] + python3 forge-paste.py [--port PORT] [--host HOST] [--db PATH] [--max-age DAYS] API: POST /api/paste — raw text body → {"key": "abc123", "url": "..."} @@ -31,6 +31,9 @@ DB_PATH = os.environ.get("FORGE_PASTE_DB", "/var/lib/forge-paste/pastes.db") HOST = os.environ.get("FORGE_PASTE_HOST", "127.0.0.1") PORT = int(os.environ.get("FORGE_PASTE_PORT", "7890")) MAX_SIZE = 512 * 1024 # 512KB +MAX_AGE_DAYS = int(os.environ.get("FORGE_PASTE_MAX_AGE", "30")) # Default 30 days +_CLEANUP_INTERVAL = 3600 # Run cleanup at most once per hour +_last_cleanup = 0 def get_db(): """Get a thread-local database connection.""" @@ -52,27 +55,60 @@ def init_db(): language TEXT, created_at REAL NOT NULL DEFAULT (unixepoch()), views INTEGER NOT NULL DEFAULT 0, - ip TEXT + ip TEXT, + expires_at REAL ) """) db.execute("CREATE INDEX IF NOT EXISTS idx_slug ON pastes(slug)") db.execute("CREATE INDEX IF NOT EXISTS idx_created ON pastes(created_at)") + db.execute("CREATE INDEX IF NOT EXISTS idx_expires ON pastes(expires_at)") db.commit() + + # Migrate: add expires_at column if missing (existing installs) + cols = [row[1] for row in db.execute("PRAGMA table_info(pastes)").fetchall()] + if "expires_at" not in cols: + db.execute("ALTER TABLE pastes ADD COLUMN expires_at REAL") + # Backfill existing pastes: expire them MAX_AGE_DAYS from creation + db.execute( + "UPDATE pastes SET expires_at = created_at + ? WHERE expires_at IS NULL", + (MAX_AGE_DAYS * 86400,), + ) + db.commit() + db.close() def generate_slug(length=8): chars = string.ascii_lowercase + string.digits return ''.join(random.choices(chars, k=length)) +def cleanup_expired(): + """Delete expired pastes. Runs at most once per _CLEANUP_INTERVAL.""" + global _last_cleanup + now = time.time() + if now - _last_cleanup < _CLEANUP_INTERVAL: + return + _last_cleanup = now + db = get_db() + deleted = db.execute( + "DELETE FROM pastes WHERE expires_at IS NOT NULL AND expires_at < ?", (now,) + ).rowcount + if deleted: + db.execute("PRAGMA wal_checkpoint(TRUNCATE)") + db.commit() + db.close() + def create_paste(content, language=None, ip=None): + cleanup_expired() # Opportunistic cleanup db = get_db() slug = generate_slug() # Ensure unique while db.execute("SELECT 1 FROM pastes WHERE slug=?", (slug,)).fetchone(): slug = generate_slug() + now = time.time() + expires_at = now + (MAX_AGE_DAYS * 86400) if MAX_AGE_DAYS > 0 else None db.execute( - "INSERT INTO pastes (slug, content, language, created_at, views, ip) VALUES (?, ?, ?, ?, 0, ?)", - (slug, content, language, time.time(), ip) + "INSERT INTO pastes (slug, content, language, created_at, views, ip, expires_at) VALUES (?, ?, ?, ?, 0, ?, ?)", + (slug, content, language, now, ip, expires_at) ) db.commit() db.close() @@ -80,7 +116,11 @@ def create_paste(content, language=None, ip=None): def get_paste(slug): db = get_db() - row = db.execute("SELECT * FROM pastes WHERE slug=?", (slug,)).fetchone() + # Exclude expired pastes from reads + row = db.execute( + "SELECT * FROM pastes WHERE slug=? AND (expires_at IS NULL OR expires_at > ?)", + (slug, time.time()), + ).fetchone() if row: db.execute("UPDATE pastes SET views = views + 1 WHERE slug=?", (slug,)) db.commit() @@ -183,6 +223,19 @@ def page_viewer(paste, host, is_new=False): v = paste["views"] slug = paste["slug"] url = f"{host}/{slug}" + expires_at = paste.get("expires_at") + if expires_at: + expires_in = expires_at - time.time() + if expires_in > 86400: + expiry_text = f"expires in {int(expires_in // 86400)}d" + elif expires_in > 3600: + expiry_text = f"expires in {int(expires_in // 3600)}h" + elif expires_in > 0: + expiry_text = "expires soon" + else: + expiry_text = "expired" + else: + expiry_text = "no expiry" if is_new: return f""" @@ -200,7 +253,7 @@ def page_viewer(paste, host, is_new=False): {slug} — Forge Paste{STYLE}
-
{ta} · {v} view{'s' if v != 1 else ''} +
{ta} · {v} view{'s' if v != 1 else ''} · {expiry_text} Raw New
@@ -276,7 +329,12 @@ class PasteHandler(BaseHTTPRequestHandler): self._send(404, page_notfound()) def do_POST(self): - length = int(self.headers.get("Content-Length", 0)) + try: + length = int(self.headers.get("Content-Length", 0)) + except (ValueError, TypeError): + return self._send(400, json.dumps({"error": "Invalid Content-Length"}), "application/json") + if length < 0: + return self._send(400, json.dumps({"error": "Invalid Content-Length"}), "application/json") if length > MAX_SIZE: return self._send(413, json.dumps({"error": "Content too large (max 512KB)"}), "application/json") @@ -301,17 +359,19 @@ class PasteHandler(BaseHTTPRequestHandler): def main(): - global DB_PATH, HOST, PORT + global DB_PATH, HOST, PORT, MAX_AGE_DAYS parser = argparse.ArgumentParser(description="Forge Paste Server") parser.add_argument("--port", type=int, default=PORT, help=f"Port (default: {PORT})") parser.add_argument("--host", default=HOST, help=f"Host (default: {HOST})") parser.add_argument("--db", default=DB_PATH, help=f"Database path (default: {DB_PATH})") + parser.add_argument("--max-age", type=int, default=MAX_AGE_DAYS, help=f"Paste TTL in days, 0 = never expire (default: {MAX_AGE_DAYS})") args = parser.parse_args() DB_PATH = args.db HOST = args.host PORT = args.port + MAX_AGE_DAYS = args.max_age init_db() -- 2.45.3