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

122
app.py
View File

@ -2,15 +2,42 @@ 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
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', '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 = [
('1h', '1 hour'),
@ -30,10 +57,7 @@ def ensure_dirs():
def generate_id():
while True:
pid = uuid.uuid4().hex[:8]
if not os.path.exists(os.path.join(app.config['STORE_FOLDER'], pid)):
return pid
return uuid.uuid4().hex[:16]
def parse_expiry(expiry_key):
@ -53,25 +77,27 @@ def parse_expiry(expiry_key):
def store_paste(paste_id, paste_data):
store_path = os.path.join(app.config['STORE_FOLDER'], paste_id)
with open(store_path, 'w') as f:
fcntl.flock(f, fcntl.LOCK_EX)
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)
fcntl.flock(f, fcntl.LOCK_UN)
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
with open(store_path, 'r') as f:
fcntl.flock(f, fcntl.LOCK_SH)
data = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
return data
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']
@ -79,6 +105,8 @@ def delete_paste(paste_id):
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):
@ -89,20 +117,31 @@ def is_expired(paste):
def cleanup_expired():
now = datetime.now(timezone.utc)
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:
fcntl.flock(f, fcntl.LOCK_SH)
paste = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
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
@ -132,13 +171,49 @@ 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():
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'])
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')
@ -167,7 +242,16 @@ def create_paste():
filename = secure_filename(file.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()
ext = filename.rsplit('.', 1)[-1]

View File

@ -1,2 +1,3 @@
flask>=3.0.0
gunicorn>=21.2.0
flask==3.1.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">
<input type="hidden" name="paste_type" id="pasteType" value="text">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="title">Title (optional)</label>
@ -56,11 +57,8 @@
<div class="form-group">
<label id="file-label2">File (max 20 MB)</label>
<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-text">Choose file or drag here</span>
</label>
<span class="file-icon">&#128100;</span>
<span class="file-text" id="file-text2">Choose file or drag here</span>
<span id="chosen-filename2" class="chosen-file"></span>
</div>
</div>
@ -107,16 +105,13 @@
contentTextarea.setAttribute('name', 'content');
} else if (tabName === 'image') {
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;';
fileText.textContent = 'Choose image or drag here';
fileLabel.textContent = 'Image (max 20 MB)';
} else if (tabName === 'file') {
uploadFile.setAttribute('name', 'file');
uploadFile.removeAttribute('accept');
fileIcon.innerHTML = '&#128100;';
fileText.textContent = 'Choose file or drag here';
fileLabel.textContent = 'File (max 20 MB)';
}
}