From cc886d467583412dd498b00ee0e267ae695414e8 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Thu, 16 Oct 2025 01:38:31 +0200 Subject: [PATCH 1/4] ADD deletion function with POST request and token in query --- main.py | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 118 insertions(+), 8 deletions(-) diff --git a/main.py b/main.py index f535adf..4dafcdc 100644 --- a/main.py +++ b/main.py @@ -107,6 +107,50 @@ def generate_random_path(length: int = None) -> str: return ''.join(secrets.choice(alphabet) for _ in range(length)) +def generate_deletion_token() -> str: + """Generate a secure deletion token""" + return secrets.token_urlsafe(32) + + +def save_metadata(paste_id: str, deletion_token: str, client_ip: str) -> None: + """Save paste metadata to JSON file""" + meta_path = UPLOAD_DIR / f"{paste_id}.meta" + metadata = { + "deletion_token": deletion_token, + "created_at": datetime.utcnow().isoformat() + "Z", + "client_ip": client_ip + } + with open(meta_path, 'w') as f: + json.dump(metadata, f) + + +def load_metadata(paste_id: str) -> Optional[dict]: + """Load paste metadata from JSON file""" + meta_path = UPLOAD_DIR / f"{paste_id}.meta" + if not meta_path.exists(): + return None + try: + with open(meta_path, 'r') as f: + return json.load(f) + except: + return None + + +def delete_paste(paste_id: str) -> bool: + """Delete paste and its metadata""" + paste_path = UPLOAD_DIR / paste_id + meta_path = UPLOAD_DIR / f"{paste_id}.meta" + + deleted = False + if paste_path.exists(): + paste_path.unlink() + deleted = True + if meta_path.exists(): + meta_path.unlink() + + return deleted + + def validate_upload_token(request: Request) -> bool: """Validate upload token if authentication is enabled""" if not UPLOAD_TOKENS: @@ -189,16 +233,24 @@ async def upload_text(request: Request, authorized: bool = Depends(validate_uplo file_path = UPLOAD_DIR / random_path try: + # Generate deletion token + deletion_token = generate_deletion_token() + + # Save paste content with open(file_path, 'w', encoding='utf-8') as f: f.write(content) + # Save metadata with deletion token + save_metadata(random_path, deletion_token, client_ip) + log("INFO", "paste_created", paste_id=random_path, client_ip=client_ip, user_agent=user_agent, size_bytes=len(content)) - return f"{BASEURL}/{random_path}\n" + # Return URL and deletion token + return f"{BASEURL}/{random_path}\nDelete: {BASEURL}/{random_path}?token={deletion_token}\n" except Exception as e: log("ERROR", "upload_failed", @@ -208,15 +260,16 @@ async def upload_text(request: Request, authorized: bool = Depends(validate_uplo error=str(e)) raise HTTPException(status_code=500, detail="Failed to save file") -@app.get("/{file_path}", response_class=PlainTextResponse) -async def get_file(file_path: str, request: Request): - if not file_path.isalnum(): - raise HTTPException(status_code=404, detail="File not found") +@app.get("/{paste_id}", response_class=PlainTextResponse) +async def get_file(paste_id: str, request: Request, token: Optional[str] = None): + """Get paste content or delete if token is provided""" + if not paste_id.isalnum(): + raise HTTPException(status_code=404, detail="Paste not found") - file_location = UPLOAD_DIR / file_path + file_location = UPLOAD_DIR / paste_id if not file_location.exists() or not file_location.is_file(): - raise HTTPException(status_code=404, detail="File not found") + raise HTTPException(status_code=404, detail="Paste not found") try: with open(file_location, 'r', encoding='utf-8') as f: @@ -225,10 +278,67 @@ async def get_file(file_path: str, request: Request): return content except Exception as e: log("ERROR", "download_failed", - paste_id=file_path, + paste_id=paste_id, error=str(e)) raise HTTPException(status_code=500, detail="Failed to read file") + +@app.post("/{paste_id}", response_class=PlainTextResponse) +async def delete_paste_endpoint(paste_id: str, request: Request, token: Optional[str] = None): + """Delete a paste using its deletion token""" + client_ip = get_real_ip(request) + user_agent = request.headers.get("User-Agent", "unknown") + + # Validate paste_id format + if not paste_id.isalnum(): + raise HTTPException(status_code=404, detail="Paste not found") + + # Check if token is provided (query param or header) + deletion_token = token or request.headers.get("X-Delete-Token") + if not deletion_token: + log("WARNING", "deletion_failed", + paste_id=paste_id, + client_ip=client_ip, + user_agent=user_agent, + reason="missing_token") + raise HTTPException(status_code=400, detail="Deletion token required") + + # Load metadata + metadata = load_metadata(paste_id) + if not metadata: + log("WARNING", "deletion_failed", + paste_id=paste_id, + client_ip=client_ip, + user_agent=user_agent, + reason="metadata_not_found") + raise HTTPException(status_code=404, detail="Paste not found") + + # Verify deletion token using constant-time comparison + if not secrets.compare_digest(deletion_token, metadata.get("deletion_token", "")): + log("WARNING", "deletion_failed", + paste_id=paste_id, + client_ip=client_ip, + user_agent=user_agent, + reason="invalid_token") + raise HTTPException(status_code=403, detail="Invalid deletion token") + + # Delete the paste and metadata + if delete_paste(paste_id): + log("INFO", "paste_deleted", + paste_id=paste_id, + client_ip=client_ip, + user_agent=user_agent, + deletion_method="user_requested") + return "Paste deleted successfully\n" + else: + log("ERROR", "deletion_failed", + paste_id=paste_id, + client_ip=client_ip, + user_agent=user_agent, + reason="file_not_found") + raise HTTPException(status_code=500, detail="Failed to delete paste") + + @app.get("/", response_class=PlainTextResponse) async def root(): # Build authentication notice and examples if tokens are configured From 9ff85a4e7ef720ebaeb29d6f65ae9a5c62da1c32 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Thu, 16 Oct 2025 01:55:28 +0200 Subject: [PATCH 2/4] ADD path validation for deletions, change to PSOT requests only, some error handling --- main.py | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/main.py b/main.py index 4dafcdc..5bc9f57 100644 --- a/main.py +++ b/main.py @@ -112,9 +112,32 @@ def generate_deletion_token() -> str: return secrets.token_urlsafe(32) +def validate_paste_id(paste_id: str) -> bool: + """Validate paste ID to prevent path traversal and other attacks""" + # Must be alphanumeric only + if not paste_id.isalnum(): + return False + # Reasonable length check (prevent extremely long IDs) + if len(paste_id) > 64: + return False + # Must not be empty + if len(paste_id) == 0: + return False + return True + + def save_metadata(paste_id: str, deletion_token: str, client_ip: str) -> None: """Save paste metadata to JSON file""" + # Validate paste_id before file operations + if not validate_paste_id(paste_id): + raise ValueError("Invalid paste ID") + meta_path = UPLOAD_DIR / f"{paste_id}.meta" + + # Ensure resolved path is within UPLOAD_DIR (prevent path traversal) + if not str(meta_path.resolve()).startswith(str(UPLOAD_DIR.resolve())): + raise ValueError("Invalid paste ID: path traversal detected") + metadata = { "deletion_token": deletion_token, "created_at": datetime.utcnow().isoformat() + "Z", @@ -126,7 +149,16 @@ def save_metadata(paste_id: str, deletion_token: str, client_ip: str) -> None: def load_metadata(paste_id: str) -> Optional[dict]: """Load paste metadata from JSON file""" + # Validate paste_id before file operations + if not validate_paste_id(paste_id): + return None + meta_path = UPLOAD_DIR / f"{paste_id}.meta" + + # Ensure resolved path is within UPLOAD_DIR (prevent path traversal) + if not str(meta_path.resolve()).startswith(str(UPLOAD_DIR.resolve())): + return None + if not meta_path.exists(): return None try: @@ -138,9 +170,19 @@ def load_metadata(paste_id: str) -> Optional[dict]: def delete_paste(paste_id: str) -> bool: """Delete paste and its metadata""" + # Validate paste_id before file operations + if not validate_paste_id(paste_id): + return False + paste_path = UPLOAD_DIR / paste_id meta_path = UPLOAD_DIR / f"{paste_id}.meta" + # Ensure resolved paths are within UPLOAD_DIR (prevent path traversal) + if not str(paste_path.resolve()).startswith(str(UPLOAD_DIR.resolve())): + return False + if not str(meta_path.resolve()).startswith(str(UPLOAD_DIR.resolve())): + return False + deleted = False if paste_path.exists(): paste_path.unlink() @@ -301,7 +343,16 @@ async def delete_paste_endpoint(paste_id: str, request: Request, token: Optional client_ip=client_ip, user_agent=user_agent, reason="missing_token") - raise HTTPException(status_code=400, detail="Deletion token required") + raise HTTPException(status_code=404, detail="Not found") + + # Validate token length (prevent abuse with extremely long tokens) + if len(deletion_token) > 128: + log("WARNING", "deletion_failed", + paste_id=paste_id, + client_ip=client_ip, + user_agent=user_agent, + reason="invalid_token_length") + raise HTTPException(status_code=403, detail="Deletion failed") # Load metadata metadata = load_metadata(paste_id) @@ -311,7 +362,7 @@ async def delete_paste_endpoint(paste_id: str, request: Request, token: Optional client_ip=client_ip, user_agent=user_agent, reason="metadata_not_found") - raise HTTPException(status_code=404, detail="Paste not found") + raise HTTPException(status_code=404, detail="Deletion failed") # Verify deletion token using constant-time comparison if not secrets.compare_digest(deletion_token, metadata.get("deletion_token", "")): @@ -320,7 +371,7 @@ async def delete_paste_endpoint(paste_id: str, request: Request, token: Optional client_ip=client_ip, user_agent=user_agent, reason="invalid_token") - raise HTTPException(status_code=403, detail="Invalid deletion token") + raise HTTPException(status_code=403, detail="Deletion failed") # Delete the paste and metadata if delete_paste(paste_id): From 842cc1606d3e7cb7684de82553aad2bba11f1689 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Thu, 16 Oct 2025 01:59:10 +0200 Subject: [PATCH 3/4] ADD examples for deletion and add remark for cli output --- README.md | 5 +++++ main.py | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a6332ea..b418d5c 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,11 @@ Invoke-RestMethod -Uri "https://linedump.com/{path}" Invoke-RestMethod -Uri "https://linedump.com/{path}" -OutFile "filename.txt" # save to file + █ Delete: + +curl -X POST "https://linedump.com/{path}?token={deletion_token}" # delete paste + + ██ Encryption Examples with curl ██ diff --git a/main.py b/main.py index 5bc9f57..dad094b 100644 --- a/main.py +++ b/main.py @@ -292,7 +292,7 @@ async def upload_text(request: Request, authorized: bool = Depends(validate_uplo size_bytes=len(content)) # Return URL and deletion token - return f"{BASEURL}/{random_path}\nDelete: {BASEURL}/{random_path}?token={deletion_token}\n" + return f"{BASEURL}/{random_path}\nDelete with HTTP POST: {BASEURL}/{random_path}?token={deletion_token}\n" except Exception as e: log("ERROR", "upload_failed", @@ -472,6 +472,11 @@ Invoke-RestMethod -Uri "{BASEURL}/{{path}}" # Invoke-RestMethod -Uri "{BASEURL}/{{path}}" -OutFile "filename.txt" # save to file + █ Delete: + +curl -X POST "{BASEURL}/{{path}}?token={{deletion_token}}" # delete paste + + ██ Encryption Examples with curl ██ From c7446d0f4806c90da23a8f6efe880e32fc18e022 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Thu, 16 Oct 2025 12:32:06 +0200 Subject: [PATCH 4/4] FIX typo in simple docker example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b418d5c..ece1a1e 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ Invoke-RestMethod -Uri "https://linedump.com/" -Headers @{"Authorization"="Beare **Simple / Testing** -`docker run -d -p 127.0.0.1:8000:8000 -v /path/to/upload:/app/upload git.uphillsecurity.com/cf7/linedump:latest` +`docker run -d -p 127.0.0.1:8000:8000 -v /path/to/uploads:/app/uploads git.uphillsecurity.com/cf7/linedump:latest` Open `http://127.0.0.1:8000`