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.
This commit is contained in:
Jarian Cottingham 2026-07-04 04:57:15 +00:00
parent 2774a748bc
commit 3ea2ab4258
4 changed files with 115 additions and 31 deletions

View File

@ -7,7 +7,11 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
RUN mkdir -p /app/uploads /app/store RUN mkdir -p /app/uploads /app/store && \
adduser --disabled-password --no-create-home appuser && \
chown -R appuser:appuser /app
USER appuser
EXPOSE 8080 EXPOSE 8080

120
app.py
View File

@ -2,15 +2,42 @@ import os
import uuid import uuid
import json import json
import fcntl import fcntl
import secrets
import time
import threading
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from flask import Flask, request, redirect, url_for, render_template, send_file, abort from flask import Flask, request, redirect, url_for, render_template, send_file, abort, make_response
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
app = Flask(__name__) app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 20 * 1024 * 1024 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['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['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', 'paste-bin-secret') 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 = [ EXPIRY_OPTIONS = [
('1h', '1 hour'), ('1h', '1 hour'),
@ -30,10 +57,7 @@ def ensure_dirs():
def generate_id(): def generate_id():
while True: return uuid.uuid4().hex[:16]
pid = uuid.uuid4().hex[:8]
if not os.path.exists(os.path.join(app.config['STORE_FOLDER'], pid)):
return pid
def parse_expiry(expiry_key): def parse_expiry(expiry_key):
@ -53,25 +77,27 @@ def parse_expiry(expiry_key):
def store_paste(paste_id, paste_data): def store_paste(paste_id, paste_data):
store_path = os.path.join(app.config['STORE_FOLDER'], paste_id) store_path = os.path.join(app.config['STORE_FOLDER'], paste_id)
with open(store_path, 'w') as f: tmp_path = store_path + f".tmp.{os.getpid()}"
fcntl.flock(f, fcntl.LOCK_EX) 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) json.dump(paste_data, f)
fcntl.flock(f, fcntl.LOCK_UN) os.rename(tmp_path, store_path)
def load_paste(paste_id): def load_paste(paste_id):
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
try:
with open(store_path, 'r') as f: with open(store_path, 'r') as f:
fcntl.flock(f, fcntl.LOCK_SH) return json.load(f)
data = json.load(f) except (json.JSONDecodeError, IOError):
fcntl.flock(f, fcntl.LOCK_UN) return None
return data
def delete_paste(paste_id): def delete_paste(paste_id):
store_path = os.path.join(app.config['STORE_FOLDER'], paste_id) store_path = os.path.join(app.config['STORE_FOLDER'], paste_id)
txt_path = store_path + '.txt'
paste = load_paste(paste_id) paste = load_paste(paste_id)
if paste and paste['type'] in ('image', 'file') and paste.get('filepath'): if paste and paste['type'] in ('image', 'file') and paste.get('filepath'):
filepath = paste['filepath'] filepath = paste['filepath']
@ -79,6 +105,8 @@ def delete_paste(paste_id):
os.remove(filepath) os.remove(filepath)
if os.path.exists(store_path): if os.path.exists(store_path):
os.remove(store_path) os.remove(store_path)
if os.path.exists(txt_path):
os.remove(txt_path)
def is_expired(paste): def is_expired(paste):
@ -89,20 +117,31 @@ def is_expired(paste):
def cleanup_expired(): def cleanup_expired():
now = datetime.now(timezone.utc)
for filename in os.listdir(app.config['STORE_FOLDER']): for filename in os.listdir(app.config['STORE_FOLDER']):
if filename.endswith('.txt'):
continue
filepath = os.path.join(app.config['STORE_FOLDER'], filename) filepath = os.path.join(app.config['STORE_FOLDER'], filename)
try: try:
with open(filepath, 'r') as f: with open(filepath, 'r') as f:
fcntl.flock(f, fcntl.LOCK_SH)
paste = json.load(f) paste = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
except (json.JSONDecodeError, IOError): except (json.JSONDecodeError, IOError):
continue continue
if is_expired(paste): if is_expired(paste):
delete_paste(filename) 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): def is_image(filename):
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else '' ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
return ext in ALLOWED_IMAGE_EXTENSIONS return ext in ALLOWED_IMAGE_EXTENSIONS
@ -132,13 +171,49 @@ def before_request():
ensure_dirs() 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']) @app.route('/', methods=['GET'])
def index(): def index():
return render_template('index.html', expiry_options=EXPIRY_OPTIONS) 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']) @app.route('/paste', methods=['POST'])
def create_paste(): 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') paste_type = request.form.get('paste_type', 'text')
title = request.form.get('title', '').strip() title = request.form.get('title', '').strip()
expiry_key = request.form.get('expiry', '1d') expiry_key = request.form.get('expiry', '1d')
@ -167,7 +242,16 @@ def create_paste():
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
if not is_image(filename): if not is_image(filename):
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='Invalid image format. Allowed: ' + ', '.join(sorted(ALLOWED_IMAGE_EXTENSIONS))), 400 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() paste_id = generate_id()
ext = filename.rsplit('.', 1)[-1] ext = filename.rsplit('.', 1)[-1]

