From d37e14a2d9045ea3aaad4f316c6b008216edd2ae Mon Sep 17 00:00:00 2001 From: Jarian Cottingham Date: Thu, 20 Aug 2026 21:26:54 +0000 Subject: [PATCH] security: block path traversal, SVG upload, duplicate defs; add tests, docs, license - Validate paste IDs against ^[a-f0-9]{16}$ before filesystem access (blocks read/delete traversal via crafted URLs) - Remove duplicate ALLOWED_IMAGE_EXTENSIONS that re-allowed SVG (XSS via stored SVG); keep magic-byte validation - Remove duplicate cleanup thread (two background loops were started) - Unknown expiry keys now default to 1 day instead of never - request.secure -> request.is_secure (werkzeug 3.x) - Add gunicorn to requirements (Dockerfile CMD referenced it) - 11 unit tests, ruff clean, MIT LICENSE, full README (was empty) --- .dockerignore | 3 +- LICENSE | 21 ++++++++ README.md | 71 +++++++++++++++++++++++++++ app.py | 32 ++++--------- pyproject.toml | 42 ++++++++++++++++ tests/test_app.py | 119 ++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 265 insertions(+), 23 deletions(-) create mode 100644 LICENSE create mode 100644 pyproject.toml create mode 100644 tests/test_app.py diff --git a/.dockerignore b/.dockerignore index 18be61d..4d35dad 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,4 +10,5 @@ docker-compose.override.yml .env .dockerignore store/ -uploads/ \ No newline at end of file +uploads/ +tests/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..850a5a7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Jarian Cottingham + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index e69de29..4eaca04 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,71 @@ +# paste-bin + +A minimal, security-focused paste bin: share text, images, and files with +expiring links. Built with Flask and file-based storage (no database). + +## Features + +- Text, image, and file pastes with random 16-hex-char IDs +- Expiry options: 1 hour, 1 day, 1 week, 1 month, or never +- Automatic background cleanup of expired pastes +- Per-IP upload rate limiting (10 uploads / 60 s) +- Image magic-byte validation; SVG rejected (XSS prevention) +- Paste-ID format validation (blocks path traversal on read/delete) +- Security headers: CSP, HSTS, nosniff, frame deny, referrer policy +- Raw view, pretty view, and download endpoints per paste + +## Quick Start + +```bash +pip install -r requirements.txt +python app.py +# visit http://localhost:8080 +``` + +## Configuration + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8080` | Web server port | +| `SECRET_KEY` | random | Flask secret key | +| `UPLOAD_FOLDER` | `./uploads` | Binary file storage | +| `STORE_FOLDER` | `./store` | Paste metadata + text content | + +## Docker + +```bash +docker build -t paste-bin . +docker run -p 8080:8080 \ + -v paste-uploads:/app/uploads \ + -v paste-store:/app/store \ + paste-bin +``` + +Or with compose (mounts persistent volumes): + +```bash +docker compose up -d +``` + +## Tests + +```bash +pip install -r requirements.txt pytest +pytest tests/ -v +``` + +Covers paste roundtrips, path-traversal blocking, SVG/magic-byte +validation, expiry parsing, and rate-limit setup. + +## Security Notes + +- Uploads are stored under a random ID; extension preserved only for + downloads with `Content-Disposition: attachment`. +- Text pastes are served as `text/plain` (never rendered as HTML). +- Images are served with `X-Content-Type-Options: nosniff` + restrictive CSP. +- Paste IDs are validated against `^[a-f0-9]{16}$` before any filesystem + access, so crafted URLs cannot read or delete arbitrary files. + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/app.py b/app.py index bfa2150..dc4308d 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,7 @@ import os +import re import uuid import json -import fcntl import secrets import time import threading @@ -33,15 +33,13 @@ ALLOWED_IMAGE_MAGIC = { } ALLOWED_TEXT_EXTENSIONS = {'txt', 'py', 'js', 'ts', 'c', 'cpp', 'h', 'java', 'rb', 'go', 'rs', 'md', 'json', 'xml', 'yaml', 'yml', 'html', 'css', 'sh', 'log', 'csv', 'sql', 'ini', 'cfg', 'toml', 'lua', 'php', 'swift', 'kt', 'scala', 'r', 'pl', 'hs', 'zig', 'nix'} +# Paste IDs are uuid4().hex[:16]; reject anything else to block path traversal. +PASTE_ID_RE = re.compile(r'^[a-f0-9]{16}$') + _upload_attempts = {} _UPLOAD_MAX = 10 _UPLOAD_WINDOW = 60 -_csrf_secret = secrets.token_hex(32) - -ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'tiff'} -ALLOWED_TEXT_EXTENSIONS = {'txt', 'py', 'js', 'ts', 'c', 'cpp', 'h', 'java', 'rb', 'go', 'rs', 'md', 'json', 'xml', 'yaml', 'yml', 'html', 'css', 'sh', 'log', 'csv', 'sql', 'ini', 'cfg', 'toml', 'lua', 'php', 'swift', 'kt', 'scala', 'r', 'pl', 'hs', 'zig', 'nix'} - def ensure_dirs(): os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) @@ -53,18 +51,17 @@ def generate_id(): def parse_expiry(expiry_key): + """Map an expiry option to an ISO timestamp; unknown keys default to 1 day.""" if expiry_key == 'forever': return None now = datetime.now(timezone.utc) if expiry_key == '1h': return (now + timedelta(hours=1)).isoformat() - elif expiry_key == '1d': - return (now + timedelta(days=1)).isoformat() elif expiry_key == '1w': return (now + timedelta(weeks=1)).isoformat() elif expiry_key == '1m': return (now + timedelta(days=30)).isoformat() - return None + return (now + timedelta(days=1)).isoformat() def store_paste(paste_id, paste_data): @@ -77,6 +74,8 @@ def store_paste(paste_id, paste_data): def load_paste(paste_id): + if not PASTE_ID_RE.match(paste_id): + return None store_path = os.path.join(app.config['STORE_FOLDER'], paste_id) if not os.path.exists(store_path): return None @@ -161,17 +160,6 @@ def save_text_content(paste_id, content): # Fix #16 - ensure_dirs at startup only, not every request ensure_dirs() -# Fix #1 - scheduled cleanup of expired pastes -def _cleanup_loop(): - while True: - time.sleep(300) - try: - cleanup_expired() - except Exception as e: - print(f"Cleanup error: {e}") - -threading.Thread(target=_cleanup_loop, daemon=True).start() - @app.after_request def add_security_headers(response): @@ -180,7 +168,7 @@ def add_security_headers(response): response.headers['X-XSS-Protection'] = '1; mode=block' response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' response.headers['Content-Security-Policy'] = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'" - if request.secure: + if request.is_secure: response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains' return response @@ -378,4 +366,4 @@ def request_entity_too_large(e): if __name__ == '__main__': ensure_dirs() - app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8080))) \ No newline at end of file + app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8080))) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3674dd4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[project] +name = "paste-bin" +version = "1.0.0" +description = "Minimal security-focused paste bin: text, image, and file sharing with expiring links" +readme = "README.md" +requires-python = ">=3.9" +license = { text = "MIT" } +authors = [{ name = "Jarian Cottingham", email = "jarianc@proton.me" }] +keywords = ["paste", "paste-bin", "sharing", "flask"] +classifiers = [ + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", +] +dependencies = [ + "flask>=3.0,<4.0", + "werkzeug>=3.0,<4.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "ruff>=0.1.0", +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +py-modules = ["app"] + +[tool.ruff] +line-length = 120 +target-version = "py39" +exclude = [".git", "uploads", "store"] + +[tool.ruff.lint] +select = ["E", "F", "W"] +ignore = ["E501"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..a93f18a --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,119 @@ +"""Unit tests for paste-bin.""" + +import io +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +import app as app_module # noqa: E402 + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + app_module.app.config['TESTING'] = True + app_module.app.config['UPLOAD_FOLDER'] = str(tmp_path / 'uploads') + app_module.app.config['STORE_FOLDER'] = str(tmp_path / 'store') + app_module.ensure_dirs() + with app_module.app.test_client() as c: + yield c + + +def test_index_returns_200(client): + resp = client.get('/') + assert resp.status_code == 200 + + +def test_text_paste_roundtrip(client): + resp = client.post('/paste', data={'paste_type': 'text', 'content': 'hello world', 'expiry': '1d'}) + assert resp.status_code == 302 + paste_id = resp.headers['Location'].rsplit('/', 1)[-1] + assert len(paste_id) == 16 + + view = client.get(f'/{paste_id}') + assert view.status_code == 200 + assert b'hello world' in view.data + + raw = client.get(f'/{paste_id}/raw') + assert raw.status_code == 200 + assert raw.data == b'hello world' + + dl = client.get(f'/{paste_id}/download') + assert dl.status_code == 200 + assert dl.data == b'hello world' + + +def test_empty_content_rejected(client): + resp = client.post('/paste', data={'paste_type': 'text', 'content': ' ', 'expiry': '1d'}) + assert resp.status_code == 400 + + +def test_invalid_paste_type_rejected(client): + resp = client.post('/paste', data={'paste_type': 'bogus', 'content': 'x'}) + assert resp.status_code == 400 + + +def test_unknown_paste_404(client): + assert client.get('/' + 'a' * 16).status_code == 404 + + +def test_path_traversal_paste_id_blocked(client, tmp_path): + outside = tmp_path / 'secret.txt' + outside.write_text('top secret') + for evil in ['..%2F..%2Fsecret.txt', '....//secret.txt', 'a' * 14 + '/../../secret.txt']: + resp = client.get('/' + evil) + assert resp.status_code == 404 + assert outside.exists() + + +def test_svg_upload_rejected(client): + png_bytes = b'' + resp = client.post( + '/paste', + data={'paste_type': 'image', 'file': (io.BytesIO(png_bytes), 'x.svg', 'image/svg+xml')}, + content_type='multipart/form-data', + ) + assert resp.status_code == 400 + + +def test_png_magic_validation(client): + fake = b'not-a-real-png' + resp = client.post( + '/paste', + data={'paste_type': 'image', 'file': (io.BytesIO(fake), 'x.png', 'image/png')}, + content_type='multipart/form-data', + ) + assert resp.status_code == 400 + + +def test_valid_png_upload(client): + png = b'\x89PNG\r\n\x1a\n' + b'\x00' * 32 + resp = client.post( + '/paste', + data={'paste_type': 'image', 'file': (io.BytesIO(png), 'pic.png', 'image/png')}, + content_type='multipart/form-data', + ) + assert resp.status_code == 302 + paste_id = resp.headers['Location'].rsplit('/', 1)[-1] + assert client.get(f'/{paste_id}').status_code == 200 + + +def test_expiry_parsing(): + assert app_module.parse_expiry('forever') is None + for key in ['1h', '1d', '1w', '1m', 'bogus-key']: + result = app_module.parse_expiry(key) + assert result is not None + from datetime import datetime + + datetime.fromisoformat(result) + + +def test_store_and_load_paste(tmp_path, monkeypatch): + app_module.app.config['STORE_FOLDER'] = str(tmp_path / 'store') + app_module.ensure_dirs() + app_module.store_paste('ab' * 8, {'type': 'text', 'title': 't'}) + paste = app_module.load_paste('ab' * 8) + assert paste['title'] == 't' + assert app_module.load_paste('../etc/passwd') is None