4 Commits

Author SHA1 Message Date
StormyCloud
1a42d0b988 added CSRF 2025-08-17 20:45:38 -05:00
Stormycloud
599e331754 added security updates 2025-08-17 20:06:17 -05:00
5abc6c80ec Update templates/index.html 2025-06-25 09:47:15 -04:00
9ff1e0b0b9 Update templates/index.html 2025-06-25 09:35:06 -04:00
5 changed files with 538 additions and 422 deletions

137
app.py
View File

@@ -3,6 +3,9 @@
import os import os
import uuid import uuid
import sqlite3 import sqlite3
import mimetypes
import secrets
import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from io import BytesIO from io import BytesIO
@@ -13,6 +16,8 @@ from flask import (
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
# Note: For production, consider adding Flask-WTF for CSRF protection
from flask_wtf.csrf import CSRFProtect
from PIL import Image from PIL import Image
from pygments import highlight from pygments import highlight
@@ -30,6 +35,9 @@ load_dotenv()
app = Flask(__name__) app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
# Note: CSRF protection would be initialized here if Flask-WTF is available
csrf = CSRFProtect(app)
app.config['SECRET_KEY'] = os.getenv('SSP_SECRET_KEY') app.config['SECRET_KEY'] = os.getenv('SSP_SECRET_KEY')
app.config['ADMIN_PASSWORD_HASH'] = os.getenv('SSP_ADMIN_PASSWORD_HASH') app.config['ADMIN_PASSWORD_HASH'] = os.getenv('SSP_ADMIN_PASSWORD_HASH')
@@ -44,9 +52,19 @@ app.config['UPLOAD_FOLDER'] = os.getenv('SSP_UPLOAD_FOLDER', 'uploads')
app.config['DATABASE_PATH'] = os.getenv('SSP_DATABASE_PATH', 'database.db') app.config['DATABASE_PATH'] = os.getenv('SSP_DATABASE_PATH', 'database.db')
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB
app.config['FLASK_DEBUG'] = os.getenv('SSP_FLASK_DEBUG', 'False').lower() in ('true', '1', 't') # Ensure debug mode is never enabled in production
debug_env = os.getenv('SSP_FLASK_DEBUG', 'False').lower()
app.config['FLASK_DEBUG'] = debug_env in ('true', '1', 't') and os.getenv('FLASK_ENV') != 'production'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tiff'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tiff'}
ALLOWED_MIME_TYPES = {
'image/png', 'image/jpeg', 'image/gif', 'image/webp',
'image/bmp', 'image/x-icon', 'image/tiff'
}
# Maximum filename length and allowed characters
MAX_FILENAME_LENGTH = 255
SAFE_FILENAME_REGEX = re.compile(r'^[a-zA-Z0-9._-]+$')
# --- Rate Limiting (I2P-aware) --- # --- Rate Limiting (I2P-aware) ---
def i2p_key_func(): def i2p_key_func():
@@ -146,13 +164,70 @@ def cleanup_expired_content():
try: try:
os.remove(path) os.remove(path)
except OSError as e: except OSError as e:
app.logger.error(f"Error removing expired image file {path}: {e}") app.logger.error(f"Error removing expired image file: {sanitize_error_message(e)}")
cur.execute("DELETE FROM images WHERE id = ?", (img_id,)) cur.execute("DELETE FROM images WHERE id = ?", (img_id,))
conn.commit() conn.commit()
conn.close() conn.close()
# --- Utility Functions --- # --- Utility Functions ---
def sanitize_error_message(error_msg):
"""Sanitize error messages to prevent information disclosure"""
# Remove file paths and sensitive information
sanitized = re.sub(r'/[\w/.-]+', '[path]', str(error_msg))
sanitized = re.sub(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '[ip]', sanitized)
return sanitized
def secure_session_key(prefix, identifier):
"""Generate cryptographically secure session keys"""
random_token = secrets.token_hex(16)
return f"{prefix}_{identifier}_{random_token}"
def validate_filename_security(filename):
"""Enhanced filename validation for security"""
if not filename or len(filename) > MAX_FILENAME_LENGTH:
return False
# Check for path traversal attempts
if '..' in filename or '/' in filename or '\\' in filename:
return False
# Check for null bytes and control characters
if '\x00' in filename or any(ord(c) < 32 for c in filename if c != '\t'):
return False
# Ensure filename matches safe pattern
if not SAFE_FILENAME_REGEX.match(filename):
return False
return True
def validate_file_content(file_stream, filename):
"""Validate file content matches expected image format"""
try:
# Reset stream position
file_stream.seek(0)
# Check MIME type
mime_type, _ = mimetypes.guess_type(filename)
if mime_type not in ALLOWED_MIME_TYPES:
return False
# Try to open as image to verify it's actually an image
file_stream.seek(0)
img = Image.open(file_stream)
img.verify() # Verify it's a valid image
# Reset stream for later use
file_stream.seek(0)
return True
except Exception:
return False
def allowed_file(fn): def allowed_file(fn):
"""Enhanced file validation with security checks"""
if not fn or not validate_filename_security(fn):
return False
return '.' in fn and fn.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS return '.' in fn and fn.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_time_left(expiry_str): def get_time_left(expiry_str):
@@ -194,7 +269,7 @@ def process_and_encrypt_image(stream, orig_fn, keep_exif=False):
f.write(encrypted) f.write(encrypted)
return new_fn return new_fn
except Exception as e: except Exception as e:
app.logger.error(f"Image processing failed ({orig_fn}): {e}") app.logger.error(f"Image processing failed: {sanitize_error_message(e)}")
return None return None
@app.context_processor @app.context_processor
@@ -242,7 +317,7 @@ def healthz():
conn.close() conn.close()
db_status = "ok" db_status = "ok"
except Exception as e: except Exception as e:
app.logger.error(f"Health check DB error: {e}") app.logger.error(f"Health check DB error: {sanitize_error_message(e)}")
db_status = "error" db_status = "error"
sched_status = "running" if scheduler.running and scheduler.state == 1 else "stopped" sched_status = "running" if scheduler.running and scheduler.state == 1 else "stopped"
return jsonify(database=db_status, scheduler=sched_status) return jsonify(database=db_status, scheduler=sched_status)
@@ -299,7 +374,7 @@ def upload_image():
return redirect(url_for('index', _anchor='image')) return redirect(url_for('index', _anchor='image'))
file = request.files['file'] file = request.files['file']
if file and allowed_file(file.filename): if file and allowed_file(file.filename) and validate_file_content(file.stream, file.filename):
keep_exif = bool(request.form.get('keep_exif')) keep_exif = bool(request.form.get('keep_exif'))
new_fn = process_and_encrypt_image(file.stream, file.filename, keep_exif) new_fn = process_and_encrypt_image(file.stream, file.filename, keep_exif)
if not new_fn: if not new_fn:
@@ -336,6 +411,11 @@ def upload_paste():
flash('Paste content cannot be empty.', 'error') flash('Paste content cannot be empty.', 'error')
return redirect(url_for('index', _anchor='paste')) return redirect(url_for('index', _anchor='paste'))
# Input validation and size limits
if len(content) > 1024 * 1024: # 1MB limit for pastes
flash('Paste content is too large (max 1MB).', 'error')
return redirect(url_for('index', _anchor='paste'))
now = datetime.now() now = datetime.now()
expiry = now + EXPIRY_MAP.get(request.form.get('expiry', '1h'), timedelta(hours=1)) expiry = now + EXPIRY_MAP.get(request.form.get('expiry', '1h'), timedelta(hours=1))
pw = request.form.get('password') or None pw = request.form.get('password') or None
@@ -369,10 +449,11 @@ def view_image(filename):
abort(404) abort(404)
pw_hash = row['password_hash'] pw_hash = row['password_hash']
if pw_hash and not session.get(f'unlocked_image_{filename}'): session_key = f'unlocked_image_{filename}'
if pw_hash and not session.get(session_key):
if request.method == 'POST': if request.method == 'POST':
if check_password_hash(pw_hash, request.form.get('password', '')): if check_password_hash(pw_hash, request.form.get('password', '')):
session[f'unlocked_image_{filename}'] = True session[session_key] = secrets.token_hex(16)
return redirect(url_for('view_image', filename=filename)) return redirect(url_for('view_image', filename=filename))
flash('Incorrect password.', 'error') flash('Incorrect password.', 'error')
return render_template('view_image.html', password_required=True, filename=filename) return render_template('view_image.html', password_required=True, filename=filename)
@@ -396,10 +477,11 @@ def view_paste(paste_id):
abort(404) abort(404)
pw_hash = row['password_hash'] pw_hash = row['password_hash']
if pw_hash and not session.get(f'unlocked_paste_{paste_id}'): session_key = f'unlocked_paste_{paste_id}'
if pw_hash and not session.get(session_key):
if request.method == 'POST': if request.method == 'POST':
if check_password_hash(pw_hash, request.form.get('password', '')): if check_password_hash(pw_hash, request.form.get('password', '')):
session[f'unlocked_paste_{paste_id}'] = True session[session_key] = secrets.token_hex(16)
return redirect(url_for('view_paste', paste_id=paste_id)) return redirect(url_for('view_paste', paste_id=paste_id))
flash('Incorrect password.', 'error') flash('Incorrect password.', 'error')
return render_template('view_paste.html', password_required=True, paste_id=paste_id) return render_template('view_paste.html', password_required=True, paste_id=paste_id)
@@ -463,8 +545,24 @@ def paste_raw(paste_id):
@app.route('/uploads/<filename>') @app.route('/uploads/<filename>')
def get_upload(filename): def get_upload(filename):
# Enhanced security validation
if not validate_filename_security(filename):
abort(404)
safe_fn = secure_filename(filename) safe_fn = secure_filename(filename)
path = os.path.join(app.config['UPLOAD_FOLDER'], safe_fn)
# Additional path traversal protection
if safe_fn != filename or not safe_fn:
abort(404)
# Ensure the file path is within the upload directory
upload_dir = os.path.abspath(app.config['UPLOAD_FOLDER'])
file_path = os.path.abspath(os.path.join(upload_dir, safe_fn))
if not file_path.startswith(upload_dir + os.sep):
abort(404)
path = file_path
db = get_db() db = get_db()
row = db.execute("SELECT * FROM images WHERE id = ?", (safe_fn,)).fetchone() row = db.execute("SELECT * FROM images WHERE id = ?", (safe_fn,)).fetchone()
@@ -494,7 +592,7 @@ def get_upload(filename):
data = fernet.decrypt(encrypted) data = fernet.decrypt(encrypted)
return send_file(BytesIO(data), mimetype='image/webp') return send_file(BytesIO(data), mimetype='image/webp')
except Exception as e: except Exception as e:
app.logger.error(f"Error serving image {safe_fn}: {e}") app.logger.error(f"Error serving image: {sanitize_error_message(e)}")
abort(500) abort(500)
@app.route('/admin/delete/image/<filename>', methods=['POST']) @app.route('/admin/delete/image/<filename>', methods=['POST'])
@@ -509,7 +607,8 @@ def delete_image(filename):
db.commit() db.commit()
flash(f'Image "{safe}" has been deleted.', 'success') flash(f'Image "{safe}" has been deleted.', 'success')
except Exception as e: except Exception as e:
flash(f'Error deleting image file: {e}', 'error') flash('Error deleting image file.', 'error')
app.logger.error(f'Error deleting image file: {sanitize_error_message(e)}')
return redirect(url_for('admin_dashboard')) return redirect(url_for('admin_dashboard'))
@app.route('/admin/delete/paste/<paste_id>', methods=['POST']) @app.route('/admin/delete/paste/<paste_id>', methods=['POST'])
@@ -521,18 +620,20 @@ def delete_paste(paste_id):
db.commit() db.commit()
flash(f'Paste "{paste_id}" has been deleted.', 'success') flash(f'Paste "{paste_id}" has been deleted.', 'success')
except Exception as e: except Exception as e:
flash(f'Error deleting paste: {e}', 'error') flash('Error deleting paste.', 'error')
app.logger.error(f'Error deleting paste: {sanitize_error_message(e)}')
return redirect(url_for('admin_dashboard')) return redirect(url_for('admin_dashboard'))
# --- API Routes --- # --- API Routes ---
@app.route('/api/upload/image', methods=['POST']) @app.route('/api/upload/image', methods=['POST'])
@limiter.limit("50 per hour") @limiter.limit("50 per hour")
@csrf.exempt
def api_upload_image(): def api_upload_image():
if 'file' not in request.files or request.files['file'].filename == '': if 'file' not in request.files or request.files['file'].filename == '':
return jsonify(error="No file selected"), 400 return jsonify(error="No file selected"), 400
file = request.files['file'] file = request.files['file']
if file and allowed_file(file.filename): if file and allowed_file(file.filename) and validate_file_content(file.stream, file.filename):
new_fn = process_and_encrypt_image(file.stream, file.filename, bool(request.form.get('keep_exif'))) new_fn = process_and_encrypt_image(file.stream, file.filename, bool(request.form.get('keep_exif')))
if not new_fn: return jsonify(error="Failed to process image"), 500 if not new_fn: return jsonify(error="Failed to process image"), 500
@@ -557,13 +658,21 @@ def api_upload_image():
@app.route('/api/upload/paste', methods=['POST']) @app.route('/api/upload/paste', methods=['POST'])
@limiter.limit("100 per hour") @limiter.limit("100 per hour")
@csrf.exempt
def api_upload_paste(): def api_upload_paste():
if not request.is_json: return jsonify(error="Request must be JSON"), 400 if not request.is_json: return jsonify(error="Request must be JSON"), 400
data = request.get_json() data = request.get_json()
if not isinstance(data, dict):
return jsonify(error="Invalid JSON data"), 400
content = data.get('content', '').strip() content = data.get('content', '').strip()
if not content: return jsonify(error="Paste content is missing"), 400 if not content: return jsonify(error="Paste content is missing"), 400
# Input validation and size limits
if len(content) > 1024 * 1024: # 1MB limit for pastes
return jsonify(error="Paste content is too large (max 1MB)"), 400
now = datetime.now() now = datetime.now()
expiry = now + EXPIRY_MAP.get(data.get('expiry', '1h'), timedelta(hours=1)) expiry = now + EXPIRY_MAP.get(data.get('expiry', '1h'), timedelta(hours=1))
pw = data.get('password') pw = data.get('password')

