security: block path traversal, SVG upload, duplicate defs; add tests, docs, license
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions

- 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)
This commit is contained in:
Jarian Cottingham 2026-08-20 21:26:54 +00:00
parent 9ff745f3dd
commit d37e14a2d9
6 changed files with 265 additions and 23 deletions

View File

@ -11,3 +11,4 @@ docker-compose.override.yml
.dockerignore
store/
uploads/
tests/

21
LICENSE Normal file
View File

@ -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.

View File

@ -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).

30
app.py
View File

@ -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

42
pyproject.toml Normal file
View File

@ -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"]

119
tests/test_app.py Normal file
View File

@ -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'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
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