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
|
.env
|
||||||
.dockerignore
|
.dockerignore
|
||||||
store/
|
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 os
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
import json
|
import json
|
||||||
import fcntl
|
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
import threading
|
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'}
|
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_attempts = {}
|
||||||
_UPLOAD_MAX = 10
|
_UPLOAD_MAX = 10
|
||||||
_UPLOAD_WINDOW = 60
|
_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():
|
def ensure_dirs():
|
||||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||||
@ -53,18 +51,17 @@ def generate_id():
|
|||||||
|
|
||||||
|
|
||||||
def parse_expiry(expiry_key):
|
def parse_expiry(expiry_key):
|
||||||
|
"""Map an expiry option to an ISO timestamp; unknown keys default to 1 day."""
|
||||||
if expiry_key == 'forever':
|
if expiry_key == 'forever':
|
||||||
return None
|
return None
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if expiry_key == '1h':
|
if expiry_key == '1h':
|
||||||
return (now + timedelta(hours=1)).isoformat()
|
return (now + timedelta(hours=1)).isoformat()
|
||||||
elif expiry_key == '1d':
|
|
||||||
return (now + timedelta(days=1)).isoformat()
|
|
||||||
elif expiry_key == '1w':
|
elif expiry_key == '1w':
|
||||||
return (now + timedelta(weeks=1)).isoformat()
|
return (now + timedelta(weeks=1)).isoformat()
|
||||||
elif expiry_key == '1m':
|
elif expiry_key == '1m':
|
||||||
return (now + timedelta(days=30)).isoformat()
|
return (now + timedelta(days=30)).isoformat()
|
||||||
return None
|
return (now + timedelta(days=1)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
def store_paste(paste_id, paste_data):
|
def store_paste(paste_id, paste_data):
|
||||||
@ -77,6 +74,8 @@ def store_paste(paste_id, paste_data):
|
|||||||
|
|
||||||
|
|
||||||
def load_paste(paste_id):
|
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)
|
store_path = os.path.join(app.config['STORE_FOLDER'], paste_id)
|
||||||
if not os.path.exists(store_path):
|
if not os.path.exists(store_path):
|
||||||
return None
|
return None
|
||||||
@ -161,17 +160,6 @@ def save_text_content(paste_id, content):
|
|||||||
# Fix #16 - ensure_dirs at startup only, not every request
|
# Fix #16 - ensure_dirs at startup only, not every request
|
||||||
ensure_dirs()
|
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
|
@app.after_request
|
||||||
def add_security_headers(response):
|
def add_security_headers(response):
|
||||||
@ -180,7 +168,7 @@ def add_security_headers(response):
|
|||||||
response.headers['X-XSS-Protection'] = '1; mode=block'
|
response.headers['X-XSS-Protection'] = '1; mode=block'
|
||||||
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
|
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'"
|
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'
|
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
|
||||||
return response
|
return response
|
||||||
|
|
||||||
@ -378,4 +366,4 @@ def request_entity_too_large(e):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
ensure_dirs()
|
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