From 1998e780a74204bb31819954615d0a1be7d2b86e Mon Sep 17 00:00:00 2001 From: DaaanielTV <125688359+DaaanielTV@users.noreply.github.com> Date: Mon, 4 May 2026 22:40:41 +0200 Subject: [PATCH] Harden auth, uploads, and content rendering --- app/__init__.py | 9 ++++++-- app/admin.py | 38 +++++++++++++++++++++++++++------ app/auth.py | 15 ++++++++----- app/models.py | 17 +++++++++++++++ requirements.txt | 4 +++- templates/blog/post_detail.html | 2 +- 6 files changed, 70 insertions(+), 15 deletions(-) diff --git a/app/__init__.py b/app/__init__.py index 3dde394..2cac10e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,6 +4,7 @@ from flask_migrate import Migrate from dotenv import load_dotenv import os +from pathlib import Path # Load environment variables load_dotenv() @@ -17,7 +18,10 @@ def create_app(): app = Flask(__name__) # Configuration - app.config['SECRET_KEY'] = os.getenv('SECRET_KEY', 'change-me-in-production') + secret_key = os.getenv('SECRET_KEY') + if not secret_key: + raise RuntimeError('SECRET_KEY must be set via environment variable.') + app.config['SECRET_KEY'] = secret_key database_url = os.getenv('SQLALCHEMY_DATABASE_URI') if not database_url: @@ -32,6 +36,7 @@ def create_app(): app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['UPLOAD_FOLDER'] = os.getenv('UPLOAD_FOLDER', 'static/uploads') app.config['MAX_CONTENT_LENGTH'] = int(os.getenv('MAX_CONTENT_LENGTH', 16777216)) + Path(app.config['UPLOAD_FOLDER']).mkdir(parents=True, exist_ok=True) # Initialize Flask extensions db.init_app(app) @@ -54,4 +59,4 @@ def create_app(): # Create database tables db.create_all() - return app \ No newline at end of file + return app diff --git a/app/admin.py b/app/admin.py index a9a3241..f040f96 100644 --- a/app/admin.py +++ b/app/admin.py @@ -5,11 +5,33 @@ from werkzeug.utils import secure_filename from slugify import slugify import os +import uuid from sqlalchemy.exc import IntegrityError +from PIL import Image from . import db from .models import Post admin_bp = Blueprint('admin', __name__, url_prefix='/admin') +ALLOWED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} + + +def _save_featured_image(file_storage): + filename = secure_filename(file_storage.filename or '') + _, ext = os.path.splitext(filename.lower()) + if ext not in ALLOWED_IMAGE_EXTENSIONS: + raise ValueError('Unsupported image format. Allowed: jpg, jpeg, png, gif, webp.') + + try: + image = Image.open(file_storage.stream) + image.verify() + file_storage.stream.seek(0) + except Exception as exc: + raise ValueError('Uploaded file is not a valid image.') from exc + + unique_name = f"{uuid.uuid4().hex}{ext}" + destination = os.path.join(current_app.config['UPLOAD_FOLDER'], unique_name) + file_storage.save(destination) + return unique_name def admin_required(f): @wraps(f) @@ -78,9 +100,11 @@ def create_post(): if 'featured_image' in request.files: file = request.files['featured_image'] if file.filename: - filename = secure_filename(file.filename) - file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename)) - post.featured_image = filename + try: + post.featured_image = _save_featured_image(file) + except ValueError as exc: + flash(str(exc)) + return render_template('admin/post_form.html', post=post) db.session.add(post) try: @@ -109,9 +133,11 @@ def edit_post(id): if 'featured_image' in request.files: file = request.files['featured_image'] if file.filename: - filename = secure_filename(file.filename) - file.save(os.path.join(current_app.config['UPLOAD_FOLDER'], filename)) - post.featured_image = filename + try: + post.featured_image = _save_featured_image(file) + except ValueError as exc: + flash(str(exc)) + return render_template('admin/post_form.html', post=post) try: db.session.commit() diff --git a/app/auth.py b/app/auth.py index bedf900..46105d8 100644 --- a/app/auth.py +++ b/app/auth.py @@ -1,10 +1,17 @@ from flask import Blueprint, render_template, redirect, url_for, flash, request from flask_login import login_user, logout_user, login_required, current_user +from urllib.parse import urlparse, urljoin from . import db from .models import User auth_bp = Blueprint('auth', __name__) + +def _is_safe_redirect_url(target): + host_url = urlparse(request.host_url) + redirect_url = urlparse(urljoin(request.host_url, target or '')) + return redirect_url.scheme in ('http', 'https') and host_url.netloc == redirect_url.netloc + @auth_bp.route('/login', methods=['GET', 'POST']) def login(): if current_user.is_authenticated: @@ -18,7 +25,9 @@ def login(): if user and user.check_password(password): login_user(user) next_page = request.args.get('next') - return redirect(next_page if next_page else url_for('blog.index')) + if next_page and _is_safe_redirect_url(next_page): + return redirect(next_page) + return redirect(url_for('blog.index')) flash('Invalid email or password') @@ -47,10 +56,6 @@ def register(): user = User(username=username, email=email) user.set_password(password) - # First user is automatically an admin - if User.query.count() == 0: - user.is_admin = True - db.session.add(user) db.session.commit() diff --git a/app/models.py b/app/models.py index 2b0bdf2..8ca9daa 100644 --- a/app/models.py +++ b/app/models.py @@ -3,6 +3,8 @@ from sqlalchemy import and_, or_ from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash +import bleach +import markdown from . import db, login_manager """ @@ -93,3 +95,18 @@ def public_filter(cls): def __repr__(self): return f'' + + @property + def rendered_content(self): + rendered = markdown.markdown(self.content or '') + allowed_tags = set(bleach.sanitizer.ALLOWED_TAGS).union({ + 'p', 'pre', 'code', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'blockquote', 'hr', 'br', 'ul', 'ol', 'li', 'strong', 'em', + 'a', 'img' + }) + allowed_attrs = { + **bleach.sanitizer.ALLOWED_ATTRIBUTES, + 'a': ['href', 'title', 'rel'], + 'img': ['src', 'alt', 'title'], + } + return bleach.clean(rendered, tags=allowed_tags, attributes=allowed_attrs, protocols={'http', 'https', 'mailto'}, strip=True) diff --git a/requirements.txt b/requirements.txt index f7eeca7..7da9d01 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,6 @@ Markdown==3.5.2 Pillow==10.2.0 gunicorn==21.2.0 email-validator==2.1.1 -alembic==1.13.1 \ No newline at end of file +alembic==1.13.1 +bleach==6.2.0 + diff --git a/templates/blog/post_detail.html b/templates/blog/post_detail.html index 2737f75..5ab1730 100644 --- a/templates/blog/post_detail.html +++ b/templates/blog/post_detail.html @@ -19,7 +19,7 @@

{{ post.title }}

- {{ post.content | safe }} + {{ post.rendered_content | safe }}
{% endblock %}