Security: headers, input validation, db connection safety
This commit is contained in:
parent
89a7c33adb
commit
8339d0f2d9
42
app.py
42
app.py
|
|
@ -34,7 +34,29 @@ app.config['SECRET_KEY'] = _server.get('secret_key', 'change-me')
|
||||||
|
|
||||||
DATABASE = _db_cfg.get('path', 'bastebin.db')
|
DATABASE = _db_cfg.get('path', 'bastebin.db')
|
||||||
MAX_ENCRYPTED_BYTES = _pastes.get('max_size_bytes', 2 * 1024 * 1024)
|
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_-]+$')
|
_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 ─────────────────────────────────────────────────────────────
|
# ── Jinja helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -120,6 +142,12 @@ def create_paste():
|
||||||
return jsonify({'error': 'content must be a non-empty string'}), 400
|
return jsonify({'error': 'content must be a non-empty string'}), 400
|
||||||
if len(content.encode('utf-8')) > MAX_ENCRYPTED_BYTES:
|
if len(content.encode('utf-8')) > MAX_ENCRYPTED_BYTES:
|
||||||
return jsonify({'error': 'Paste too large'}), 413
|
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})
|
store_data = json.dumps({'title': title, 'content': content, 'language': language})
|
||||||
else:
|
else:
|
||||||
return jsonify({'error': 'Provide either encrypted_data or content'}), 400
|
return jsonify({'error': 'Provide either encrypted_data or content'}), 400
|
||||||
|
|
@ -141,26 +169,34 @@ def create_paste():
|
||||||
|
|
||||||
paste_id = generate_paste_id()
|
paste_id = generate_paste_id()
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
'INSERT INTO pastes (id, encrypted_data, expires_at) VALUES (?, ?, ?)',
|
'INSERT INTO pastes (id, encrypted_data, expires_at) VALUES (?, ?, ?)',
|
||||||
(paste_id, store_data, expires_at)
|
(paste_id, store_data, expires_at)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'paste_id': paste_id, 'url': f'/{paste_id}'})
|
return jsonify({'paste_id': paste_id, 'url': f'/{paste_id}'})
|
||||||
|
|
||||||
@app.route('/<paste_id>')
|
@app.route('/<string:paste_id>')
|
||||||
def view_paste(paste_id):
|
def view_paste(paste_id):
|
||||||
|
if not _PASTE_ID_RE.match(paste_id):
|
||||||
|
abort(404)
|
||||||
paste = _get_paste_or_abort(paste_id)
|
paste = _get_paste_or_abort(paste_id)
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,))
|
conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone()
|
paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone()
|
||||||
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return render_template('view.html', paste=paste)
|
return render_template('view.html', paste=paste)
|
||||||
|
|
||||||
@app.route('/<paste_id>/raw')
|
@app.route('/<string:paste_id>/raw')
|
||||||
def view_paste_raw(paste_id):
|
def view_paste_raw(paste_id):
|
||||||
|
if not _PASTE_ID_RE.match(paste_id):
|
||||||
|
abort(404)
|
||||||
paste = _get_paste_or_abort(paste_id)
|
paste = _get_paste_or_abort(paste_id)
|
||||||
stored = paste['encrypted_data']
|
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):
|
if not re.match(r'^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$', stored):
|
||||||
try:
|
try:
|
||||||
data = json.loads(stored)
|
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):
|
except (json.JSONDecodeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue