This page is the practical security baseline for this project. Canonical ownership map for edge vs app headers: SECURITY_BASELINE.md.
For private vulnerability reports, use GitHub private vulnerability reporting for this repository (Security tab).
- Do not include exploit details in public issues.
- Include reproduction steps, affected version/commit, and impact.
- Expect an initial acknowledgement within 3 business days.
If you only do three things before production:
- set strong secrets and
DJANGO_DEBUG=0 - require TLS and 2FA for admin + teacher users
- run
bash scripts/validate_env_secrets.sh
flowchart LR
U[Users] --> C[Caddy edge]
C --> H1[Security headers]
C --> H2[Route armor<br/>/admin + /teach]
C --> A[Class Hub]
C --> B[Homework Helper]
A --> D[Django auth session + OTP<br/>optional Google SSO]
B --> E[Scope token + rate limits]
A --> F[(Postgres/Redis)]
B --> F
| Area | Current posture |
|---|---|
| Student identity | Pseudonymous (class code + display name) |
| Teacher/admin auth | Django auth session; optional Google SSO; OTP required by default for /admin and /teach |
| Transport | Caddy at edge; HTTPS expected in production |
| Service exposure | Postgres/Redis internal-only; Ollama/MinIO localhost-bound on host |
| Browser hardening | Enforced CSP + report-only CSP + Permissions-Policy + Referrer-Policy + frame protections |
| Helper scope protection | Student helper calls require signed scope_token |
| Upload access | Not public /media; downloads are permission-checked views |
| Auditing | Staff mutations logged as immutable AuditEvent rows |
| Helper event ingest | Authenticated internal endpoint (token-gated) |
- Set
DJANGO_DEBUG=0. - Set a strong
DJANGO_SECRET_KEY(non-default, 32+ chars). - Set a strong
DEVICE_HINT_SIGNING_KEY(32+ chars) and keep it different fromDJANGO_SECRET_KEYin production. - Set a strong
HELPER_SCOPE_SIGNING_KEY(32+ chars) and keep it different fromDJANGO_SECRET_KEYin production. - Enable HTTPS behavior for domain deployments:
DJANGO_SECURE_SSL_REDIRECT=1CADDY_HSTS_MAX_AGE(edge-owned HSTS; recommend>=31536000after verification)
- Keep
DJANGO_ADMIN_2FA_REQUIRED=1. - Keep
DJANGO_TEACHER_2FA_REQUIRED=1. - Set deployment timezone (for lesson release dates):
DJANGO_TIME_ZONE=America/Chicago(or your local timezone)
- Keep strict staff org boundary in production:
REQUIRE_ORG_MEMBERSHIP_FOR_STAFF=1- use
0only for local/dev or time-boxed migration.
- If using separate asset subdomain under same parent domain, set cookie domains:
DJANGO_SESSION_COOKIE_DOMAIN=.yourdomain.tldDJANGO_CSRF_COOKIE_DOMAIN=.yourdomain.tld
- Validate secrets and guardrails:
bash scripts/validate_env_secrets.sh
- Confirm edge request size limits are set:
CADDY_CLASSHUB_MAX_BODYCADDY_HELPER_MAX_BODY
- Set a strong cross-service event token:
CLASSHUB_INTERNAL_EVENTS_TOKEN
- Students do not have passwords in MVP.
- Teacher SSO can be enabled per deployment (currently Google flow live; other providers scaffolded).
- Teachers should be
is_staff=True,is_superuser=Falsefor daily use. /teach/*requires OTP-verified staff session whenDJANGO_TEACHER_2FA_REQUIRED=1.- Superusers should be limited to operational tasks.
- Optional hard org boundary:
REQUIRE_ORG_MEMBERSHIP_FOR_STAFF=1is the production-safe default and blocks staff with no active org memberships from class listing/access.
- Operational guide for inside-org vs cross-org behavior:
- Helper chat requires either:
- valid student classroom session, or
- authenticated staff session.
- Student helper requests must include a valid signed scope token.
- Staff can also be forced to require signed scope tokens:
HELPER_REQUIRE_SCOPE_TOKEN_FOR_STAFF=1
Admin 2FA bootstrap command:
docker compose exec classhub_web python manage.py bootstrap_admin_otp --username <admin_username> --with-static-backup- Caddy is the public edge.
- Postgres and Redis are not published on host ports.
- Ollama and MinIO are bound to
127.0.0.1on the host for local/admin access. - Proxy header trust is explicit opt-in:
- local preset:
REQUEST_SAFETY_TRUST_PROXY_HEADERS=0 - domain preset (behind Caddy first hop):
REQUEST_SAFETY_TRUST_PROXY_HEADERS=1
- local preset:
Optional Caddy controls for /admin* and /teach*:
- IP allowlist:
CADDY_STAFF_IP_ALLOWLIST_V4/CADDY_STAFF_IP_ALLOWLIST_V6(set real operator IP ranges in domain mode)
- Extra
/admin*basic-auth gate:CADDY_ADMIN_BASIC_AUTH_ENABLED=1CADDY_ADMIN_BASIC_AUTH_USERCADDY_ADMIN_BASIC_AUTH_HASH(bcrypt hash)/admin/login*is intentionally excluded so Django login + OTP can render.- If enabled with default/placeholder credentials, Caddy now fail-closes with
503.
- Open staff-route acknowledgement (required if allowlists are intentionally open in domain mode):
CADDY_ALLOW_PUBLIC_STAFF_ROUTES=1
- Optional upstream app health endpoint exposure:
CADDY_EXPOSE_UPSTREAM_HEALTHZ=1to expose/upstream-healthzpublicly.
- Optional Caddy root FS hardening:
CADDY_READ_ONLY=true(recommended only after deploy/smoke validation on your host).
These controls are additive to Django auth + OTP.
- Dynamic teacher/admin redirects are guarded as local, same-origin paths before redirect responses are returned.
- Lesson/course file resolution is rooted under
CONTENT_ROOT/courseswith safe path join + containment checks. - Manifest lesson
filevalues are treated as untrusted metadata and rejected when they escape the content root. - Lesson metadata parse failures return a generic 500 response; detailed exception context is logged server-side.
Field-level lifecycle details (exact fields, TTL knobs, deletion controls): PRIVACY-ADDENDUM.md.
- Stored under
data/classhub_uploads/. - Not served as public static media.
- Access is permission-checked (
/submission/<id>/download). - Files use randomized server-side names; original filename is metadata only.
- Upload checks now include lightweight content validation (for example
.sb3must be a valid zip withproject.json). - File cleanup signals remove stored files on row delete/cascade delete and file replacement.
- Helper reset archives (
/uploads/helper_reset_exports) are operator-only artifacts:- not publicly served by Caddy routes,
- not included in student-facing portfolio exports,
- intended access scope is teachers + createMPLS admins only.
sequenceDiagram
participant B as Browser
participant MW as Middleware
participant V as student.material_upload
participant UV as upload_validation
participant FS as MEDIA storage
participant DB as Postgres
participant R as Redis/cache
B->>MW: POST /material/<id>/upload (file)
MW->>V: request (mode/headers/session)
V->>R: rate limits (fail-open for student continuity)
V->>UV: validate size + allowlist + magic bytes + scan hook
UV-->>V: ok / reject
V->>FS: save file bytes
V->>DB: create Submission (metadata)
V->>DB: StudentEvent (ext + size only)
V->>B: 200/redirect + user-visible message
- Lesson assets are permission-checked (
/lesson-asset/<id>/download). - Unsafe file types (for example
.html) are forced to download, not inline render. - Inline rendering is restricted to allow-listed media/PDF types and includes
X-Content-Type-Options: nosniff. - Optional: set
CLASSHUB_ASSET_BASE_URLto serve lesson asset/video links from a separate origin.
Retention commands:
python manage.py prune_submissions --older-than-days <N>
python manage.py prune_student_events --older-than-days <N>
python manage.py prune_student_events --older-than-days <N> --export-csv /path/to/student_events_before_prune.csv
python manage.py scavenge_orphan_uploadsScheduled maintenance entrypoint:
bash scripts/retention_maintenance.sh --compose-mode prodAuditEventlogs staff actions in/teach/*.StudentEventstores metadata (status/request IDs/timing) only.- No raw helper prompt text and no file contents are stored in
StudentEvent.details.
- Unsigned helper scope fields (
context/topics/allowed_topics/reference) are ignored. - Helper access telemetry is forwarded to Class Hub via authenticated internal endpoint:
CLASSHUB_INTERNAL_EVENTS_URLCLASSHUB_INTERNAL_EVENTS_TOKEN
- Student helper session-table checks are configurable:
- default: fail-open when classhub tables are unavailable
- production hardening option:
HELPER_REQUIRE_CLASSHUB_TABLE=1(fail-closed)
- Local LLM (
Ollama) keeps inference on your infrastructure, but logs and prompt handling still require governance.
Enable command-based scanning (for example ClamAV):
CLASSHUB_UPLOAD_SCAN_ENABLED=1CLASSHUB_UPLOAD_SCAN_COMMAND(example:clamscan --no-summary --stdout)CLASSHUB_UPLOAD_SCAN_FAIL_CLOSED=1to block uploads on scanner errors/timeouts
Class Hub and Homework Helper attach these headers by default:
Content-Security-Policy(enforced)Content-Security-Policy-Report-Only(for visibility while tuning)Permissions-PolicyReferrer-PolicyX-Frame-Options
Primary knobs:
DJANGO_CSP_MODE(relaxed,report-only,strict)DJANGO_CSP_POLICYDJANGO_CSP_REPORT_ONLY_POLICYDJANGO_PERMISSIONS_POLICYDJANGO_SECURE_REFERRER_POLICY
Mode behavior:
relaxed(default): enforced relaxed baseline + strict report-only baselinereport-only: strict report-only baseline only (no enforced CSP header)strict: strict enforced baseline only
DJANGO_CSP_POLICY and DJANGO_CSP_REPORT_ONLY_POLICY remain explicit overrides when you need custom policies.
Rollout strategy:
- Keep report-only enabled while reviewing violations.
- Tighten directives (especially
script-src,style-src,connect-src,frame-src). - Keep enforced + report-only in parallel until violation noise stabilizes.
Transitional strict-script canary:
- Before full strict CSP rollout, you can lock script execution while temporarily allowing inline styles.
- Set:
DJANGO_CSP_MODE=strictDJANGO_CSP_POLICY=default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; img-src 'self' data: https:; media-src 'self' https:; frame-src 'self' https://www.youtube-nocookie.com; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self' https:;
- This is useful when inline scripts are removed but template/style extraction is still in progress.
- After style cleanup, remove
'unsafe-inline'fromstyle-srcand rely on mode-derived strict policy.
Embed notes:
- YouTube embeds use privacy-enhanced
youtube-nocookie.com; keepframe-src https://www.youtube-nocookie.com. - Separate asset origins require matching updates in
img-src,media-src, andconnect-src.
CLASSHUB_SITE_MODE can narrow behavior during incidents:
normalread-only(uploads/write actions blocked)join-only(student entry paths available, heavy routes paused)maintenance(student-facing routes paused)
Optional operator message override:
CLASSHUB_SITE_MODE_MESSAGE
Class Hub follows a public curriculum, private artifacts design:
| Content type | Visibility | Enforcement |
|---|---|---|
Lesson page body (/course/<slug>/<lesson>) |
Public — any visitor can read the rendered markdown | Date-locked lessons show intro excerpt only; full body unlocks on available_on date |
Course overview (/course/<slug>) |
Public — anyone can see the lesson index | No gate |
Lesson assets (/lesson-asset/<id>/download) |
Class-gated — student's class must link to the lesson | media.py checks Material(TYPE_LINK) membership |
Lesson videos (/lesson-video/<id>/stream) |
Class-gated — same check as assets | media.py checks Material(TYPE_LINK) membership |
Student uploads (/submission/<id>/download) |
Owner + staff only | View checks student_id or is_staff |
Design rationale: Open-education nonprofits benefit from discoverable curriculum. Making lesson text public lets partner orgs preview content before onboarding, and lets teachers share lesson links freely. Private artifacts (uploads, assets, videos) protect student work and licensed media that may live alongside the open curriculum.
If your deployment requires private lesson bodies (partner-only curricula,
sensitive material), you can gate the lesson view behind student/staff
authentication by adding a session check to course_lesson() in
hub/views/content.py. This is a one-line change but is intentionally not the
default.
- Teacher SSO and optional student school-login rollout plan: IDENTITY_SSO_EXPANSION_PLAN.md.
- Separate databases per service if isolation requirements increase.
- Keep
RUN_MIGRATIONS_ON_START=0in production so migrations run only through explicit deploy steps.