From a61e96e8b8d2da66c00e4dd52ff748d70e00303b Mon Sep 17 00:00:00 2001 From: Bjoern Welker Date: Fri, 30 Jan 2026 12:08:08 +0100 Subject: [PATCH] Harden order endpoint and async mail; improve security defaults --- README.md | 2 ++ wawi/app.py | 52 +++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index bce4aab..06ea4fe 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,11 @@ Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaW - `SMTP_USER` / `SMTP_PASS` – SMTP Login - `SMTP_FROM` – Absender (z. B. `bestand@hellas.welker.me`) - `ORDER_TO` – Empfänger, mehrere per Komma +- `ORDER_API_KEY` – optionaler Key für Bestell‑Endpoint `/wawi/order` **Optional** - `APP_API_KEY` – API‑Key für direkten Zugriff auf `/wawi/api/bestand` +- `COOKIE_SECURE` – `1` (default) setzt Secure‑Cookie, `0` deaktiviert für http ## Deployment (systemd + Gunicorn) 1) App nach `/var/www/hellas/wawi` kopieren diff --git a/wawi/app.py b/wawi/app.py index 906680f..fd183d5 100644 --- a/wawi/app.py +++ b/wawi/app.py @@ -4,6 +4,8 @@ import os import sqlite3 import secrets import smtplib +import time +import threading from uuid import uuid4 from email.message import EmailMessage from functools import wraps @@ -28,12 +30,18 @@ app = Flask(__name__, static_url_path=STATIC_URL_PATH) # Session‑Secret für Login‑Cookies (in Produktion unbedingt setzen). app.secret_key = os.environ.get("SECRET_KEY", "change-me") app.config["SESSION_COOKIE_SAMESITE"] = "Lax" -app.config["SESSION_COOKIE_SECURE"] = False +app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "1") == "1" +app.config["SESSION_COOKIE_HTTPONLY"] = True app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024 bp = Blueprint("bp", __name__) UPLOAD_DIR.mkdir(parents=True, exist_ok=True) +_DB_INIT_DONE = False +_RATE_LIMIT = {} +_RATE_WINDOW = 60 +_RATE_MAX = 15 + def get_db() -> sqlite3.Connection: if "db" not in g: @@ -146,8 +154,11 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None: @app.before_request def ensure_db() -> None: - # Erstellt Tabellen, wenn sie fehlen (bei jedem Request idempotent). - init_db() + # Erstellt Tabellen einmal pro Worker. + global _DB_INIT_DONE + if not _DB_INIT_DONE: + init_db() + _DB_INIT_DONE = True def now_iso() -> str: @@ -160,6 +171,8 @@ def save_upload(file) -> str | None: ext = os.path.splitext(file.filename)[1].lower() if ext not in ALLOWED_EXT: return None + if not (file.mimetype or "").startswith("image/"): + return None name = secure_filename(Path(file.filename).stem) or "image" safe_name = f"{name}-{uuid4().hex}{ext}" dest = UPLOAD_DIR / safe_name @@ -214,6 +227,16 @@ def api_key_required(fn): return wrapper +def rate_limited(ip: str) -> bool: + now = time.time() + bucket = _RATE_LIMIT.setdefault(ip, []) + bucket[:] = [t for t in bucket if now - t < _RATE_WINDOW] + if len(bucket) >= _RATE_MAX: + return True + bucket.append(now) + return False + + @bp.route("/") @login_required def index(): @@ -534,10 +557,22 @@ def build_bestand() -> list[dict]: @bp.route("/order", methods=["POST"]) def order(): + ip = request.headers.get("X-Forwarded-For", request.remote_addr or "unknown").split(",")[0].strip() + if rate_limited(ip): + return jsonify({"error": "Zu viele Anfragen."}), 429 + + expected_key = os.environ.get("ORDER_API_KEY", "") + if expected_key: + provided = request.headers.get("X-Order-Key") or request.args.get("key") or "" + if provided != expected_key: + return jsonify({"error": "Unauthorized"}), 401 + data = request.get_json(silent=True) or request.form required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"] if any(not (data.get(k) or "").strip() for k in required): return jsonify({"error": "Pflichtfelder fehlen."}), 400 + if int(data.get("menge") or 0) <= 0: + return jsonify({"error": "Menge muss größer als 0 sein."}), 400 db = get_db() db.execute( @@ -586,10 +621,13 @@ def order(): ) msg.set_content(body) - with smtplib.SMTP(smtp_host, smtp_port) as server: - server.starttls() - server.login(smtp_user, smtp_pass) - server.send_message(msg, to_addrs=recipients) + def _send(): + with smtplib.SMTP(smtp_host, smtp_port) as server: + server.starttls() + server.login(smtp_user, smtp_pass) + server.send_message(msg, to_addrs=recipients) + + threading.Thread(target=_send, daemon=True).start() return jsonify({"ok": True})