Harden order endpoint and async mail; improve security defaults

This commit is contained in:
2026-01-30 12:08:08 +01:00
parent a7d058b57c
commit a61e96e8b8
2 changed files with 47 additions and 7 deletions

View File

@@ -56,9 +56,11 @@ Wenn du diese Datei auf einem Webserver auslieferst, stelle sicher, dass die WaW
- `SMTP_USER` / `SMTP_PASS` SMTP Login - `SMTP_USER` / `SMTP_PASS` SMTP Login
- `SMTP_FROM` Absender (z. B. `bestand@hellas.welker.me`) - `SMTP_FROM` Absender (z. B. `bestand@hellas.welker.me`)
- `ORDER_TO` Empfänger, mehrere per Komma - `ORDER_TO` Empfänger, mehrere per Komma
- `ORDER_API_KEY` optionaler Key für BestellEndpoint `/wawi/order`
**Optional** **Optional**
- `APP_API_KEY` APIKey für direkten Zugriff auf `/wawi/api/bestand` - `APP_API_KEY` APIKey für direkten Zugriff auf `/wawi/api/bestand`
- `COOKIE_SECURE` `1` (default) setzt SecureCookie, `0` deaktiviert für http
## Deployment (systemd + Gunicorn) ## Deployment (systemd + Gunicorn)
1) App nach `/var/www/hellas/wawi` kopieren 1) App nach `/var/www/hellas/wawi` kopieren

View File

@@ -4,6 +4,8 @@ import os
import sqlite3 import sqlite3
import secrets import secrets
import smtplib import smtplib
import time
import threading
from uuid import uuid4 from uuid import uuid4
from email.message import EmailMessage from email.message import EmailMessage
from functools import wraps from functools import wraps
@@ -28,12 +30,18 @@ app = Flask(__name__, static_url_path=STATIC_URL_PATH)
# SessionSecret für LoginCookies (in Produktion unbedingt setzen). # SessionSecret für LoginCookies (in Produktion unbedingt setzen).
app.secret_key = os.environ.get("SECRET_KEY", "change-me") app.secret_key = os.environ.get("SECRET_KEY", "change-me")
app.config["SESSION_COOKIE_SAMESITE"] = "Lax" 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 app.config["MAX_CONTENT_LENGTH"] = 5 * 1024 * 1024
bp = Blueprint("bp", __name__) bp = Blueprint("bp", __name__)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True) 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: def get_db() -> sqlite3.Connection:
if "db" not in g: if "db" not in g:
@@ -146,8 +154,11 @@ def ensure_orders_columns(db: sqlite3.Connection) -> None:
@app.before_request @app.before_request
def ensure_db() -> None: def ensure_db() -> None:
# Erstellt Tabellen, wenn sie fehlen (bei jedem Request idempotent). # Erstellt Tabellen einmal pro Worker.
init_db() global _DB_INIT_DONE
if not _DB_INIT_DONE:
init_db()
_DB_INIT_DONE = True
def now_iso() -> str: def now_iso() -> str:
@@ -160,6 +171,8 @@ def save_upload(file) -> str | None:
ext = os.path.splitext(file.filename)[1].lower() ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXT: if ext not in ALLOWED_EXT:
return None return None
if not (file.mimetype or "").startswith("image/"):
return None
name = secure_filename(Path(file.filename).stem) or "image" name = secure_filename(Path(file.filename).stem) or "image"
safe_name = f"{name}-{uuid4().hex}{ext}" safe_name = f"{name}-{uuid4().hex}{ext}"
dest = UPLOAD_DIR / safe_name dest = UPLOAD_DIR / safe_name
@@ -214,6 +227,16 @@ def api_key_required(fn):
return wrapper 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("/") @bp.route("/")
@login_required @login_required
def index(): def index():
@@ -534,10 +557,22 @@ def build_bestand() -> list[dict]:
@bp.route("/order", methods=["POST"]) @bp.route("/order", methods=["POST"])
def order(): 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 data = request.get_json(silent=True) or request.form
required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"] required = ["name", "handy", "mannschaft", "artikel", "groesse", "menge"]
if any(not (data.get(k) or "").strip() for k in required): if any(not (data.get(k) or "").strip() for k in required):
return jsonify({"error": "Pflichtfelder fehlen."}), 400 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 = get_db()
db.execute( db.execute(
@@ -586,10 +621,13 @@ def order():
) )
msg.set_content(body) msg.set_content(body)
with smtplib.SMTP(smtp_host, smtp_port) as server: def _send():
server.starttls() with smtplib.SMTP(smtp_host, smtp_port) as server:
server.login(smtp_user, smtp_pass) server.starttls()
server.send_message(msg, to_addrs=recipients) server.login(smtp_user, smtp_pass)
server.send_message(msg, to_addrs=recipients)
threading.Thread(target=_send, daemon=True).start()
return jsonify({"ok": True}) return jsonify({"ok": True})