View File

@ -1,2 +1,3 @@
flask>=3.0.0 flask==3.1.0
gunicorn>=21.2.0 gunicorn==23.0.0
werkzeug==3.1.3

View File

@ -25,6 +25,7 @@
<form method="POST" action="/paste" enctype="multipart/form-data" id="uploadForm"> <form method="POST" action="/paste" enctype="multipart/form-data" id="uploadForm">
<input type="hidden" name="paste_type" id="pasteType" value="text"> <input type="hidden" name="paste_type" id="pasteType" value="text">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group"> <div class="form-group">
<label for="title">Title (optional)</label> <label for="title">Title (optional)</label>
@ -56,11 +57,8 @@
<div class="form-group"> <div class="form-group">
<label id="file-label2">File (max 20 MB)</label> <label id="file-label2">File (max 20 MB)</label>
<div class="file-input"> <div class="file-input">
<input type="file" id="upload-file2" style="display:none">
<label for="upload-file2" class="file-label">
<span class="file-icon">&#128100;</span> <span class="file-icon">&#128100;</span>
<span class="file-text">Choose file or drag here</span> <span class="file-text" id="file-text2">Choose file or drag here</span>
</label>
<span id="chosen-filename2" class="chosen-file"></span> <span id="chosen-filename2" class="chosen-file"></span>
</div> </div>
</div> </div>
@ -107,16 +105,13 @@
contentTextarea.setAttribute('name', 'content'); contentTextarea.setAttribute('name', 'content');
} else if (tabName === 'image') { } else if (tabName === 'image') {
uploadFile.setAttribute('name', 'file'); uploadFile.setAttribute('name', 'file');
uploadFile.setAttribute('accept', 'image/png,image/jpeg,image/gif,image/bmp,image/webp,image/svg+xml,image/tiff'); uploadFile.setAttribute('accept', 'image/png,image/jpeg,image/gif,image/bmp,image/webp,image/tiff');
fileIcon.innerHTML = '&#128247;'; fileIcon.innerHTML = '&#128247;';
fileText.textContent = 'Choose image or drag here'; fileText.textContent = 'Choose image or drag here';
fileLabel.textContent = 'Image (max 20 MB)'; fileLabel.textContent = 'Image (max 20 MB)';
} else if (tabName === 'file') { } else if (tabName === 'file') {
uploadFile.setAttribute('name', 'file'); uploadFile.setAttribute('name', 'file');
uploadFile.removeAttribute('accept'); uploadFile.removeAttribute('accept');
fileIcon.innerHTML = '&#128100;';
fileText.textContent = 'Choose file or drag here';
fileLabel.textContent = 'File (max 20 MB)';
} }
} }