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:
parent
2774a748bc
commit
3ea2ab4258
@ -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
122
app.py
@ -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]
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
flask>=3.0.0
|
||||
gunicorn>=21.2.0
|
||||
flask==3.1.0
|
||||
gunicorn==23.0.0
|
||||
werkzeug==3.1.3
|
||||
|
||||
@ -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">👤</span>
|
||||
<span class="file-text">Choose file or drag here</span>
|
||||
</label>
|
||||
<span class="file-icon">👤</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 = '📷';
|
||||
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 = '👤';
|
||||
fileText.textContent = 'Choose file or drag here';
|
||||
fileLabel.textContent = 'File (max 20 MB)';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user