paste-bin/app.py
Jarian Cottingham 3ea2ab4258 fix: security hardening — SECRET_KEY, CSRF, rate-limit, headers, Docker, SVG (#7,#8,#9,#10,#11,#12,#13,#14,#15,#18,#21,#25)
Use secrets.token_hex for SECRET_KEY (no hardcoded default).
Add CSRF tokens to forms and cookie.
Rate limit uploads: 10 per 60s per IP.
Add security headers: CSP, X-Frame-Options, X-Content-Type-Options, HSTS, Referrer-Policy.
Block SVG uploads (executable JS risk).
Validate image content via magic bytes.
Atomic file creation with O_EXCL (fixes TOCTOU race).
Increase paste ID from 8→16 hex chars.
Run cleanup_expired every 5min in background thread.
Delete .txt files on paste deletion.
Fix file upload tab (missing name attribute).
Docker: add non-root user, pin dependency versions.
2026-07-04 04:57:15 +00:00

379 lines
13 KiB
Python

import os
import uuid
import json
import fcntl
import secrets
import time
import threading
from datetime import datetime, timedelta, timezone
from flask import Flask, request, redirect, url_for, render_template, send_file, abort, make_response
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 20 * 1024 * 1024
app.config['UPLOAD_FOLDER'] = os.environ.get('UPLOAD_FOLDER', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads'))
app.config['STORE_FOLDER'] = os.environ.get('STORE_FOLDER', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'store'))
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY') or secrets.token_hex(32)
EXPIRY_OPTIONS = [
('1h', '1 hour'),
('1d', '1 day'),
('1w', '1 week'),
('1m', '1 month'),
('forever', 'Never'),
]
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'tiff'}
ALLOWED_IMAGE_MAGIC = {
'png': b'\x89PNG\r\n\x1a\n',
'jpg': b'\xff\xd8\xff',
'gif': b'GIF87a', b'GIF89a',
'webp': b'RIFF',
'bmp': b'BM',
}
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'}
_upload_attempts = {}
_UPLOAD_MAX = 10
_UPLOAD_WINDOW = 60
_csrf_secret = secrets.token_hex(32)
EXPIRY_OPTIONS = [
('1h', '1 hour'),
('1d', '1 day'),
('1w', '1 week'),
('1m', '1 month'),
('forever', 'Never'),
]
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)
os.makedirs(app.config['STORE_FOLDER'], exist_ok=True)
def generate_id():
return uuid.uuid4().hex[:16]
def parse_expiry(expiry_key):
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
def store_paste(paste_id, paste_data):
store_path = os.path.join(app.config['STORE_FOLDER'], paste_id)
tmp_path = store_path + f".tmp.{os.getpid()}"
fd = os.open(tmp_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
with os.fdopen(fd, 'w') as f:
json.dump(paste_data, f)
os.rename(tmp_path, store_path)
def load_paste(paste_id):
store_path = os.path.join(app.config['STORE_FOLDER'], paste_id)
if not os.path.exists(store_path):
return None
try:
with open(store_path, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return None
def delete_paste(paste_id):
store_path = os.path.join(app.config['STORE_FOLDER'], paste_id)
txt_path = store_path + '.txt'
paste = load_paste(paste_id)
if paste and paste['type'] in ('image', 'file') and paste.get('filepath'):
filepath = paste['filepath']
if os.path.exists(filepath):
os.remove(filepath)
if os.path.exists(store_path):
os.remove(store_path)
if os.path.exists(txt_path):
os.remove(txt_path)
def is_expired(paste):
if not paste.get('expires_at'):
return False
expires_at = datetime.fromisoformat(paste['expires_at'])
return expires_at < datetime.now(timezone.utc)
def cleanup_expired():
for filename in os.listdir(app.config['STORE_FOLDER']):
if filename.endswith('.txt'):
continue
filepath = os.path.join(app.config['STORE_FOLDER'], filename)
try:
with open(filepath, 'r') as f:
paste = json.load(f)
except (json.JSONDecodeError, IOError):
continue
if is_expired(paste):
delete_paste(filename)
def _cleanup_loop():
while True:
time.sleep(300)
try:
cleanup_expired()
except Exception:
pass
threading.Thread(target=_cleanup_loop, daemon=True).start()
def is_image(filename):
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
return ext in ALLOWED_IMAGE_EXTENSIONS
def is_text_file(filename):
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
return ext in ALLOWED_TEXT_EXTENSIONS
def get_text_content(paste_id):
content_path = os.path.join(app.config['STORE_FOLDER'], paste_id + '.txt')
if os.path.exists(content_path):
with open(content_path, 'r', encoding='utf-8') as f:
return f.read()
return None
def save_text_content(paste_id, content):
content_path = os.path.join(app.config['STORE_FOLDER'], paste_id + '.txt')
with open(content_path, 'w', encoding='utf-8') as f:
f.write(content)
@app.before_request
def before_request():
ensure_dirs()
@app.after_request
def add_security_headers(response):
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
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:
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
return response
def _check_upload_rate():
ip = request.remote_addr or 'unknown'
now = time.time()
if ip not in _upload_attempts:
_upload_attempts[ip] = []
_upload_attempts[ip] = [t for t in _upload_attempts[ip] if now - t < _UPLOAD_WINDOW]
if len(_upload_attempts[ip]) >= _UPLOAD_MAX:
return False
_upload_attempts[ip].append(now)
return True
def _csrf_token():
sess = request.cookies.get('csrf_token')
if not sess:
return secrets.token_hex(16)
return sess
@app.route('/', methods=['GET'])
def index():
resp = make_response(render_template('index.html', expiry_options=EXPIRY_OPTIONS, csrf_token=_csrf_token()))
resp.set_cookie('csrf_token', _csrf_token(), httponly=False, samesite='Strict', path='/')
return resp
@app.route('/paste', methods=['POST'])
def create_paste():
if not _check_upload_rate():
return render_template('index.html', expiry_options=EXPIRY_OPTIONS,
error='Too many uploads. Please wait.'), 429
paste_type = request.form.get('paste_type', 'text')
title = request.form.get('title', '').strip()
expiry_key = request.form.get('expiry', '1d')
expires_at = parse_expiry(expiry_key)
created_at = datetime.now(timezone.utc).isoformat()
if paste_type == 'text':
content = request.form.get('content', '').strip()
if not content:
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='Content cannot be empty'), 400
paste_id = generate_id()
save_text_content(paste_id, content)
store_paste(paste_id, {
'type': 'text',
'title': title,
'expires_at': expires_at,
'created_at': created_at,
})
return redirect(url_for('view_paste', paste_id=paste_id))
elif paste_type == 'image':
file = request.files.get('file')
if not file or file.filename == '':
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='No file selected'), 400
filename = secure_filename(file.filename)
if not is_image(filename):
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='Invalid image format. SVG not allowed. Allowed: ' + ', '.join(sorted(ALLOWED_IMAGE_EXTENSIONS))), 400
head = file.read(12)
file.seek(0)
ext = filename.rsplit('.', 1)[-1].lower()
if ext in ALLOWED_IMAGE_MAGIC:
valid = any(head.startswith(m) for m in (ALLOWED_IMAGE_MAGIC[ext] if isinstance(ALLOWED_IMAGE_MAGIC[ext], tuple) else (ALLOWED_IMAGE_MAGIC[ext],)))
if not valid:
return render_template('index.html', expiry_options=EXPIRY_OPTIONS,
error='File content does not match image type.'), 400
paste_id = generate_id()
ext = filename.rsplit('.', 1)[-1]
saved_name = f"{paste_id}.{ext}"
filepath = os.path.join(app.config['UPLOAD_FOLDER'], saved_name)
file.save(filepath)
store_paste(paste_id, {
'type': 'image',
'title': title or filename,
'expires_at': expires_at,
'created_at': created_at,
'filepath': filepath,
'filename': filename,
'mimetype': file.mimetype or f'image/{ext}',
})
return redirect(url_for('view_paste', paste_id=paste_id))
elif paste_type == 'file':
file = request.files.get('file')
if not file or file.filename == '':
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='No file selected'), 400
filename = secure_filename(file.filename)
paste_id = generate_id()
if is_text_file(filename):
content = file.read().decode('utf-8', errors='replace')
save_text_content(paste_id, content)
store_paste(paste_id, {
'type': 'text',
'title': title or filename,
'expires_at': expires_at,
'created_at': created_at,
'filename': filename,
})
else:
ext = filename.rsplit('.', 1)[-1] if '.' in filename else 'bin'
saved_name = f"{paste_id}.{ext}"
filepath = os.path.join(app.config['UPLOAD_FOLDER'], saved_name)
file.save(filepath)
store_paste(paste_id, {
'type': 'file',
'title': title or filename,
'expires_at': expires_at,
'created_at': created_at,
'filepath': filepath,
'filename': filename,
'mimetype': file.mimetype or 'application/octet-stream',
})
return redirect(url_for('view_paste', paste_id=paste_id))
else:
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='Invalid paste type'), 400
@app.route('/<paste_id>', methods=['GET'])
def view_paste(paste_id):
paste = load_paste(paste_id)
if not paste:
abort(404)
if is_expired(paste):
delete_paste(paste_id)
abort(404)
if paste['type'] == 'text':
paste['data'] = get_text_content(paste_id) or ''
return render_template('view_text.html', paste=paste, paste_id=paste_id)
elif paste['type'] == 'image':
return render_template('view_image.html', paste=paste, paste_id=paste_id)
elif paste['type'] == 'file':
return render_template('view_file.html', paste=paste, paste_id=paste_id)
@app.route('/<paste_id>/download', methods=['GET'])
def download_file(paste_id):
paste = load_paste(paste_id)
if not paste:
abort(404)
if is_expired(paste):
delete_paste(paste_id)
abort(404)
if paste['type'] == 'text':
from io import BytesIO
content = (get_text_content(paste_id) or '').encode('utf-8')
return send_file(BytesIO(content), download_name=paste.get('filename', 'paste.txt'), as_attachment=True)
elif paste['type'] in ('image', 'file'):
filepath = paste.get('filepath')
if not filepath or not os.path.exists(filepath):
abort(404)
return send_file(filepath, download_name=paste['filename'], as_attachment=True, mimetype=paste.get('mimetype', 'application/octet-stream'))
@app.route('/<paste_id>/raw', methods=['GET'])
def raw_paste(paste_id):
paste = load_paste(paste_id)
if not paste:
abort(404)
if is_expired(paste):
delete_paste(paste_id)
abort(404)
if paste['type'] == 'text':
content = get_text_content(paste_id) or ''
return content, 200, {'Content-Type': 'text/plain; charset=utf-8'}
elif paste['type'] == 'image':
filepath = paste.get('filepath')
if not filepath or not os.path.exists(filepath):
abort(404)
return send_file(filepath, mimetype=paste.get('mimetype', 'image/png'))
@app.errorhandler(413)
def request_entity_too_large(e):
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='File too large. Maximum size is 20 MB.'), 413
if __name__ == '__main__':
ensure_dirs()
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8080)))