From 07bcfede75cd1363fbfe9c711af5b31ee6694256 Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 28 Feb 2026 14:47:33 +0100 Subject: [PATCH] Add more security and audit --- .env.example | 4 + SECURITY.md | 89 ++++++++++++++++++++++ backend/Dockerfile | 10 ++- backend/app.py | 160 ++++++++++++++++++++++++++++++++------- backend/models.py | 3 +- backend/requirements.txt | 20 ++--- backend/routes/admin.py | 3 +- backend/routes/api.py | 10 ++- backend/routes/auth.py | 26 ++++++- docker-compose.yml | 14 ++++ nginx/nginx.conf | 123 +++++++++++++++++++++++------- 11 files changed, 386 insertions(+), 76 deletions(-) create mode 100644 SECURITY.md diff --git a/.env.example b/.env.example index a2e5dd1..5d1740a 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,10 @@ MICROSOFT_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Naam van de scholengroep — verschijnt op de loginpagina ORG_NAME=GO! Scholengroep 2 +# Redis wachtwoord — beschermt de rate limiter state +# Genereer met: python3 -c "import secrets; print(secrets.token_hex(24))" +REDIS_PASSWORD=verander_dit_redis_wachtwoord + # Docker image uit de Gitea registry (wordt ingevuld door CI/CD) # Lokaal builden: laat leeg of zet op 'leerdoelen-backend:local' BACKEND_IMAGE=gitea.jouwdomein.be/jouw-org/leerdoelen-tracker:latest diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..f5caa34 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,89 @@ +# Security beleid + +## Kwetsbaarheden melden + +Gevonden een beveiligingsprobleem? Stuur een e-mail naar de systeembeheerder van jouw scholengroep. +Voeg zo veel mogelijk detail toe: stappen om te reproduceren, impact, en eventueel een proof-of-concept. + +Publiceer kwetsbaarheden **niet** publiek voordat ze zijn opgelost. + +--- + +## Beveiligingsmaatregelen in deze applicatie + +### Authenticatie +- Primaire login via Microsoft Entra ID (Azure AD) — geen wachtwoorden opgeslagen voor gewone gebruikers +- Superadmin wachtwoord gehasht met **scrypt** (sterk geheugenintensief algoritme) +- OAuth2 state parameter validatie — beschermt tegen CSRF in OAuth flow +- `?next=` redirect parameter gevalideerd — beschermt tegen open redirect aanvallen +- Session cookies: `HttpOnly`, `Secure` (HTTPS), `SameSite=Lax` + +### Rate limiting +| Endpoint | Limiet | +|----------|--------| +| Alle `/auth/*` routes | 10 per minuut per IP | +| Superadmin login | 10/min + 30/uur per IP | +| API endpoints | 120 per minuut per IP | +| Doelen upload | 5 per minuut per IP | +| Setup endpoint | 5 per minuut per IP | + +Rate limiting via **Redis** (persistent over meerdere workers). +Nginx voegt een extra laag rate limiting toe vóór Flask. + +### HTTP Security headers +Via Flask-Talisman + Nginx: +- `Content-Security-Policy` — nonce-based, geen unsafe-inline scripts +- `Strict-Transport-Security` — HSTS 1 jaar, incl. subdomains +- `X-Frame-Options: DENY` — clickjacking preventie +- `X-Content-Type-Options: nosniff` +- `Referrer-Policy: strict-origin-when-cross-origin` +- `Permissions-Policy` — geen toegang tot camera/microfoon/locatie +- `form-action: 'self'` — voorkomt form hijacking +- `base-uri: 'self'` — voorkomt base tag injection +- `object-src: 'none'` — geen Flash of plugins + +### Autorisatie +- Rolgebaseerde toegangscontrole (superadmin → scholengroep_ict → school_ict → director → teacher) +- Elke API route heeft expliciete rolauthenticatie decorator +- School-isolatie: gebruikers kunnen enkel data van hun eigen school zien +- Auditlog van alle beheerhandelingen + +### Database +- Parameterized queries via SQLAlchemy ORM — geen raw SQL met gebruikersinput +- Non-root database gebruiker +- PostgreSQL container niet publiek blootgesteld (intern Docker netwerk) + +### Infrastructuur +- Flask draait als non-root gebruiker (`appuser`) in Docker container +- Read-only volume mount voor doelen JSON bestanden +- Redis beveiligd met wachtwoord +- Backend enkel bereikbaar via `127.0.0.1` (niet publiek) +- Nginx als reverse proxy met request size limiting en timeouts (Slowloris bescherming) + +--- + +## Dependency updates + +Controleer regelmatig op kwetsbaarheden in dependencies: + +```bash +pip install pip-audit +pip-audit -r backend/requirements.txt +``` + +Python base image: pin op specifieke patch versie in `Dockerfile`. +Controleer updates op: https://hub.docker.com/_/python + +--- + +## Checklist voor nieuwe deployment + +- [ ] `SECRET_KEY` gegenereerd met `python3 -c "import secrets; print(secrets.token_hex(32))"` +- [ ] `POSTGRES_PASSWORD` sterk en uniek +- [ ] `REDIS_PASSWORD` ingesteld +- [ ] `BASE_URL` correct ingesteld op HTTPS URL +- [ ] SSL/TLS certificaat aanwezig (Let's Encrypt via Certbot) +- [ ] Microsoft Entra ID app registratie correct geconfigureerd +- [ ] Superadmin wachtwoord ingesteld via `/auth/setup` (min. 12 tekens) +- [ ] `/auth/setup` endpoint niet meer toegankelijk na setup (wordt automatisch geblokkeerd) +- [ ] Firewall: enkel poorten 80 en 443 publiek open diff --git a/backend/Dockerfile b/backend/Dockerfile index 230e767..a6ac10a 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,7 @@ -FROM python:3.12-slim +# Pin op een specifieke patch versie voor reproduceerbare builds. +# Controleer regelmatig op https://hub.docker.com/_/python voor updates. +# Bij elke Python security patch: versienummer hier bijwerken + opnieuw builden. +FROM python:3.12.9-slim WORKDIR /app @@ -8,9 +11,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ gcc \ && rm -rf /var/lib/apt/lists/* -# Python dependencies +# Python dependencies — upgrade pip zelf ook voor security fixes COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -r requirements.txt # App code + entrypoint (chmod als root, vóór USER switch) COPY . . diff --git a/backend/app.py b/backend/app.py index 3138d58..6c4e4aa 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,65 +1,168 @@ import os -from flask import Flask +import logging +from flask import Flask, jsonify, request from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager from flask_migrate import Migrate +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from flask_talisman import Talisman from werkzeug.middleware.proxy_fix import ProxyFix -db = SQLAlchemy() +logger = logging.getLogger(__name__) + +db = SQLAlchemy() login_manager = LoginManager() -migrate = Migrate() +migrate = Migrate() +limiter = Limiter(key_func=get_remote_address, default_limits=[]) def create_app(): app = Flask(__name__, template_folder='templates', static_folder='static') - # Config - app.config['SECRET_KEY'] = os.environ['SECRET_KEY'] - app.config['SQLALCHEMY_DATABASE_URI'] = os.environ['DATABASE_URL'] + # ── Config ──────────────────────────────────────────────────────────────── + app.config['SECRET_KEY'] = os.environ['SECRET_KEY'] + app.config['SQLALCHEMY_DATABASE_URI'] = os.environ['DATABASE_URL'] app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - app.config['BASE_URL'] = os.environ.get('BASE_URL', 'http://localhost') - app.config['ORG_NAME'] = os.environ.get('ORG_NAME', 'GO! Scholengroep') + app.config['BASE_URL'] = os.environ.get('BASE_URL', 'http://localhost') + app.config['ORG_NAME'] = os.environ.get('ORG_NAME', 'GO! Scholengroep') - # OAuth2 config (voor later) - app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID') - app.config['MICROSOFT_CLIENT_SECRET'] = os.environ.get('MICROSOFT_CLIENT_SECRET') - app.config['MICROSOFT_TENANT_ID'] = os.environ.get('MICROSOFT_TENANT_ID', 'common') - app.config['GOOGLE_CLIENT_ID'] = os.environ.get('GOOGLE_CLIENT_ID') - app.config['GOOGLE_CLIENT_SECRET'] = os.environ.get('GOOGLE_CLIENT_SECRET') + # OAuth2 + app.config['MICROSOFT_CLIENT_ID'] = os.environ.get('MICROSOFT_CLIENT_ID') + app.config['MICROSOFT_CLIENT_SECRET'] = os.environ.get('MICROSOFT_CLIENT_SECRET') + app.config['MICROSOFT_TENANT_ID'] = os.environ.get('MICROSOFT_TENANT_ID', 'common') - # ProxyFix: Flask zit achter nginx als reverse proxy. - # x_for=1, x_proto=1 zorgt dat Flask de echte client IP en https ziet. + # Session cookie beveiliging + is_https = app.config['BASE_URL'].startswith('https') + app.config['SESSION_COOKIE_SECURE'] = is_https + app.config['SESSION_COOKIE_HTTPONLY'] = True + app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # Lax ipv Strict: compatibel met OAuth redirect + app.config['REMEMBER_COOKIE_SECURE'] = is_https + app.config['REMEMBER_COOKIE_HTTPONLY'] = True + app.config['REMEMBER_COOKIE_SAMESITE'] = 'Lax' + app.config['REMEMBER_COOKIE_DURATION'] = 86400 * 8 # 8 dagen max (was: onbeperkt) + + # ── Rate limit handler ─────────────────────────────────────────────────── + def _rate_limit_handler(e): + logger.warning( + f"Rate limit overschreden: {request.method} {request.path} " + f"van {request.remote_addr}" + ) + return jsonify({ + 'error': 'Te veel verzoeken. Probeer later opnieuw.', + 'retry_after': e.retry_after, + }), 429 + + # ── ProxyFix (Flask zit achter nginx) ──────────────────────────────────── app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) - # Extensions + # ── Rate limiter ────────────────────────────────────────────────────────── + redis_url = os.environ.get('REDIS_URL', '') + if not redis_url: + logger.warning( + "REDIS_URL niet ingesteld — rate limiter gebruikt in-memory storage. " + "Dit werkt NIET correct met meerdere gunicorn workers! " + "Stel REDIS_URL in voor productie." + ) + redis_url = 'memory://' + limiter.init_app( + app, + storage_uri=redis_url, + strategy='fixed-window-elastic-expiry', # robuuster dan fixed-window + on_breach=_rate_limit_handler, + ) + + # ── Security headers via Talisman ───────────────────────────────────────── + # CSP: strikte whitelist — geen inline scripts, geen externe resources buiten cdnjs + # CSP: nonce-based voor scripts (Talisman injecteert {{ csp_nonce() }} in templates) + # unsafe-inline is uitgeschakeld voor scripts — gebruik {{ csp_nonce() }} in