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)
This commit is contained in:
parent
9ff745f3dd
commit
d37e14a2d9
@ -10,4 +10,5 @@ docker-compose.override.yml
|
||||
.env
|
||||
.dockerignore
|
||||
store/
|
||||
uploads/
|
||||
uploads/
|
||||
tests/
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
71
README.md
71
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).
|
||||
32
app.py
32
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)))
|
||||
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8080)))
|
||||
|
||||
42
pyproject.toml
Normal file
42
pyproject.toml
Normal 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
119
tests/test_app.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user