Harden order endpoint and async mail; improve security defaults
This commit is contained in:
@@ -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 Bestell‑Endpoint `/wawi/order`
|
||||||
|
|
||||||
**Optional**
|
**Optional**
|
||||||
- `APP_API_KEY` – API‑Key für direkten Zugriff auf `/wawi/api/bestand`
|
- `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)
|
## Deployment (systemd + Gunicorn)
|
||||||
1) App nach `/var/www/hellas/wawi` kopieren
|
1) App nach `/var/www/hellas/wawi` kopieren
|
||||||
|
|||||||
52
wawi/app.py
52
wawi/app.py
@@ -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)
|
|||||||
# Session‑Secret für Login‑Cookies (in Produktion unbedingt setzen).
|
# Session‑Secret für Login‑Cookies (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})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user