From 2a7bab59f3cf976cae414eefb9a6aefe45307c1c Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Fri, 10 Oct 2025 21:06:49 +0200 Subject: [PATCH 01/12] ADD auth token for paste creatiog - ref #1 --- README.md | 22 ++++++++++++ main.py | 100 ++++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 104 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 8bd0e1f..41f9e5b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ - save and share content via CLI - up- and download in CLI possible - rate-limits +- optional auth token for paste creation **Ideas**: - integrated retention/purge function @@ -91,6 +92,24 @@ curl -s https://linedump.com/{path} | base64 -d | openssl enc -d -aes-256-cb +██ Authentication Examples ██ + +If the instance has authentication enabled, include Bearer token: + + █ curl: + +curl -H "Authorization: Bearer YOUR_TOKEN" -X POST -d "Cheers" https://linedump.com/ + + █ wget: + +wget --header="Authorization: Bearer YOUR_TOKEN" --post-data="Cheers" -O- https://linedump.com/ + + █ Powershell: + +Invoke-RestMethod -Uri "https://linedump.com/" -Headers @{"Authorization"="Bearer YOUR_TOKEN"} -Method Post -Body "Cheers" + + + ██ Adv Examples ██ @@ -148,6 +167,9 @@ podman run --replace -d --restart=unless-stopped \ | `MAX_FILE_SIZE_MB` | Maximum file size limit in megabytes | `50` | No | | `RATE_LIMIT` | Rate limit for uploads (format: "requests/timeframe") | `50/hour` | No | | `URL_PATH_LENGTH` | Length of generated URL paths (number of characters) | `6` | No | +| `UPLOAD_TOKENS` | Comma-separated list of Bearer tokens for upload authentication (if set, uploads require valid token) | _(disabled)_ | No | + +Create a secure token with: `openssl rand -base64 32`. --- diff --git a/main.py b/main.py index d4d5a99..50d0de9 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,7 @@ DESCRIPTION = os.getenv('DESCRIPTION', 'CLI-only pastebin powered by linedump.co MAX_FILE_SIZE_MB = int(os.getenv('MAX_FILE_SIZE_MB', '50')) RATE_LIMIT = os.getenv('RATE_LIMIT', '50/hour') URL_PATH_LENGTH = int(os.getenv('URL_PATH_LENGTH', '6')) +UPLOAD_TOKENS = [t.strip() for t in os.getenv('UPLOAD_TOKENS', '').split(',') if t.strip()] if os.getenv('UPLOAD_TOKENS') else [] limiter = Limiter(key_func=get_remote_address) app = FastAPI(title="linedump", version="1.0.0") @@ -42,6 +43,33 @@ def get_client_ip(request: Request) -> str: return request.client.host +def validate_upload_token(request: Request) -> bool: + """Validate upload token if authentication is enabled""" + if not UPLOAD_TOKENS: + # No tokens configured, authentication is disabled + return True + + auth = request.headers.get("Authorization", "") + if not auth.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"WWW-Authenticate": "Bearer"} + ) + + token = auth[7:] # Remove "Bearer " prefix + + # Use constant-time comparison to prevent timing attacks + if not any(secrets.compare_digest(token, valid_token) for valid_token in UPLOAD_TOKENS): + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"WWW-Authenticate": "Bearer"} + ) + + return True + + def validate_content(content: str) -> bool: """Basic validation for content size and encoding""" if len(content) > MAX_FILE_SIZE: @@ -60,7 +88,7 @@ def validate_content(content: str) -> bool: @app.post("/", response_class=PlainTextResponse) @limiter.limit(RATE_LIMIT) -async def upload_text(request: Request): +async def upload_text(request: Request, authorized: bool = Depends(validate_upload_token)): body = await request.body() content = body.decode('utf-8', errors='ignore') @@ -82,8 +110,12 @@ async def upload_text(request: Request): f.write(content) return f"{BASEURL}/{random_path}\n" - + except Exception as e: + # Log the actual error for debugging + import traceback + print(f"Error saving file: {e}") + print(traceback.format_exc()) raise HTTPException(status_code=500, detail="Failed to save file") @app.get("/{file_path}", response_class=PlainTextResponse) @@ -105,6 +137,38 @@ async def get_file(file_path: str): @app.get("/", response_class=PlainTextResponse) async def root(): + # Build authentication notice and examples if tokens are configured + auth_notice = "\n- Authentication: not required" + auth_section = "" + auth_header_curl = "" + auth_header_wget = "" + auth_header_ps = "" + + if UPLOAD_TOKENS: + auth_notice = "\n- Authentication: REQUIRED (Bearer token)" + auth_header_curl = '-H "Authorization: Bearer $LINEDUMP_TOKEN" ' + auth_header_wget = '--header="Authorization: Bearer $LINEDUMP_TOKEN" ' + auth_header_ps = ' -Headers @{"Authorization"="Bearer $env:LINEDUMP_TOKEN"}' + auth_section = f""" + +████ Authentication Examples ████ + +When authentication is enabled, include Bearer token in Authorization header: + +Set token as environment variable (recommended): +export LINEDUMP_TOKEN="your-token-here" + + █ curl: +curl -H "Authorization: Bearer $LINEDUMP_TOKEN" -X POST -d "Cheers" {BASEURL}/ + + █ wget: +wget --header="Authorization: Bearer $LINEDUMP_TOKEN" --post-data="Cheers" -O- {BASEURL}/ + + █ Powershell: +$env:LINEDUMP_TOKEN="your-token-here" +Invoke-RestMethod -Uri "{BASEURL}/" -Headers @{{"Authorization"="Bearer $env:LINEDUMP_TOKEN"}} -Method Post -Body "Cheers" +""" + return f"""LD {BASEURL} ████ General ████ @@ -114,31 +178,31 @@ async def root(): - File limit: {MAX_FILE_SIZE_MB} MB - Rate limit: {RATE_LIMIT} - text-only -- no server-side encryption, consider content public or use client-side encryption - +- no server-side encryption, consider content public or use client-side encryption{auth_notice} +{auth_section} ████ Usage ████ █ Upload curl: -curl -X POST -d "Cheers" {BASEURL}/ # string -curl -X POST {BASEURL} --data-binary @- < file.txt # file -ip -br a | curl -X POST {BASEURL} --data-binary @- # command output +curl {auth_header_curl}-X POST -d "Cheers" {BASEURL}/ # string +curl {auth_header_curl}-X POST {BASEURL} --data-binary @- < file.txt # file +ip -br a | curl {auth_header_curl}-X POST {BASEURL} --data-binary @- # command output █ Upload wget: -echo "Cheers" | wget --post-data=@- -O- {BASEURL}/ # string -wget --post-file=file.txt -O- {BASEURL}/ # file -ip -br a | wget --post-data=@- -O- {BASEURL}/ # command output +echo "Cheers" | wget {auth_header_wget}--post-data=@- -O- {BASEURL}/ # string +wget {auth_header_wget}--post-file=file.txt -O- {BASEURL}/ # file +ip -br a | wget {auth_header_wget}--post-data=@- -O- {BASEURL}/ # command output █ Upload Powershell: -Invoke-RestMethod -Uri "{BASEURL}/" -Method Post -Body "Cheers" # string -Invoke-RestMethod -Uri "{BASEURL}/" -Method Post -InFile "file.txt" # file -ipconfig | Invoke-RestMethod -Uri "{BASEURL}/" -Method Post -Body {{ $_ }} # command output +Invoke-RestMethod -Uri "{BASEURL}/"{auth_header_ps} -Method Post -Body "Cheers" # string +Invoke-RestMethod -Uri "{BASEURL}/"{auth_header_ps} -Method Post -InFile "file.txt" # file +ipconfig | Invoke-RestMethod -Uri "{BASEURL}/"{auth_header_ps} -Method Post -Body {{ $_ }} # command output █ Download: @@ -161,20 +225,20 @@ Invoke-RestMethod -Uri "{BASEURL}/{{path}}" -OutFile "filename.txt" # echo 'Cheers' \ | openssl enc -aes-256-cbc -pbkdf2 -salt -base64 -pass pass:yourkey \ - | curl -X POST -d @- {BASEURL}/ + | curl {auth_header_curl}-X POST -d @- {BASEURL}/ █ Upload file: openssl enc -aes-256-cbc -pbkdf2 -salt -pass pass:yourkey -base64 < file.txt \ - | curl -sS -X POST {BASEURL} --data-binary @- + | curl {auth_header_curl}-sS -X POST {BASEURL} --data-binary @- █ Upload command output: ip -br a \ | openssl enc -aes-256-cbc -pbkdf2 -salt -pass pass:yourkey -base64 \ - | curl -sS -X POST {BASEURL} --data-binary @- + | curl {auth_header_curl}-sS -X POST {BASEURL} --data-binary @- █ Download: @@ -193,7 +257,7 @@ curl -s {BASEURL}/{{path}} \ {{ cmd() {{ printf "\\n# %s\\n" "$*"; "$@"; }}; \\ cmd hostname; \\ cmd ip -br a; \\ - }} 2>&1 | curl -X POST {BASEURL} --data-binary @- + }} 2>&1 | curl {auth_header_curl}-X POST {BASEURL} --data-binary @- █ Continous command: @@ -201,7 +265,7 @@ curl -s {BASEURL}/{{path}} \ (timeout --signal=INT --kill-after=5s 10s \\ ping 127.1; \\ echo "--- Terminated ---") | \\ - curl -X POST --data-binary @- {BASEURL} + curl {auth_header_curl}-X POST --data-binary @- {BASEURL} From 4ff65e85619044bcb4bf16d7a543d7e2f26002c2 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Thu, 16 Oct 2025 00:09:07 +0200 Subject: [PATCH 02/12] ADD logging feature #13 --- README.md | 3 ++ main.py | 132 +++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 119 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 41f9e5b..bcf441c 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ - up- and download in CLI possible - rate-limits - optional auth token for paste creation +- logging **Ideas**: - integrated retention/purge function @@ -168,6 +169,8 @@ podman run --replace -d --restart=unless-stopped \ | `RATE_LIMIT` | Rate limit for uploads (format: "requests/timeframe") | `50/hour` | No | | `URL_PATH_LENGTH` | Length of generated URL paths (number of characters) | `6` | No | | `UPLOAD_TOKENS` | Comma-separated list of Bearer tokens for upload authentication (if set, uploads require valid token) | _(disabled)_ | No | +| `LOGGING_ENABLED` | Enable structured JSON logging to file and stdout | `false` | No | +| `LOG_LEVEL` | Logging level (INFO, WARNING, ERROR) | `INFO` | No | Create a secure token with: `openssl rand -base64 32`. diff --git a/main.py b/main.py index 50d0de9..290f6bb 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,9 @@ import os from pathlib import Path import hashlib from typing import Optional +import json +from datetime import datetime +import sys BASEURL = os.getenv('BASEURL', 'http://127.0.0.1:8000') @@ -18,13 +21,80 @@ MAX_FILE_SIZE_MB = int(os.getenv('MAX_FILE_SIZE_MB', '50')) RATE_LIMIT = os.getenv('RATE_LIMIT', '50/hour') URL_PATH_LENGTH = int(os.getenv('URL_PATH_LENGTH', '6')) UPLOAD_TOKENS = [t.strip() for t in os.getenv('UPLOAD_TOKENS', '').split(',') if t.strip()] if os.getenv('UPLOAD_TOKENS') else [] +LOGGING_ENABLED = os.getenv('LOGGING_ENABLED', 'false').lower() in ['true', '1', 'yes'] +LOG_LEVEL = os.getenv('LOG_LEVEL', 'INFO').upper() +LOG_FILE = 'logs/linedump.log' -limiter = Limiter(key_func=get_remote_address) +# Create logs directory and log file if logging is enabled +if LOGGING_ENABLED: + Path('logs').mkdir(exist_ok=True) + Path(LOG_FILE).touch(exist_ok=True) + + +def log(level: str, event: str, **kwargs): + """Simple structured logging function""" + # Skip if logging is disabled + if not LOGGING_ENABLED: + return + + # Skip logs based on level + if LOG_LEVEL == 'ERROR' and level in ['INFO', 'WARNING']: + return + if LOG_LEVEL == 'WARNING' and level == 'INFO': + return + + log_entry = { + "timestamp": datetime.utcnow().isoformat() + "Z", + "level": level, + "event": event, + **kwargs + } + + log_line = json.dumps(log_entry) + + # Write to file + try: + with open(LOG_FILE, 'a') as f: + f.write(log_line + '\n') + except: + pass # Fail silently if logging fails + + # Write to stdout + print(log_line, file=sys.stdout) + + +def get_real_ip(request: Request) -> str: + """Get real client IP for rate limiting (supports reverse proxy)""" + # Check X-Real-IP header first (set by reverse proxy) + x_real_ip = request.headers.get("X-Real-IP") + if x_real_ip: + return x_real_ip.strip() + # Fallback to direct connection IP + return request.client.host + +limiter = Limiter(key_func=get_real_ip) app = FastAPI(title="linedump", version="1.0.0") app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) +@app.exception_handler(RateLimitExceeded) +async def log_rate_limit(request: Request, exc: RateLimitExceeded): + """Custom handler to log rate limit violations""" + log("WARNING", "rate_limit_exceeded", + client_ip=get_client_ip(request), + user_agent=request.headers.get("User-Agent", "unknown"), + endpoint=request.url.path) + return await _rate_limit_exceeded_handler(request, exc) + + +# Log startup +log("INFO", "application_started", + base_url=BASEURL, + max_file_size_mb=MAX_FILE_SIZE_MB, + auth_enabled=bool(UPLOAD_TOKENS)) + + UPLOAD_DIR = Path("uploads") UPLOAD_DIR.mkdir(exist_ok=True) @@ -51,6 +121,10 @@ def validate_upload_token(request: Request) -> bool: auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): + log("WARNING", "auth_failed", + client_ip=get_client_ip(request), + user_agent=request.headers.get("User-Agent", "unknown"), + reason="missing_bearer") raise HTTPException( status_code=401, detail="Unauthorized", @@ -61,6 +135,10 @@ def validate_upload_token(request: Request) -> bool: # Use constant-time comparison to prevent timing attacks if not any(secrets.compare_digest(token, valid_token) for valid_token in UPLOAD_TOKENS): + log("WARNING", "auth_failed", + client_ip=get_client_ip(request), + user_agent=request.headers.get("User-Agent", "unknown"), + reason="invalid_token") raise HTTPException( status_code=401, detail="Unauthorized", @@ -89,50 +167,72 @@ def validate_content(content: str) -> bool: @app.post("/", response_class=PlainTextResponse) @limiter.limit(RATE_LIMIT) async def upload_text(request: Request, authorized: bool = Depends(validate_upload_token)): - + + client_ip = get_client_ip(request) + user_agent = request.headers.get("User-Agent", "unknown") body = await request.body() content = body.decode('utf-8', errors='ignore') - + if not validate_content(content): + log("WARNING", "upload_failed", + client_ip=client_ip, + user_agent=user_agent, + reason="invalid_content", + size_bytes=len(content)) raise HTTPException(status_code=400, detail="Invalid content") - + if not content.strip(): + log("WARNING", "upload_failed", + client_ip=client_ip, + user_agent=user_agent, + reason="empty_content") raise HTTPException(status_code=400, detail="Empty content") - + random_path = generate_random_path() while (UPLOAD_DIR / random_path).exists(): random_path = generate_random_path() - + file_path = UPLOAD_DIR / random_path - + try: with open(file_path, 'w', encoding='utf-8') as f: f.write(content) + 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" except Exception as e: - # Log the actual error for debugging - import traceback - print(f"Error saving file: {e}") - print(traceback.format_exc()) + log("ERROR", "upload_failed", + paste_id=random_path, + client_ip=client_ip, + user_agent=user_agent, + 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): +async def get_file(file_path: str, request: Request): if not file_path.isalnum(): raise HTTPException(status_code=404, detail="File not found") - + file_location = UPLOAD_DIR / file_path - + if not file_location.exists() or not file_location.is_file(): raise HTTPException(status_code=404, detail="File not found") - + try: with open(file_location, 'r', encoding='utf-8') as f: content = f.read() + return content - except Exception: + except Exception as e: + log("ERROR", "download_failed", + paste_id=file_path, + error=str(e)) raise HTTPException(status_code=500, detail="Failed to read file") @app.get("/", response_class=PlainTextResponse) From 0556f98c95da2e9cb3e639acdbd7d1293b20e357 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Thu, 16 Oct 2025 00:30:17 +0200 Subject: [PATCH 03/12] DEL dup function to retreive real-ip for logging and rate-limit --- main.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/main.py b/main.py index 290f6bb..f535adf 100644 --- a/main.py +++ b/main.py @@ -64,7 +64,7 @@ def log(level: str, event: str, **kwargs): def get_real_ip(request: Request) -> str: - """Get real client IP for rate limiting (supports reverse proxy)""" + """Get real client IP for rate limiting and logging (supports reverse proxy)""" # Check X-Real-IP header first (set by reverse proxy) x_real_ip = request.headers.get("X-Real-IP") if x_real_ip: @@ -82,7 +82,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) async def log_rate_limit(request: Request, exc: RateLimitExceeded): """Custom handler to log rate limit violations""" log("WARNING", "rate_limit_exceeded", - client_ip=get_client_ip(request), + client_ip=get_real_ip(request), user_agent=request.headers.get("User-Agent", "unknown"), endpoint=request.url.path) return await _rate_limit_exceeded_handler(request, exc) @@ -106,12 +106,6 @@ def generate_random_path(length: int = None) -> str: alphabet = string.ascii_letters + string.digits return ''.join(secrets.choice(alphabet) for _ in range(length)) -def get_client_ip(request: Request) -> str: - x_real_ip = request.headers.get("X-Real-IP") - if x_real_ip: - return x_real_ip.strip() - return request.client.host - def validate_upload_token(request: Request) -> bool: """Validate upload token if authentication is enabled""" @@ -122,7 +116,7 @@ def validate_upload_token(request: Request) -> bool: auth = request.headers.get("Authorization", "") if not auth.startswith("Bearer "): log("WARNING", "auth_failed", - client_ip=get_client_ip(request), + client_ip=get_real_ip(request), user_agent=request.headers.get("User-Agent", "unknown"), reason="missing_bearer") raise HTTPException( @@ -136,7 +130,7 @@ def validate_upload_token(request: Request) -> bool: # Use constant-time comparison to prevent timing attacks if not any(secrets.compare_digest(token, valid_token) for valid_token in UPLOAD_TOKENS): log("WARNING", "auth_failed", - client_ip=get_client_ip(request), + client_ip=get_real_ip(request), user_agent=request.headers.get("User-Agent", "unknown"), reason="invalid_token") raise HTTPException( @@ -168,7 +162,7 @@ def validate_content(content: str) -> bool: @limiter.limit(RATE_LIMIT) async def upload_text(request: Request, authorized: bool = Depends(validate_upload_token)): - client_ip = get_client_ip(request) + client_ip = get_real_ip(request) user_agent = request.headers.get("User-Agent", "unknown") body = await request.body() content = body.decode('utf-8', errors='ignore') From ed081b280cab76845a43118d33fc6782e72e7cfc Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Thu, 16 Oct 2025 00:37:14 +0200 Subject: [PATCH 04/12] CHANGE info about the need of a reverse-proxy --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index bcf441c..a6332ea 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,8 @@ Invoke-RestMethod -Uri "https://linedump.com/" -Headers @{"Authorization"="Beare ## Installation -**Use with reverse-proxy and HTTPS!** +> [!IMPORTANT] +> **Production Deployment:** Use a reverse-proxy (nginx, caddy) with TLS/HTTPS! Rate-limiting and logging features require the `X-Real-IP` header from a reverse proxy to function correctly. Less critical for local or trusted environments. ### Docker From cc886d467583412dd498b00ee0e267ae695414e8 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Thu, 16 Oct 2025 01:38:31 +0200 Subject: [PATCH 05/12] 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 06/12] 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 07/12] 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 08/12] 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` From f3a52b33d3a6d09e4f480f11dd386e9e3d55552d Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Thu, 16 Oct 2025 18:19:23 +0200 Subject: [PATCH 09/12] CHANGE docs from {path} to {paste_id} to make it more persistent --- README.md | 16 ++++++++-------- main.py | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ece1a1e..b988067 100644 --- a/README.md +++ b/README.md @@ -58,19 +58,19 @@ ipconfig | Invoke-RestMethod -Uri "https://linedump.com/" -Method Post -Body { $ █ Download: -curl https://linedump.com/{path} # print to stdout -curl -o filename.txt https://linedump.com/{path} # save to file +curl https://linedump.com/{paste_id} # print to stdout +curl -o filename.txt https://linedump.com/{paste_id} # save to file -wget -O- https://linedump.com/{path} # print to stdout -wget -O filename.txt https://linedump.com/{path} # save to file +wget -O- https://linedump.com/{paste_id} # print to stdout +wget -O filename.txt https://linedump.com/{paste_id} # save to file -Invoke-RestMethod -Uri "https://linedump.com/{path}" # print to stdout -Invoke-RestMethod -Uri "https://linedump.com/{path}" -OutFile "filename.txt" # save to file +Invoke-RestMethod -Uri "https://linedump.com/{paste_id}" # print to stdout +Invoke-RestMethod -Uri "https://linedump.com/{paste_id}" -OutFile "filename.txt" # save to file █ Delete: -curl -X POST "https://linedump.com/{path}?token={deletion_token}" # delete paste +curl -X POST "https://linedump.com/{paste_id}?token={deletion_token}" # delete paste @@ -94,7 +94,7 @@ ip -br a | openssl enc -aes-256-cbc -pbkdf2 -salt -pass pass:yourkey -base64 █ Download: -curl -s https://linedump.com/{path} | base64 -d | openssl enc -d -aes-256-cbc -pbkdf2 -pass pass:yourkey +curl -s https://linedump.com/{paste_id} | base64 -d | openssl enc -d -aes-256-cbc -pbkdf2 -pass pass:yourkey diff --git a/main.py b/main.py index dad094b..730a2dd 100644 --- a/main.py +++ b/main.py @@ -462,19 +462,19 @@ ipconfig | Invoke-RestMethod -Uri "{BASEURL}/"{auth_header_ps} -Method Post -Bod █ Download: -curl {BASEURL}/{{path}} # print to stdout -curl -o filename.txt {BASEURL}/{{path}} # save to file +curl {BASEURL}/{{paste_id}} # print to stdout +curl -o filename.txt {BASEURL}/{{paste_id}} # save to file -wget -O- {BASEURL}/{{path}} # print to stdout -wget -O filename.txt {BASEURL}/{{path}} # save to file +wget -O- {BASEURL}/{{paste_id}} # print to stdout +wget -O filename.txt {BASEURL}/{{paste_id}} # save to file -Invoke-RestMethod -Uri "{BASEURL}/{{path}}" # print to stdout -Invoke-RestMethod -Uri "{BASEURL}/{{path}}" -OutFile "filename.txt" # save to file +Invoke-RestMethod -Uri "{BASEURL}/{{paste_id}}" # print to stdout +Invoke-RestMethod -Uri "{BASEURL}/{{paste_id}}" -OutFile "filename.txt" # save to file █ Delete: -curl -X POST "{BASEURL}/{{path}}?token={{deletion_token}}" # delete paste +curl -X POST "{BASEURL}/{{paste_id}}?token={{deletion_token}}" # delete paste @@ -503,7 +503,7 @@ ip -br a \ █ Download: -curl -s {BASEURL}/{{path}} \ +curl -s {BASEURL}/{{paste_id}} \ | base64 -d \ | openssl enc -d -aes-256-cbc -pbkdf2 -pass pass:yourkey From 6337f47b0d750f6c9b10ad502e9d06031ccd952c Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Mon, 20 Oct 2025 16:27:23 +0200 Subject: [PATCH 10/12] ADD information about wiki --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index b988067..f959edb 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ Invoke-RestMethod -Uri "https://linedump.com/" -Headers @{"Authorization"="Beare curl -X POST --data-binary @- https://linedump.com ``` +[For more examples check out the Wiki.](https://git.uphillsecurity.com/cf7/linedump/wiki/Usage) + --- ## Installation @@ -192,6 +194,7 @@ For security concerns or reports, please contact via `hello a t uphillsecurity d - [Github Mirror available](https://github.com/CaffeineFueled1/linedump) - [Rate Limit Testing Script](https://git.uphillsecurity.com/cf7/Snippets/wiki/bash-linedump-ratelimit-test.-) +- [Linedumpe Wiki](https://git.uphillsecurity.com/cf7/linedump/wiki/?action=_pages) --- From b62ec8473edecdea2d6d404aac1e78524b33d955 Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sun, 26 Oct 2025 11:23:21 +0100 Subject: [PATCH 11/12] CHANGE format for deletion token display --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 730a2dd..c084c38 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 with HTTP POST: {BASEURL}/{random_path}?token={deletion_token}\n" + return f"{BASEURL}/{random_path}\nDelete with HTTP POST:\n{BASEURL}/{random_path}?token={deletion_token}\n" except Exception as e: log("ERROR", "upload_failed", From a8707a267bf64df38aaeacd8e160a81eb6ae788d Mon Sep 17 00:00:00 2001 From: CaffeineFueled Date: Sun, 26 Oct 2025 11:39:46 +0100 Subject: [PATCH 12/12] ADD multiline support for desc env var for terms and other links --- README.md | 2 +- main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f959edb..e707168 100644 --- a/README.md +++ b/README.md @@ -172,7 +172,7 @@ podman run --replace -d --restart=unless-stopped \ | Variable | Description | Default | Required | |----------|-------------|---------|----------| | `BASEURL` | Base URL used in the application responses and examples | `http://127.0.0.1:8000` | No | -| `DESCRIPTION` | Application description displayed in the root endpoint | `CLI-only pastebin powered by linedump.com` | No | +| `DESCRIPTION` | Application description displayed in the root endpoint (supports `\n` for multiline) | `CLI-only pastebin powered by linedump.com\nOpen Source: https://git.uphillsecurity.com/cf7/linedump` | No | | `MAX_FILE_SIZE_MB` | Maximum file size limit in megabytes | `50` | No | | `RATE_LIMIT` | Rate limit for uploads (format: "requests/timeframe") | `50/hour` | No | | `URL_PATH_LENGTH` | Length of generated URL paths (number of characters) | `6` | No | diff --git a/main.py b/main.py index c084c38..6174696 100644 --- a/main.py +++ b/main.py @@ -16,7 +16,7 @@ import sys BASEURL = os.getenv('BASEURL', 'http://127.0.0.1:8000') -DESCRIPTION = os.getenv('DESCRIPTION', 'CLI-only pastebin powered by linedump.com') +DESCRIPTION = os.getenv('DESCRIPTION', 'CLI-only pastebin powered by linedump.com\nOpen Source: https://git.uphillsecurity.com/cf7/linedump').replace('\\n', '\n') MAX_FILE_SIZE_MB = int(os.getenv('MAX_FILE_SIZE_MB', '50')) RATE_LIMIT = os.getenv('RATE_LIMIT', '50/hour') URL_PATH_LENGTH = int(os.getenv('URL_PATH_LENGTH', '6'))