diff --git a/Dockerfile b/Dockerfile index 37bf694..dd3eb32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app.py b/app.py index 182776c..6cecc08 100644 --- a/app.py +++ b/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] diff --git a/requirements.txt b/requirements.txt index 9ccb73f..4636aba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -flask>=3.0.0 -gunicorn>=21.2.0 \ No newline at end of file +flask==3.1.0 +gunicorn==23.0.0 +werkzeug==3.1.3 diff --git a/templates/index.html b/templates/index.html index 7d81ac1..6d084ed 100644 --- a/templates/index.html +++ b/templates/index.html @@ -25,6 +25,7 @@