docs(phase-19.4.1): add security threat verification
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
---
|
||||
phase: "19.4.1"
|
||||
slug: "early-public-demo-on-demo-summercms-io"
|
||||
status: verified
|
||||
# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate)
|
||||
threats_open: 0
|
||||
asvs_level: 1
|
||||
created: "2026-11-05"
|
||||
---
|
||||
|
||||
# Phase 19.4.1 — Security
|
||||
|
||||
> Per-phase security contract: threat register, accepted risks, and audit trail. Static audit of repository contents (no SSH, no production requests, tests not re-run; test results from the phase gate log and VERIFICATION). Audited: sm-demo-app da03c06 (after Phases 20 and 33), vue-demo-app fe5d38d (the submodule; the sibling `../vue-demo-app` checkout is stale), framework v0.6.1 and the plugin tags go.mod requires. Roots: DEMO = `../sm-demo-app`, VUE = `DEMO/vue-demo-app`, PLG = `DEMO/plugins/golem15`, FW = `summercms.go`.
|
||||
|
||||
---
|
||||
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description | Data Crossing |
|
||||
|----------|-------------|---------------|
|
||||
| ROADMAP.md → client-facing deck | internal phase names include client applications | phase titles and summaries (high) |
|
||||
| local .env → demo:seed | visitor credentials enter the database | visitor email and password (high) |
|
||||
| local demo pages → committed screenshots | captures could show secrets or personal data | OIDC and admin PNGs (medium) |
|
||||
| seeded deck HTML → public deck API | rendered on a public origin | slide HTML (low) |
|
||||
| internet → nginx (demo.summercms.io) | only allow-listed prefixes reach Go; includes the public login-required `/smbackend` admin (user decision 2026-10-20) | all HTTP traffic, admin login (high) |
|
||||
| nginx → Go and Nuxt on loopback | trusted proxy 127.0.0.1/32 sets client IP headers | X-Forwarded-For, X-Real-IP (medium) |
|
||||
| build machine → module proxies and git.golem15.com | tags become immutable inputs for every consumer | plugin tags, go.sum (medium) |
|
||||
| build machine → rome over SSH | secrets generated on rome or piped, never logged | env values, binaries (high) |
|
||||
| Stripe → webhook | signed callbacks from the internet | events, Stripe-Signature (high) |
|
||||
| browser → `/_user/api/v1/register` and `/login` | anonymous, automatable sign-ups and credential attempts | credentials, Turnstile token (high) |
|
||||
| browser → `/_feedback/api/v1/{key}/submit` | anonymous free text with a public key | name, email, message (medium) |
|
||||
| Go → Cloudflare siteverify and G15Office | outbound calls carrying secrets | Turnstile secret, G15Office token (high) |
|
||||
| operator → demo:reset | destructive command on production data | `--force` (high) |
|
||||
| browser locale cookie → SSR | user-controlled input selecting content | locale value (low) |
|
||||
| registered user → `/_demo/api/v1/presentations` | untrusted HTML, markdown and settings stored | deck JSON (high) |
|
||||
| browser → `/api/realtime/token` and `/ws` | authenticated users obtain realtime credentials | JWT, Centrifugo token (high) |
|
||||
| Centrifugo → `/api/realtime/subscribe` | server-to-server over loopback | X-Centrifugo-Secret, user, channel (high) |
|
||||
| Go → Centrifugo HTTP API | publishes slide positions | API key, positions (medium) |
|
||||
| anonymous visitor → `/_demo/api/v1/quizzes` | public read of quiz metadata | slug, title, description, count (medium) |
|
||||
| test harness → Docker / test fixtures → repositories | throwaway database, test-only secrets | test data (low) |
|
||||
| check-live.sh → production origin | read-only probes | GETs plus one refused POST (low) |
|
||||
|
||||
---
|
||||
|
||||
## Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status |
|
||||
|-----------|----------|-----------|----------|-------------|------------|--------|
|
||||
| T-19.4.1-01 | Information disclosure | roadmap deck builder | high | mitigate | denylist + case-insensitive `leaks` (DEMO/plugins/demo/roadmap/names.go:8-26); leak check after curated overrides, leaking phases left out, final per-slide guard (deck.go:56-60,143-146); `TestRoadmapNoClientNames` | closed |
|
||||
| T-19.4.1-02 | Information disclosure | visitor credentials | high | mitigate | `golem15.demo.visitor.*` only, ≥8 chars, never printed (console/seed.go:101-141); empty defaults; dev.sh umask 077, prints key name only; `TestSeedVisitorFromConfig` | closed |
|
||||
| T-19.4.1-03 | Information disclosure | OIDC screenshots | medium | mitigate | capture-oidc.cjs uses the example client only, never opens the one-time secret screen; user reviewed the images (01 Task 4) | closed |
|
||||
| T-19.4.1-04 | Tampering | deck HTML | low | accept | AR-19.4.1-01 | closed |
|
||||
| T-19.4.1-05 | Denial of service | repeated seeding | low | mitigate | old image rows deleted in-tx after import, blobs after commit (console/roadmap_deck.go:109,136-155); `TestSeedRoadmapUpsert` | closed |
|
||||
| T-19.4.1-06 | Elevation of privilege | admin exposure | high | mitigate | **Re-scoped by the user's decision of 2026-10-20** (the plan's "SSH tunnel only" is superseded): `/backend` → 404 (nginx:78-80), `/smbackend` login-required; `login.max_attempts: 5 / 1 min`; ClientIP takes the rightmost untrusted XFF hop with the Cloudflare real-IP snippet; `X-Robots-Tag noindex`; `TestAdminLoginThrottle`; check-deploy.sh:245-259, check-live.sh:140-150 | closed |
|
||||
| T-19.4.1-07 | Information disclosure | secrets in repositories | high | mitigate | env examples empty; secret-pattern scan clean in both repos; values generated on rome, 0640 root:summercms-demo, admin password root 0600 (DEPLOY.md). Repo privacy is host-only | closed |
|
||||
| T-19.4.1-08 | Tampering | one-way plugin tags | medium | mitigate | all 11 phase tags resolve to the commits recorded in the SUMMARYs; scripts/check-pins.sh; `TestGoModRequiresTags` | closed |
|
||||
| T-19.4.1-09 | Spoofing | Stripe webhook | high | mitigate | signing secret required, `pgstripe.VerifySignature` on raw body (PLG/pgstripe/subscriptions/webhook.go:72-78); empty example. Value on rome is host-only | closed |
|
||||
| T-19.4.1-10 | Information disclosure | debug pages and permissive CORS | medium | mitigate | production `debug: false`; CORS only `https://demo.summercms.io`; supervisor `SUMMER_ENV="production"`; check-deploy.sh:183 | closed |
|
||||
| T-19.4.1-11 | Elevation of privilege | unintended Go routes | medium | mitigate | nginx allow-list, `/` GET/HEAD only; check-deploy.sh:268-279 refuses extra `/oauth`, `/.well-known`, `/api` and subscribe locations; PAT APIs `/api/v1/*` unrouted. Narrowed by Phase 19.7 (MCP paths) | closed |
|
||||
| T-19.4.1-12 | Denial of service | Nuxt site-cache purge | medium | mitigate | purge on 127.0.0.1:3098, no POST reaches Nuxt, route 404s proxied/401s bad token; payload token leak fixed in DEMO 866e58c (`NUXT_CACHE_PURGE_TOKEN`, check-deploy.sh:300-318, smoke checks) | closed |
|
||||
| T-19.4.1-13 | Information disclosure | credentials on public pages | high | mitigate | empty login fields cleared after submit; FAQ answer "credentials on request"; smoke.sh:256-268 fails on admin links or visitor credentials on 12 SSR pages; check-live `no_admin_link` | closed |
|
||||
| T-19.4.1-14 | Spoofing | TLS | medium | mitigate | 301 to HTTPS, certbot lineage, HSTS (nginx:50-67); check-live HSTS check. Certificate is host-only | closed |
|
||||
| T-19.4.1-15 | Denial of service | port collisions | low | mitigate | check-deploy.sh:179-243,364-371 port consistency; port probe on rome is host-only | closed |
|
||||
| T-19.4.1-16 | Spoofing | bot sign-ups and credential stuffing | high | mitigate | `turnstile: closed`; guard on `/login`, `/register` inside `throttle:user-api`; attempt_limit 5; production without secret → 503 (FW v0.6.1); check-live token-less register → 422; `TestDemoTurnstile` | closed |
|
||||
| T-19.4.1-17 | Tampering | shared sm-user-plugin behaviour | high | mitigate | default `turnstile: ""` adds nothing, invalid values fail boot (PLG/user/routes.go:63-79); `TestTurnstileRoutes`; no other app sets the key | closed |
|
||||
| T-19.4.1-18 | Denial of service | contact form spam | medium | mitigate | 10/min per client IP; field limits (message ≤5000); Origin allow-list; no mailer in feedback/demo code | closed |
|
||||
| T-19.4.1-19 | Information disclosure | Turnstile secret, G15Office token | high | mitigate | empty examples; build-machine secrets file mode 600; values piped over SSH stdin and checked by count only (DEPLOY.md:114-238). File modes host-only | closed |
|
||||
| T-19.4.1-20 | Tampering | demo:reset deleting kept data | high | mitigate | no-op without `--force`; explicit table list keeps admins, plans, settings, locales, sitemap, OAuth clients; one transaction, blobs after commit; schedule off by default; `TestResetKeepsPlans`; check-local.sh:262-281 | closed |
|
||||
| T-19.4.1-21 | Tampering | stored XSS via seed or author description | low | mitigate | AuthorCard text interpolation; PostBody `sanitizeJournalHtml` before v-html; curated seed | closed |
|
||||
| T-19.4.1-22 | Information disclosure | author payload | low | accept | AR-19.4.1-02 (rationale corrected) | closed |
|
||||
| T-19.4.1-23 | Tampering | locale cookie | low | accept | AR-19.4.1-03 | closed |
|
||||
| T-19.4.1-24 | Information disclosure | feedback widget key in the page | low | accept | AR-19.4.1-04 | closed |
|
||||
| T-19.4.1-25 | Tampering | stored XSS and phishing through user decks | high | mitigate | wrapper restricts themes, clears CSS/context, forces unpublished, resets id/hash, 403 on published decks (DEMO/plugins/demo/decks_api.go:38-120); bluemonday sanitize without `<a>` (PLG/presentations/classes/sanitize.go:21-51); reset wipes user decks; `TestDemoDeckWrites`. See residual 3 | closed |
|
||||
| T-19.4.1-26 | Elevation of privilege | cross-app realtime token reuse | high | mitigate | own Centrifugo with own HMAC secret; HMAC-tagged channels with per-subscribe DB ownership check (PLG/presentations/realtime.go:74-90); guarded token route. Owner-only access clause superseded by Phase 20 (chat community members) | closed |
|
||||
| T-19.4.1-27 | Spoofing | subscribe proxy | high | mitigate | no nginx location (enforced by check-deploy.sh:273-276, check-live); constant-time `X-Centrifugo-Secret` compare, empty refused (FW lighthouse/centrifugo/handlers.go:112-113); `TestRealtimeRoutes` | closed |
|
||||
| T-19.4.1-28 | Information disclosure | Centrifugo keys and remote key | high | mitigate | `CHANGE_ME_*` placeholders enforced by check-deploy.sh:213-219; empty env examples; generated on rome 0640; health prints only `api_key_set` | closed |
|
||||
| T-19.4.1-29 | Spoofing | WebSocket from other sites | medium | mitigate | `allowed_origins: ["https://demo.summercms.io"]`, no insecure/anonymous flags; check-live refuses a foreign Origin | closed |
|
||||
| T-19.4.1-30 | Denial of service | deck writes and token route | medium | mitigate | 256 KiB body, ≤60 slides, `throttle:20,1`; realtime bucket 120/min per user | closed |
|
||||
| T-19.4.1-31 | Information disclosure | quiz answers to anonymous visitors | medium | mitigate | public list returns slug, title, description, count only; quiz API behind `jwt.auth`; `TestDemoQuizList` | closed |
|
||||
| T-19.4.1-32 | Elevation of privilege | another user's decks | medium | mitigate | owner from JWT (`mineCaller`); no owner field, `DisallowUnknownFields`; queries scoped by owner_user_id | closed |
|
||||
| T-19.4.1-33 | Information disclosure | test fixtures and logs | medium | mitigate | test-only secrets; fake siteverify; smoke uses random tokens and passwords, never echoed | closed |
|
||||
| T-19.4.1-34 | Tampering | regression of mitigations from plans 01-04 | high | mitigate | pinning test per threat (T-01, 02, 05, 16, 17, 20, 25, 26, 27, 31; smoke/check-live for T-06, T-13); scripts/check-all.sh | closed |
|
||||
| T-19.4.1-35 | Denial of service | check-live against production | low | accept | AR-19.4.1-05 | closed |
|
||||
| T-19.4.1-SC | Tampering | npm/pip/cargo installs (plans 01-05) | high | mitigate | only `centrifuge 5.7.3` and `vitest 3.2.7` added, exact pins; `protobufjs: false`; `--frozen-lockfile`, `GOWORK=off`, `GOPRIVATE`; go.sum committed; no replace (go_mod_test.go:31) | closed |
|
||||
|
||||
*Status: open · closed · open — below high threshold (non-blocking)*
|
||||
*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open*
|
||||
*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)*
|
||||
|
||||
---
|
||||
|
||||
## Accepted Risks Log
|
||||
|
||||
| Risk ID | Threat Ref | Rationale | Accepted By | Date |
|
||||
|---------|------------|-----------|-------------|------|
|
||||
| AR-19.4.1-01 | T-19.4.1-04 | Seeded content is curated in the repository and every field passes `classes.Clean` at render; text values are HTML-escaped by the builder (`para()` uses `esc()`). | 19.4.1-01 PLAN | 2026-11-05 |
|
||||
| AR-19.4.1-02 | T-19.4.1-22 | Plan rationale: "only subtitle, description and avatar are added; email and login data stay out of authorColumns." **Corrected:** `login` is selected (PLG/journal/controllers/api/authors.go:27-28) and published as `slug` when the author slug is empty. Holds in practice: the demo's only author is `summercms-team` (no superuser, no role, unusable password). A post by a real admin without an author slug would publish that admin's login name while `/smbackend` is public. | 19.4.1-03 PLAN (corrected by audit) | 2026-11-05 |
|
||||
| AR-19.4.1-03 | T-19.4.1-23 | Only en and pl are accepted by the i18n module; any other value falls back to the default. | 19.4.1-03 PLAN | 2026-11-05 |
|
||||
| AR-19.4.1-04 | T-19.4.1-24 | Public by design; the Origin allow-list and throttles gate it. | 19.4.1-03 PLAN | 2026-11-05 |
|
||||
| AR-19.4.1-05 | T-19.4.1-35 | A handful of GET requests and one refused POST per run, inside the existing throttles. | 19.4.1-05 PLAN | 2026-11-05 |
|
||||
|
||||
*Accepted risks do not resurface in future audit runs.*
|
||||
|
||||
---
|
||||
|
||||
## Residual Risk and Notes (non-blocking)
|
||||
|
||||
1. **T-06 register row is stale in 19.4.1-02-PLAN.md** ("admin only through an SSH tunnel"). The verified mitigation is the login-required public `/smbackend` described above (user decision 2026-10-20; 02-SUMMARY flag `auth-surface`, 03-SUMMARY "Threat model update").
|
||||
2. **Later phases changed mitigations:** T-11 (Phase 19.7 routes MCP and OAuth metadata paths; check-deploy enforces the narrower list) and T-26 (Phase 20 gives realtime tokens to chat community members; presentations channels still HMAC-tagged and ownership-checked). Belongs in those phases' audits.
|
||||
3. **The deck wrapper (T-25) is the only public deck write path while** nginx does not route `/api/v1/presentations` **and** demo users lack `golem15.presentations.access_presentations` (needed by the Phase 19.7 presentations MCP tools, incl. `presentations_publish` and deck CSS; no demo group grants it today). If either changes, users get a write path that skips the wrapper's CSS, publish and theme rules.
|
||||
4. **AR-19.4.1-02 wording** corrected above (author `login` published as slug).
|
||||
5. **Local-only admin password literal:** DEMO/scripts/dev.sh:48 `ADMIN_PASSWORD=AdminDemo123!` for the local dev admin, reused as a fallback in smoke-seo.sh:74. Not a production credential, but a committed literal in a private repo.
|
||||
6. **UAT:** 19.4.1-UAT.md is `testing` with 3 pending items. Item 1 (purge token in the payload) is fixed in 866e58c; DEPLOY.md:1046-1055 records the rotation on rome on 2026-10-20 (host-only). Items 2-3 are UAT, not threats.
|
||||
|
||||
### Host-only checks (not verifiable from the repos)
|
||||
|
||||
Repository privacy on git.golem15.com; rome file modes (`.env`, `site.env` 0640 root:summercms-demo, admin file 0600, centrifugo.json 0640); Stripe and Turnstile secrets present on rome; reset schedule off on rome; certbot certificate and Cloudflare real-IP snippet; port probe; purge-token rotation; build machine's 0600 secrets file.
|
||||
|
||||
---
|
||||
|
||||
## Security Audit Trail
|
||||
|
||||
| Audit Date | Threats Total | Closed | Open | Run By |
|
||||
|------------|---------------|--------|------|--------|
|
||||
| 2026-11-05 | 36 | 36 | 0 | msd-security-auditor (ASVS L1, block_on high; static, repo contents only) |
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off
|
||||
|
||||
- [x] All threats have a disposition (mitigate / accept / transfer)
|
||||
- [x] Accepted risks documented in Accepted Risks Log
|
||||
- [x] `threats_open: 0` confirmed
|
||||
- [x] `status: verified` set in frontmatter
|
||||
- [ ] Host-only checks confirmed on rome / git.golem15.com
|
||||
|
||||
**Approval:** verified 2026-11-05 (ASVS L1, repository evidence)
|
||||
Reference in New Issue
Block a user