View File

@@ -60,6 +60,7 @@
<td class="font-mono text-sm">{{ image[2] }}</td> <td class="font-mono text-sm">{{ image[2] }}</td>
<td> <td>
<form action="{{ url_for('delete_image', filename=image[0]) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this image?');"> <form action="{{ url_for('delete_image', filename=image[0]) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this image?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn-danger text-white font-bold py-1 px-3 rounded text-sm">Delete</button> <button type="submit" class="btn-danger text-white font-bold py-1 px-3 rounded text-sm">Delete</button>
</form> </form>
</td> </td>
@@ -94,6 +95,7 @@
<td class="font-mono text-sm">{{ paste[3] }}</td> <td class="font-mono text-sm">{{ paste[3] }}</td>
<td> <td>
<form action="{{ url_for('delete_paste', paste_id=paste[0]) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this paste?');"> <form action="{{ url_for('delete_paste', paste_id=paste[0]) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this paste?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn-danger text-white font-bold py-1 px-3 rounded text-sm">Delete</button> <button type="submit" class="btn-danger text-white font-bold py-1 px-3 rounded text-sm">Delete</button>
</form> </form>
</td> </td>
@@ -120,6 +122,7 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<form method="POST" action="{{ url_for('admin_dashboard') }}"> <form method="POST" action="{{ url_for('admin_dashboard') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-6"> <div class="mb-6">
<label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label> <label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label>
<input type="password" name="password" id="password" class="w-full p-2 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500" required> <input type="password" name="password" id="password" class="w-full p-2 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500" required>

