6.5 KiB
Security & Bug Audit — Roosterwijs
Date: 2026-06-09 · Scope: backend (Django), frontend (React), Docker/nginx deployment. Verdict: code quality is good and the architecture is clean, but the system is not safe to expose on a network yet. One critical gap (no authentication) and two high-risk items must be fixed before the test server holds real student data.
STATUS 2026-06-10: all findings below are FIXED (verified with 13 functional tests + frontend build). Remaining for you: (1) run
npm installonce infrontend/and commitpackage-lock.jsonso Docker switches tonpm ci; (2) setDJANGO_SECURE=1in.envonce the server runs behind HTTPS. Note #2 (TLS itself) still requires a certificate/reverse proxy on the server.
CRITICAL
1. The entire API is unauthenticated
REST_FRAMEWORK has no DEFAULT_PERMISSION_CLASSES, so DRF defaults to AllowAny. Anyone who can reach port 8080 can, anonymously:
- read, create, edit and delete all persons — including student names and the free-text
opmerkingenfield (sensitive special-education data → AVG/GDPR); - delete groups and subgroups;
- enable/disable modules via
POST /api/modules/<key>/state/.
Fix:
# settings.py
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
],
...
}
Then add a login flow to the React app and send the CSRF token (see Low #11 — these must land together, otherwise all writes break). This naturally belongs to the accounts module, but a minimal session login must protect the API before that module is fully built — security cannot be an optional plugin.
HIGH
2. No TLS anywhere; admin login travels in plain text
docker-compose.yml exposes plain HTTP on 8080 and .env.example shows http:// CSRF origins. Django admin passwords and all student data cross the network unencrypted.
Fix: terminate TLS (reverse proxy or certs in nginx), then enable in settings.py, driven by an env flag:
if _env_bool("DJANGO_SECURE", not DEBUG):
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
3. SECRET_KEY silently falls back to a known dev key
If DJANGO_SECRET_KEY is missing in production, the app runs with "dev-only-change-me-in-productie" — sessions and password-reset tokens become forgeable, with no warning.
Fix: fail hard:
SECRET_KEY = os.environ.get("DJANGO_SECRET_KEY", "")
if not SECRET_KEY:
if _env_bool("DJANGO_DEBUG", False):
SECRET_KEY = "dev-only-change-me-in-productie"
else:
raise RuntimeError("DJANGO_SECRET_KEY ontbreekt; weiger te starten zonder geheime sleutel.")
(Note: DEBUG is read after this block today — reorder accordingly.)
MEDIUM
4. Browsable API enabled in production
BrowsableAPIRenderer gives visitors a friendly UI to explore and submit writes. Gate it: include it in DEFAULT_RENDERER_CLASSES only when DEBUG is true.
5. Bug — module toggle truthiness
plugins/views.py: enabled = bool(request.data.get("enabled", False)). A form-encoded or string payload "false" becomes True. Parse explicitly:
enabled = request.data.get("enabled")
if not isinstance(enabled, bool):
return Response({"detail": "enabled moet true of false zijn."}, status=400)
6. Bug — role change leaves stale subgroup membership
Persoon.save() clears groep when the rol is no longer leerling, but M2M subgroepen membership stays (limit_choices_to only affects admin forms, not the API). A teacher can remain "lid" of a niveaugroepje.
Fix: in Persoon.save() (after super().save()): if self.rol != Rol.LEERLING: self.subgroepen.clear() — or validate in PersoonSerializer.update().
7. No rate limiting
No DRF throttling and no nginx limit_req. Add DEFAULT_THROTTLE_CLASSES/RATES (e.g. "anon": "20/min", "user": "200/min") — cheap insurance once the API is internet-reachable.
8. Backend container runs as root
Add to the Dockerfile after COPY . .:
RUN useradd --create-home appuser && chown -R appuser /app
USER appuser
(Static volume permissions: run collectstatic before switching user, or chown the volume in entrypoint.)
LOW
9. Race in sync_states()
Three gunicorn workers can pass the key not in existing check simultaneously → IntegrityError on the unique key. Use ModuleState.objects.get_or_create(key=spec.key, defaults={"enabled": spec.default_enabled}).
10. Missing security headers on the SPA
Django adds X-Frame-Options/nosniff only to /api and /admin. Add to the nginx location / block: X-Content-Type-Options: nosniff, X-Frame-Options: DENY, Referrer-Policy: same-origin, and later a CSP.
11. Frontend sends no CSRF token
api.js never sends X-CSRFToken. Harmless today (anonymous = no CSRF check), but the moment Fix #1 lands, every POST/PATCH/DELETE will 403. Read the csrftoken cookie and set the header in request(). Ship together with #1.
12. npm install in Docker build
Use npm ci with a committed package-lock.json for reproducible, tamper-evident builds.
13. No logging / audit trail
No LOGGING config; with student data you want a record of logins and destructive actions. Minimal console logging config now; an audit-log module fits the plugin architecture later.
Verified non-issues
.gitignoreandbackend/.dockerignorecorrectly exclude.env,db.sqlite3(image and repo stay clean).- CORS is locked to localhost dev origins; production traffic is same-origin via nginx — correct.
ALLOWED_HOSTSfrom env with safe defaults; nginx forwardsHostso host-header spoofing is caught.- Dependency-aware module enable/disable logic in
plugins/services.pyis sound (checked both directions). - Postgres is not port-mapped to the host; only nginx is exposed.
- Strong password validators active;
DEBUGdefaults to off.
Recommended order of work
- #1 + #11 together (auth + CSRF in frontend) — blocks everything else being meaningful.
- #3 (fail hard on missing SECRET_KEY) — one-file change, do immediately.
- #2 (TLS + secure cookies) when the test server gets a hostname.
- #5, #6, #9 (bugs) — small, independent fixes.
- #4, #7, #8, #10, #12, #13 as hardening follow-ups.