Atlas — Threat Model
Project: Atlas — WG11 Brainstorming Group workspace
Owner: Brush Cyber (Douglas Brush) for The Sedona Conference WG11
Scope: wg11-sedona.brushcyber.com
Last reviewed: 2026-04-17
Frameworks: STRIDE, with privacy-by-design overlay
1. System overview
Atlas is a FastAPI + SQLAlchemy + HTMX + Tailwind collaborative workspace used by ~20 named legal professionals to draft a single Sedona Conference WG11 publication. It runs as a single Replit Reserved-VM service behind Replit's mTLS edge with Let's Encrypt TLS, fronted by a custom domain. Authentication is Replit OIDC (PKCE); a magic-link layer provides one-click sign-in from emails. State lives in managed PostgreSQL with daily snapshots.
1.1 Trust boundaries
| Zone | Examples | Trust |
|---|---|---|
| Public internet | unauthenticated browsers, search bots, Postmark webhook senders | untrusted |
| Authenticated users | the ~20 allowlisted attorneys + chair + Sedona staff | semi-trusted (have legitimate session, but role-scoped) |
| Privileged users | bg_chair, fg_lead, sedona_staff |
trusted for editorial actions, not for infra |
| Atlas service | FastAPI process, SQLAlchemy session | trusted |
| Atlas data plane | managed Postgres, daily snapshots | trusted, encrypted at rest |
| AI providers | Anthropic, OpenAI, Google | external; no-train contracts; encrypted in transit only |
| SMTP / Postmark | outbound mail | external; bears recipient PII |
1.2 Data classification
| Class | Examples | Storage |
|---|---|---|
| Identifying | Name, work email, firm, Replit sub |
users table |
| Confidential drafting | section bodies, outlines, comments | sections, comments |
| Operational | session cookies, magic-link tokens, activity log | session store, activities |
| Public | published outline, trust hub, SBOM | rendered from same DB |
2. Asset inventory
| Asset | Why it matters |
|---|---|
| Draft publication text | Pre-publication legal analysis. Leakage harms WG11 credibility. |
| Member identities & assignments | Membership of WG11 BG is not strictly secret but unintended disclosure embarrasses the project. |
| Comment threads | May contain candid internal disagreement. |
| Magic-link tokens | Bearer auth — anyone with the URL becomes that user. |
| Session cookies | Same risk as magic-links, but HTTP-only. |
| Admin endpoints | Bulk email send, weekly nag trigger, citation/QA reports. |
| OIDC client config | REPL_ID is public; secrets handled by Replit OIDC. |
| Postmark token | Outbound email; can spoof Atlas if leaked. |
3. STRIDE analysis
3.1 Spoofing
| Threat | Mitigation | Residual |
|---|---|---|
| Attacker tries to log in as a member | Replit OIDC required; allowlist enforced by replit_id/email match against pre-seeded users table; auto-provisioning is disabled |
Low |
| Attacker forges a magic-link token | URLSafeTimedSerializer over SESSION_SECRET; HMAC + 7d TTL; verified against users row at redemption |
Low if SESSION_SECRET is strong + rotated |
| Sender-spoofing inbound to chair | We do not consume email; only outbound | N/A |
| OIDC code interception | PKCE S256 challenge per-session; verifier stored in HTTP-only signed cookie | Low |
3.2 Tampering
| Threat | Mitigation | Residual |
|---|---|---|
| Member rewrites another member's section | Edits are role-checked; full immutable audit log in activities; section history tracked |
Low |
| Comment thread modified | Comments are append-only by design | Low |
| Bulk-delete of sections via API | Mutating routes require bg_chair |
Low |
| URL parameter manipulation (IDOR) | Section access is gated by role + assignment; no row-level read filter is currently enforced for non-confidential drafting (members can read all sections) — this is intentional | Accepted |
| SQL injection | All DB access via SQLAlchemy ORM with bound parameters; no raw f-string SQL | Very low |
3.3 Repudiation
| Threat | Mitigation | Residual |
|---|---|---|
| Member denies making a destructive edit | Every meaningful action records an activities row with user_id + timestamp + detail |
Low |
| Reminder claimed never sent | record_activity('reminder_sent' | 'reminder_failed') with recipient + Postmark response |
Low |
| Bulk weekly-nag attribution | WeeklyNagRun row records triggered_by + week_key + counts |
Low |
3.4 Information disclosure
| Threat | Mitigation | Residual |
|---|---|---|
| Pre-publication drafts leak via search engines | All drafting routes require login; only /trust, /login are public |
Low |
| Magic-link forwarded → other person sees draft | Token is bound to user_id; once redeemed it sets a session as that user. Tokens expire in 7 days. Open issue: tokens are not single-use — see §6.1. |
Medium |
| Cron secret leaked via URL logs | Switched to X-Cron-Secret header; query string accepted only for back-compat |
Low |
Chair tools (/admin/*) accessible to non-chairs |
EDITOR_ROLES gate at every route; verified by tests |
Low |
| Postmark token leaked | Stored only in env; never logged; never echoed to UI | Low |
| AI provider stores prompts | Anthropic, OpenAI, Google contractually exclude API inputs from training; section text is sent only on explicit user action | Low |
| Browser console / source map exposes secrets | No secrets in client bundles; HTMX endpoints return rendered HTML only | Low |
| Email body discloses content to mail relays | TLS to Postmark; recipient mail providers may store; not unique to Atlas | Accepted |
3.5 Denial of service
| Threat | Mitigation | Residual |
|---|---|---|
| Attacker floods reminder send | Chair-only route; quiet-hours throttle; per-user last_reminded_at |
Low |
| AI route abused to burn credits | All AI routes require auth; rate-naturally-limited by single-instance deployment | Medium — no per-user quota yet |
| Background scheduler stuck or duplicates emails | Asyncio loop, ticks hourly, idempotent across workers via weekly_nag_runs.week_key unique constraint |
Low |
| Database connection exhaustion | SQLAlchemy pool with default size; sessions closed in finally blocks; reviewed by static and architect pass |
Low |
| Magic-link replay storm | Rate-limit handler not yet present; tokens valid for 7d | Medium |
3.6 Elevation of privilege
| Threat | Mitigation | Residual |
|---|---|---|
| Member crafts request to act as chair | require_user + role check on every privileged route; no role coming from cookie payload |
Low |
| Auth bypass enabled in production | AUTH_BYPASS env defaults off; /trust exposes its real value |
Low |
Forged Replit OIDC id_token |
Token comes directly from Replit over TLS; we trust transport, do not re-verify signature client-side. Risk if replit.com is impersonated → mitigated by browser TLS cert pinning to public CAs |
Low |
| Session cookie theft via XSS | Markdown renderer escapes HTML; user content never injected unescaped; CSP enforced with no 'unsafe-inline' on script-src or style-src (§6.2) |
Low |
4. Privacy considerations (overlay)
- Data minimisation — only name, work email, firm affiliation, role, and Replit subject ID stored. No phone, no address, no DOB.
- Right to deletion — chair can soft-delete a user; full data wipe on project close is contractually committed.
- Cross-border — service runs in Replit's US infra. All members are US legal professionals; no EU data subjects expected.
- De-identification of style fabric — accepted text added to the style corpus is stored without
user_idorsection_idbinding so future stylistic suggestions cannot be attributed back to a single member. - AI providers — input only, no training, no human review by provider; surfaced explicitly on
/trust.
5. Operational security
| Control | Status |
|---|---|
| HTTPS enforced + HSTS | Yes — Replit edge |
| Session cookies HTTP-only, Secure, SameSite-Lax, signed | Yes — itsdangerous |
| Secrets via env only, never in code | Yes |
| Daily DB snapshots | Yes — managed Postgres |
| Dependency audit | runDependencyAudit clean (high CVEs patched: Jinja2 ≥3.1.6) |
| SAST | runSastScan clean |
| Privacy / dataflow scan | runHoundDogScan clean |
| Audit log of mutating actions | activities table |
| Backups tested | TODO — see §6.3 |
6. Open issues / planned hardening
6.1 Magic-link tokens are not single-use
Risk: Medium. Forwarded link works for the full 7d window.
Plan: Add magic_link_redemptions table or use a User.magic_nonce column; reject re-use after first redemption. Keep TTL at 7d.
6.2 Content Security Policy
Status: Tightened (2026-04-21). htmx, Alpine.js, and Tailwind are vendored
under /static, so the policy does not allow CDN origins like unpkg.com or
cdn.tailwindcss.com. All executable inline <script> blocks and on*="…"
event handlers in app/templates/ have been moved into vendored files under
app/static/js/ (theme-init.js, atlas.js, preview-share.js,
section-comments.js, contribute-form.js); templates now use
data-action="…", data-confirm="…", and data-disable-on-submit attributes
that a single delegated listener in atlas.js translates into behaviour.
(Inert <script type="application/json"> data blocks remain in templates
such as section.html; the browser does not execute these, and CSP does
not gate them.)
This let us drop 'unsafe-inline' from script-src — the dominant XSS
injection sink. ContentSecurityPolicyMiddleware in app/main.py attaches:
default-src 'self';
script-src 'self' 'unsafe-eval';
style-src 'self' 'nonce-{per-request}' https://fonts.googleapis.com;
font-src 'self' https://fonts.gstatic.com;
img-src 'self' data:;
connect-src 'self' https://fonts.googleapis.com https://fonts.gstatic.com;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none';
Residual risk:
'unsafe-eval'is still required by the standard Alpine.js build, which compiles directive expressions (e.g.x-show="open") withnew Function(...). This is a smaller XSS surface than'unsafe-inline'(no<script>injection, noonclick=…) but still allowseval. Migrating to Alpine's CSP build (which forbids inline expressions and requires named data components) is tracked as a follow-up.'unsafe-inline'has been removed fromstyle-src(2026-04-21). Allstyle="…"attributes inapp/templates/were refactored to vendored utility classes inapp/static/css/inline-utils.css(auto-generated.is001–.is195plus hand-written semantic helpers for status pills, bar fills, tile colors, suggestion swatches, and numeric padding/width variants). The remaining<style>blocks in templates (base.html,dashboard.html,god_mode.html, etc.) carry a per-requestnonce="{{ csp_nonce(request) }}"matched by the'nonce-{token}'source instyle-src. The nonce is a 16-byte URL-safe random value generated by the CSP middleware inapp/main.pyand exposed to Jinja via thecsp_nonceglobal registered inapp/templating.py. Injected<style>…</style>orstyle="…"attributes from attacker-controlled content are now blocked outright.
Even with these residuals, no third-party script origin is allow-listed, so
an injected <script src="…"> pointing at an attacker-controlled host is
blocked, and an injected <script>foo()</script> is blocked outright by the
removal of 'unsafe-inline' from script-src. Likewise, injected inline
styles cannot exfiltrate data via CSS selectors or rewrite the page chrome,
because 'unsafe-inline' is no longer permitted on style-src.
6.3 Restore-from-backup drill never run
Risk: Low until we need it. Plan: Document restore-from-snapshot runbook; run a dry restore into a scratch DB once a quarter.
6.4 No per-user rate limit on AI endpoints
Risk: Medium (cost / abuse).
Plan: Add a token-bucket on /god-mode/* AI routes keyed by user.id.
6.5 No anomaly alerting on bulk send
Risk: Low. Chair-only and audited but no human alert.
Plan: Slack / email digest if reminder_sent count for a 1h window exceeds N.
7. Sign-off
This threat model is designed to be read — by a Steering Committee member who is a privacy lawyer first and a software auditor second. If you read this far and have questions, the chair will route them.
— Atlas / Brush Cyber, 2026-04-17