View File

@@ -140,6 +140,7 @@
<div class="noscript-forms-container"> <div class="noscript-forms-container">
<div id="image-form" class="content-container rounded-lg p-8 shadow-lg tab-content"> <div id="image-form" class="content-container rounded-lg p-8 shadow-lg tab-content">
<form action="/upload/image" method="POST" enctype="multipart/form-data"> <form action="/upload/image" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<h2 class="text-2xl font-semibold mb-6 text-white">Upload an Image</h2> <h2 class="text-2xl font-semibold mb-6 text-white">Upload an Image</h2>
<div class="mb-6"> <div class="mb-6">
@@ -195,6 +196,7 @@
<div id="paste-form" class="content-container rounded-lg p-8 shadow-lg hidden tab-content"> <div id="paste-form" class="content-container rounded-lg p-8 shadow-lg hidden tab-content">
<form action="/upload/paste" method="POST"> <form action="/upload/paste" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<h2 class="text-2xl font-semibold mb-6 text-white">Create a Paste</h2> <h2 class="text-2xl font-semibold mb-6 text-white">Create a Paste</h2>
<div class="mb-6"> <div class="mb-6">
<label for="paste-content" class="block text-gray-300 text-sm font-bold mb-2">Paste Content:</label> <label for="paste-content" class="block text-gray-300 text-sm font-bold mb-2">Paste Content:</label>
@@ -313,7 +315,7 @@ http://{{ request.host }}/api/upload/paste</pre>
</div> </div>
<div class="text-center text-xs text-gray-500 mt-6 mb-8"> <div class="text-center text-xs text-gray-500 mt-6 mb-8">
<a href="https://github.com/your-username/your-repo-name" target="_blank" rel="noopener noreferrer" class="hover:text-gray-400 transition-colors"> <a href="http://git.idk.i2p/stormycloud/drop.i2p/releases/tag/v1.1" target="_blank" rel="noopener noreferrer" class="hover:text-gray-400 transition-colors">
Version 1.1 Version 1.1
</a> </a>
</div> </div>

View File

@@ -35,6 +35,7 @@
<div class="content-container rounded-lg p-8 shadow-lg"> <div class="content-container rounded-lg p-8 shadow-lg">
<h2 class="text-2xl font-semibold text-white mb-6">Enter Password to View Image</h2> <h2 class="text-2xl font-semibold text-white mb-6">Enter Password to View Image</h2>
<form method="POST"> <form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-6"> <div class="mb-6">
<label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label> <label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label>
<input type="password" name="password" id="password" <input type="password" name="password" id="password"

View File

@@ -111,6 +111,7 @@
<div class="content-container rounded-lg p-10 shadow-lg"></div> <div class="content-container rounded-lg p-10 shadow-lg"></div>
<h2 class="text-2xl font-semibold text-white mb-6">Enter Password to View Paste</h2> <h2 class="text-2xl font-semibold text-white mb-6">Enter Password to View Paste</h2>
<form method="POST"> <form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-6"> <div class="mb-6">
<label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label> <label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label>
<input type="password" name="password" id="password" <input type="password" name="password" id="password"