Skip to content

Security: bseverns/classhub

docs/SECURITY.md

Security

This page is the practical security baseline for this project. Canonical ownership map for edge vs app headers: SECURITY_BASELINE.md.

Reporting security issues

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:

  1. set strong secrets and DJANGO_DEBUG=0
  2. require TLS and 2FA for admin + teacher users
  3. 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
Loading

Security posture at a glance

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)

Day-1 production checklist

  1. Set DJANGO_DEBUG=0.
  2. Set a strong DJANGO_SECRET_KEY (non-default, 32+ chars).
  3. Set a strong DEVICE_HINT_SIGNING_KEY (32+ chars) and keep it different from DJANGO_SECRET_KEY in production.
  4. Set a strong HELPER_SCOPE_SIGNING_KEY (32+ chars) and keep it different from DJANGO_SECRET_KEY in production.
  5. Enable HTTPS behavior for domain deployments:
    • DJANGO_SECURE_SSL_REDIRECT=1
    • CADDY_HSTS_MAX_AGE (edge-owned HSTS; recommend >=31536000 after verification)
  6. Keep DJANGO_ADMIN_2FA_REQUIRED=1.
  7. Keep DJANGO_TEACHER_2FA_REQUIRED=1.
  8. Set deployment timezone (for lesson release dates):
    • DJANGO_TIME_ZONE=America/Chicago (or your local timezone)
  9. Keep strict staff org boundary in production:
    • REQUIRE_ORG_MEMBERSHIP_FOR_STAFF=1
    • use 0 only for local/dev or time-boxed migration.
  10. If using separate asset subdomain under same parent domain, set cookie domains:
  • DJANGO_SESSION_COOKIE_DOMAIN=.yourdomain.tld
  • DJANGO_CSRF_COOKIE_DOMAIN=.yourdomain.tld
  1. Validate secrets and guardrails:
  • bash scripts/validate_env_secrets.sh
  1. Confirm edge request size limits are set:
  • CADDY_CLASSHUB_MAX_BODY
  • CADDY_HELPER_MAX_BODY
  1. Set a strong cross-service event token:
  • CLASSHUB_INTERNAL_EVENTS_TOKEN

Authentication and authorization boundaries

  • 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=False for daily use.
  • /teach/* requires OTP-verified staff session when DJANGO_TEACHER_2FA_REQUIRED=1.
  • Superusers should be limited to operational tasks.
  • Optional hard org boundary:
    • REQUIRE_ORG_MEMBERSHIP_FOR_STAFF=1 is 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

Network and proxy trust model

  • Caddy is the public edge.
  • Postgres and Redis are not published on host ports.
  • Ollama and MinIO are bound to 127.0.0.1 on 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

Proxy armor for teacher/admin routes

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=1
    • CADDY_ADMIN_BASIC_AUTH_USER
    • CADDY_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=1 to expose /upstream-healthz publicly.
  • 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.

Redirect and path hardening

  • 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/courses with safe path join + containment checks.
  • Manifest lesson file values 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.

Data handling and retention

Field-level lifecycle details (exact fields, TTL knobs, deletion controls): PRIVACY-ADDENDUM.md.

Student submissions

  • 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 .sb3 must be a valid zip with project.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.

Upload flow (Map D2)

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
Loading

Lesson assets

  • 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_URL to 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_uploads

Scheduled maintenance entrypoint:

bash scripts/retention_maintenance.sh --compose-mode prod

Event logging

  • AuditEvent logs staff actions in /teach/*.
  • StudentEvent stores metadata (status/request IDs/timing) only.
  • No raw helper prompt text and no file contents are stored in StudentEvent.details.

Helper-specific controls

  • 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_URL
    • CLASSHUB_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.

Upload malware scanning (optional)

Enable command-based scanning (for example ClamAV):

  • CLASSHUB_UPLOAD_SCAN_ENABLED=1
  • CLASSHUB_UPLOAD_SCAN_COMMAND (example: clamscan --no-summary --stdout)
  • CLASSHUB_UPLOAD_SCAN_FAIL_CLOSED=1 to block uploads on scanner errors/timeouts

CSP and browser security headers

Class Hub and Homework Helper attach these headers by default:

  • Content-Security-Policy (enforced)
  • Content-Security-Policy-Report-Only (for visibility while tuning)
  • Permissions-Policy
  • Referrer-Policy
  • X-Frame-Options

Primary knobs:

  • DJANGO_CSP_MODE (relaxed, report-only, strict)
  • DJANGO_CSP_POLICY
  • DJANGO_CSP_REPORT_ONLY_POLICY
  • DJANGO_PERMISSIONS_POLICY
  • DJANGO_SECURE_REFERRER_POLICY

Mode behavior:

  • relaxed (default): enforced relaxed baseline + strict report-only baseline
  • report-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:

  1. Keep report-only enabled while reviewing violations.
  2. Tighten directives (especially script-src, style-src, connect-src, frame-src).
  3. 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=strict
    • DJANGO_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' from style-src and rely on mode-derived strict policy.

Embed notes:

  • YouTube embeds use privacy-enhanced youtube-nocookie.com; keep frame-src https://www.youtube-nocookie.com.
  • Separate asset origins require matching updates in img-src, media-src, and connect-src.

Site degradation modes

CLASSHUB_SITE_MODE can narrow behavior during incidents:

  • normal
  • read-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

Content visibility model

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.

Future hardening candidates

  • 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=0 in production so migrations run only through explicit deploy steps.

There aren’t any published security advisories