OpenSRM holds your SRM password so it can log in for you. This document explains what that means, what we protect and how, and how to report a problem. In short: everything sensitive is encrypted or hashed at rest, the browser gets a strict policy with zero third-party origins, every limit is designed to fail closed, and the container runs unprivileged.
Report vulnerabilities privately via GitHub Security Advisories (github.com/thenabbu/OpenSRM → Security → Report a vulnerability). Do not open public issues.
Scope: this is a student-built tool, not production infrastructure. Do not reuse your SRM password anywhere else.
- Fernet encryption at rest (AES-128-CBC). When
FERNET_KEYSis set, reads go throughMultiFernet: every listed key decrypts, only the first key encrypts — old ciphertext stays readable through a rotation. - Key location, in precedence order:
FERNET_KEYSenv — comma-separated base64 keys, first key = the encrypting key. It belongs in/docker/opensrm/.env(the compose project env, interpolated into the container'senvironment:), not/docker/.env(the lab master registry — compose never reads it for this project) and never insideDATA_DIR.- Default (unset or empty):
DATA_DIR/fernet.key(/app/data/fernet.keyin the container, mode 600, generated on first boot if absent).
- Never logged or transmitted in plaintext —
scripts/rotate_fernet_key.pyprints counts only, never key material, ciphertext, plaintext or netids. - A decrypt failure degrades instead of crashing: the stored credentials read as unreadable and the user is asked to log in again.
- Replacing the key without losing stored passwords: Key rotation.
- HttpOnly cookies (not readable from JavaScript) with
SameSite=Lax; 30-day expiry enforced on every read. Expired tokens for an account are pruned when that account logs in again — no background sweeper. - The
Secureflag is set only when the request carries a realcf-rayheader and arrived over HTTPS, so plain-HTTP local dev keeps working. - Tokens are stored as a sha256 hex digest (migration 17): the browser
holds the raw token, the database never does — a leaked
srm.dbcannot be replayed as a live session. Existing rows are hashed inside the migration (every current session survives the upgrade), and the digest is recomputed over the presented cookie on every read, prune and delete. - Cross-site state-changing requests are refused with 403 (
POST,PUT,PATCH,DELETE). WhenSec-Fetch-Siteis present it must besame-originornone(a forbidden header — a cross-site page can neither strip nor forge it); otherwise a presentOriginmust match the request host (includingOrigin: null, which fails closed). A client that sends neither header (curl, cron, tests) is not a browser and is allowed.POST /internal/push/tickis exempt — it authenticates with its own secret header. Every refusal logsstatus=403with itserror=on theopensrm.httpline. - No CSRF token is needed: the cross-site 403 guard above +
SameSite=Lax- the strict-JSON requirement cover it.
default-src 'self'— everything falls back to same-originscript-src 'self'— no inline scripts, no external script originsstyle-src 'self' 'unsafe-inline'— the pre-compiledapp.cssplus inline style attributes; no stylesheet is fetched from any third-party hostimg-src 'self' data:— no remote image hostsconnect-src 'self'— no external API calls from the browserframe-ancestors 'none'— cannot be embedded in iframesbase-uri 'self',form-action 'self',worker-src 'self',manifest-src 'self'- Plus
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer, and HSTS (max-age=31536000; includeSubDomains) oncf-rayresponses only - No third-party payload to integrity-check. Tailwind and daisyUI are
compiled ahead of time from a version-pinned, sha256-verified toolchain
(
scripts/build-css.sh) into the committedapp/static/app.css, so the browser never fetches third-party CSS or JS. The old SRI-on-CDN defence became unnecessary once the CDN itself was removed.
- Per NetID: 3 scrapes per 10 minutes — counted only when the sync
actually runs. A rejection that never reaches the portal (queue wait
exhausted, portal budget spent, cooldown active) costs nothing, so a busy
server can't lock an account out of a sync it never performed; a refresh
served from the last completed sync (
SYNC_MIN_INTERVAL_S, default 300 s) or shared from an already-running sync likewise never reaches the portal. - Per IP: 10 login attempts per hour (counted only after credentials pass format validation).
- Aggregate server→portal budget: 30 requests per 10 minutes in three buckets — full logins and preflights share 20 (preflight additionally capped at its own 10, so warm-logins can't drain the login pool), refreshes hold a reserved floor of 10 that unauthenticated work can never spend; one source IP holds at most 6 unauthenticated tokens.
- Sync serialization: one portal pipeline at a time
(
SYNC_MAX_CONCURRENCY, default 1). A second sync waits up toSYNC_QUEUE_WAIT_S(default 45 s,0= refuse immediately) with logins queued ahead of refreshes, then gets the busy 503 — the wait is free, never charged against the account quota. The archived Playwright fallback (off unlessPLAYWRIGHT_FALLBACK=enabled) stays serialized on one browser at any concurrency setting; the shipped image carries no browser at all unless built with--build-arg WITH_PLAYWRIGHT=1. - Portal cooldown: 3 consecutive portal failures arm a cooldown (5 min, doubling, capped at 30 min).
- Memory bound: every rate-limit structure (per-netid and per-IP
attempt windows, preflight admission state, the login-progress map and
its per-IP read cap) is a dict in
app/state.pybehind one lock, pruned of expired keys on every access. When a map passes 10,000 entries only its stale keys are dropped, with a hard clear at 2× as the blowout backstop for that one map. Worst case per map: ~20,000 keys × at most 10 timestamps each. The progress map additionally prunes on every write, so a login flow that never polls holds memory for at most its 10-minute TTL; the portal cooldown/failure counter is two scalars and cannot accumulate.
- A subscription's endpoint is a capability URL — anyone holding it could target that device. It is stored server-side only, never logged in full (12-char hash prefix at most) and never rendered to another user; the status API exposes only the hash.
VAPID_PRIVATE_KEY,VAPID_PUBLIC_KEY,PUSH_TICK_SECRETlive in env (/docker/opensrm/.env— the project env file compose interpolates from, never the master/docker/.env), never in the repo orDATA_DIR; the app refuses to run the feature without them (no runtime key generation — an ephemeral host would orphan every subscription).POST /internal/push/tickis authenticated by theX-Push-Tickheader compared withhmac.compare_digest; missing/wrong secret → bare 401, no session cookie involved, no detail leaked.- Push endpoints use DB-backed rate limits (survive serverless): 10 subscribes, 3 test pushes, 30 receipts per netid per hour.
- Reminder payloads contain only timetable facts (subject, time, room) and TTL out at class start; the push path never touches the portal.
- Same auth contract as the push tick:
METRICS_SECRETlives in env, theX-Metrics-Tokenheader is compared withhmac.compare_digestover UTF-8 bytes (non-ASCII fails closed), unset env means the endpoint is off, and any failure returns a bare 401 with an empty body; every failure logs a WARNING (reason only, never the secret). - The rendered Prometheus text carries only counters/timers/gauges with low-cardinality labels (pipeline, error class, page, outcome) — no netids, no portal payloads, no PII.
- When the optional observability stack (
ops/observability/) is up the scrape is secret-gated twice over: Prometheus reads itsX-Metrics-Tokenfrom a mounted secret file (mode 600, owned by the container's uid, created on the deploy host — never in the repo); Prometheus and Alertmanager bind 127.0.0.1 only (reachable solely viassh -L); Grafana binds all LAN interfaces behind a generated 32-hex admin password with sign-up, anonymous access and self-registration all off. The Cloudflare tunnel is untouched and neither service is exposed publicly. The alertmanager's ntfy topic URL is likewise a mode-600url_filemount — the topic name is a secret by ntfy's own design.
- NetID regex:
[a-z0-9]{2,20}(after stripping the email domain) - Password max length: 128 characters
- Request body max: 16 KB, and login accepts only a real
application/jsonbody — no form-encoded CSRF vector
- Cloudflare Tunnel for HTTPS termination; headers are only trusted when
the
cf-rayheader is present (prevents spoofing on direct hits). - SQLite backups must be online copies.
sqlite3 <db> ".backup <dest>"(the method the standing daily snapshot pipeline uses) is safe in every journal mode — it reads through the WAL. A rawcpofsrm.dbis only equivalent while the DB runs the defaultdeletejournal; withSQLITE_JOURNAL_MODE=walrecent commits live insrm.db-waland a file-only copy silently misses them. - Docker container runs with healthcheck and log rotation; the healthcheck
polls
GET /healthz(SELECT 1, no session, no portal contact, a 16-byte body) and_log_requestdemotes that path to DEBUG, so a 30s poll cannot fillopensrm.log— a 5xx from the probe still logs ERROR so ntfy pages. - The container does not run as root.
entrypoint.shstarts root for exactly two lines —chown -R opensrm /app/dataand thesetprivdrop that follows — thenexecs CMD in place, so PID 1 is the app asopensrm(uid 10001) withCapPrm/CapEffzeroed andNoNewPrivsset. Compose addscap_drop: ALLwith onlyCHOWN/SETUID/SETGIDback,read_onlyrootfs +/tmptmpfs,no-new-privileges, andmem_limit/pids_limitsized from measured RSS and task counts. - Chromium keeps
--no-sandbox(headless Playwright in-container; only present in images built with--build-arg WITH_PLAYWRIGHT=1) — it no longer needs root for it; the container is single-purpose and only reachable through the Cloudflare tunnel (compose publishes loopback + the cloudflared bridge gateway only).
- Every CI action is SHA-pinned to a 40-char commit; the Dockerfile pins
its base images and the
uvbinary by digest. .github/workflows/scans.ymlruns gitleaks (full git history), pip-audit (locked python deps) and trivy (built image, HIGH/CRITICAL with a fix available) on every PR — report-only by design; docs/SCANS.md documents what each would block on and how the maintainer can flip one to blocking.- The one git dependency (
blobatar) is pinned to an exact commit hash (pyproject.toml+uv.lock, enforced bytests/unit/test_audit_findings.pyM22) — a known, documented supply-chain gap (see docs/SCANS.md) reported rather than fixed.
The encryption key can be replaced without losing stored passwords, and
kept off the data volume: FERNET_KEYS in /docker/opensrm/.env holds
comma-separated keys, the first key encrypts and every key decrypts.
DATA_DIR/fernet.key stays the zero-config default. All commands below run
on the lab host; the env is read at process start, so every key change
needs a container recreate, and scripts/rotate_fernet_key.py ships in the
image at /app/scripts/.
- Snapshot first — an online
.backup, the same WAL-safe method the daily snapshot pipeline uses (the image has nosqlite3CLI, so use its python):Keep the copy until step 4's dry run is green. To restore: stop the container,docker exec opensrm python -c 'import sqlite3; s=sqlite3.connect("/app/data/srm.db"); d=sqlite3.connect("/app/data/srm.pre-rotation.snapshot.db"); s.backup(d); d.close(); s.close(); print("snapshot ok")' docker cp opensrm:/app/data/srm.pre-rotation.snapshot.db ./srm.pre-rotation.snapshot.db
docker cp ./srm.pre-rotation.snapshot.db opensrm:/app/data/srm.db, start it again. - Generate the new key on the lab host — it never passes through
chat, logs or the repo:
docker exec opensrm python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'
- Edit
/docker/opensrm/.env(only ever this file — compose does not read/docker/.envfor this project): set one lineFERNET_KEYS=<new-key>,<current-key>. The current key isdocker exec opensrm cat /app/data/fernet.keywhile you still run file mode; ifFERNET_KEYSis already in use, keep its keys after the new one. When first moving off the file key it must appear in the list — a key left out makes its rows unreadable. - Recreate, then re-encrypt history (dry run is the default and
prints counts only, never key material):
Any unreadable row stops the run before a single write with the count printed (fix the key list — usually a missing old key);
cd /docker/opensrm && docker compose up -d opensrm docker exec opensrm python /app/scripts/rotate_fernet_key.py # dry run: expect "to re-encrypt: N", "unreadable: 0" docker exec opensrm python /app/scripts/rotate_fernet_key.py --apply # one transaction; verifies by decrypting every row; rolls back if any fails
--skip-unreadablecontinues and leaves exactly those rows untouched. Safe against the running app: one short transaction on a small DB, both sides wait up to 10s for the lock. Re-running is a no-op (to re-encrypt: 0). - Drop the old key once the dry run reports
to re-encrypt: 0and logins look healthy: remove it fromFERNET_KEYS(new key stays first), recreate again, dry-run once more. Rows still on a dropped key surface as the existing "stored credentials unreadable — log in again" path; portal-session rows have a 4h TTL, so they just force one fresh login. The app's own decrypt-failure behaviour is unchanged throughout.