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_FROM` Absender (z. B. `bestand@hellas.welker.me`)
- `ORDER_TO` Empfänger, mehrere per Komma
- `ORDER_API_KEY` optionaler Key für BestellEndpoint `/wawi/order`
**Optional**
- `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)
1) App nach `/var/www/hellas/wawi` kopieren

View File

@@ -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)
# SessionSecret für LoginCookies (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).
# 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,11 +621,14 @@ def order():
)
msg.set_content(body)
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})