From 8339d0f2d940f16ac518ae8f67ee5a1cddeffd0b Mon Sep 17 00:00:00 2001 From: ComputerTech Date: Fri, 27 Mar 2026 01:30:48 +0000 Subject: [PATCH] Security: headers, input validation, db connection safety --- app.py | 62 ++++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/app.py b/app.py index 21d5914..a449e5e 100644 --- a/app.py +++ b/app.py @@ -34,7 +34,29 @@ app.config['SECRET_KEY'] = _server.get('secret_key', 'change-me') DATABASE = _db_cfg.get('path', 'bastebin.db') MAX_ENCRYPTED_BYTES = _pastes.get('max_size_bytes', 2 * 1024 * 1024) +MAX_TITLE_BYTES = 200 _ENCRYPTED_RE = re.compile(r'^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$') +_LANGUAGE_RE = re.compile(r'^[a-z0-9_+-]{1,32}$') +_PASTE_ID_RE = re.compile(r'^[0-9a-f]{4,32}$') + +# ── Security headers ───────────────────────────────────────────────────────── + +@app.after_request +def set_security_headers(response): + response.headers['X-Frame-Options'] = 'DENY' + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['Referrer-Policy'] = 'same-origin' + response.headers['Content-Security-Policy'] = ( + "default-src 'self'; " + "script-src 'self' https://cdnjs.cloudflare.com 'unsafe-inline'; " + "style-src 'self' https://cdnjs.cloudflare.com 'unsafe-inline'; " + "img-src 'self' data:; " + "connect-src 'self' blob:; " + "object-src 'none'; " + "base-uri 'self'; " + "frame-ancestors 'none';" + ) + return response # ── Jinja helpers ───────────────────────────────────────────────────────────── @@ -120,6 +142,12 @@ def create_paste(): return jsonify({'error': 'content must be a non-empty string'}), 400 if len(content.encode('utf-8')) > MAX_ENCRYPTED_BYTES: return jsonify({'error': 'Paste too large'}), 413 + # Enforce server-side limits on title and language + if not isinstance(title, str): + title = 'Untitled' + title = title.strip()[:MAX_TITLE_BYTES] or 'Untitled' + if not isinstance(language, str) or not _LANGUAGE_RE.match(language): + language = _pastes.get('default_language', 'text') store_data = json.dumps({'title': title, 'content': content, 'language': language}) else: return jsonify({'error': 'Provide either encrypted_data or content'}), 400 @@ -141,26 +169,34 @@ def create_paste(): paste_id = generate_paste_id() conn = get_db_connection() - conn.execute( - 'INSERT INTO pastes (id, encrypted_data, expires_at) VALUES (?, ?, ?)', - (paste_id, store_data, expires_at) - ) - conn.commit() - conn.close() + try: + conn.execute( + 'INSERT INTO pastes (id, encrypted_data, expires_at) VALUES (?, ?, ?)', + (paste_id, store_data, expires_at) + ) + conn.commit() + finally: + conn.close() return jsonify({'paste_id': paste_id, 'url': f'/{paste_id}'}) -@app.route('/') +@app.route('/') def view_paste(paste_id): + if not _PASTE_ID_RE.match(paste_id): + abort(404) paste = _get_paste_or_abort(paste_id) conn = get_db_connection() - conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,)) - conn.commit() - paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone() - conn.close() + try: + conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,)) + conn.commit() + paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone() + finally: + conn.close() return render_template('view.html', paste=paste) -@app.route('//raw') +@app.route('//raw') def view_paste_raw(paste_id): + if not _PASTE_ID_RE.match(paste_id): + abort(404) paste = _get_paste_or_abort(paste_id) stored = paste['encrypted_data'] @@ -168,7 +204,7 @@ def view_paste_raw(paste_id): if not re.match(r'^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$', stored): try: data = json.loads(stored) - return Response(data.get('content', ''), mimetype='text/plain; charset=utf-8') + return Response(data.get('content', ''), mimetype='text/plain') except (json.JSONDecodeError, TypeError): pass