Threat model — v1
A short, concrete document describing who shithub defends against,
what we protect, and how the controls map to attackers. This is the
v1 baseline; it evolves with the codebase. Per the S35 spec, "two
or three pages, not thirty." Pair with security-checklist.md for
the per-control test references.
Assets
In rough decreasing order of consequence if compromised:
- User credentials. Password hashes, TOTP secrets, recovery codes, PATs, SSH public keys. Direct path to account takeover.
- Repository content. Source code, issues, PR discussions, private-repo files. Privacy + IP value; for some users, source IS the product.
- Webhook secrets + delivery payloads. A leaked webhook secret lets an attacker spoof events to subscriber systems. Payloads often contain user content that hadn't been published.
- Site-admin actions. Suspending users, deleting repos, impersonating accounts. Trust violations here cascade.
- Server-side resources. CPU (argon2 hashing, git ops), disk (repo storage, response-body cap), DB connections.
Attackers
A1 — Compromised user account
The attacker authenticates as a real user (phished password, leaked PAT). They have whatever the user has.
Mitigations:
- Per-account session-epoch invalidation: the user can "log out everywhere" and burn every session at once.
- TOTP 2FA gate (S06) — when enabled, the password alone doesn't log in. Recovery codes one-shot.
- Audit log on security-relevant actions (auth, settings, admin) surfaces the attack to the user + admins.
- Suspended-account gate (
policy.Can) lets an admin freeze a hijacked account without nuking the user's data. - Common-password blocklist + argon2id make password-spraying impractical.
A2 — Malicious public-repo viewer
An anonymous or freshly-signed-up user crafts payloads against the public-repo surface: XSS via issue body, CSRF from a public-org page, SSRF via a webhook to internal infrastructure.
Mitigations:
- All user content is markdown-rendered through
internal/markdownwithbluemondayUGC policy; ad-hoctemplate.HTML(...)is lint-blocked outside designated render helpers. - CSRF protection at the router root (nosurf-derived); state- changing routes inherit it. PAT-only API and git transports are explicit exemptions documented in the lint script.
- SSRF defense (
internal/security/ssrf) on every outbound HTTP: IP block-list + dial-the-IP transport defeats DNS rebinding. - Webhook secret-decrypt failure auto-disables the hook so a compromised key doesn't keep delivering forever.
- CSP, Frame-Options DENY, COOP, CORP cut off the embed/clickjack surface.
A3 — Abusive signup automation
Botnets create accounts in bulk to spam issues, host abuse content, or burn through resources.
Mitigations:
- Per-IP signup throttle (S05): 5/hour.
- Per-/24 signup throttle (S35): 20/hour. Catches spray-from-many- IPs-on-the-same-network patterns.
- Honeypot field on the signup form (silently treats success).
- Email verification required (configurable) gates account activation behind a real inbox.
- Captcha integration is deferred (vendor decision pending); the per-/24 throttle is the live primary defense.
A4 — Supply-chain via webhook subscriber
A compromised subscriber URL receives webhook deliveries and uses the captured payloads to escalate (phishing, replay).
Mitigations:
- HMAC-SHA256 signing on every delivery; subscribers can verify authenticity. Per-webhook secret stored AEAD-encrypted at rest.
- Idempotency key on each delivery so replays are detectable.
- SSRF defense rejects subscriber URLs that resolve to private IPs
(operator can opt in for self-hosted CI via
AllowedHosts). - Auto-disable on persistent failure (50 consecutive). Damage is bounded.
A5 — Insider with admin access
A site admin abuses privileges (looking at user data, mass-deleting repos, impersonating users to act on their behalf).
Mitigations:
- Impersonation defaults read-only (
policy.Can+DenyImpersonationReadOnly); writes require a typed-name confirm. - Visible red sticky banner on every page during impersonation.
- Audit row carries BOTH the real admin id and the impersonated id
(
meta.impersonated_user_id) for forensics. - Bootstrap-admin CLI is the only out-of-band elevation path; all
subsequent grants happen through
/admin/users/{id}and audit. - 404 (not 403) for non-admin
/adminaccess prevents privilege enumeration.
A6 — Resource exhaustion
A determined attacker tries to consume CPU, DB connections, or disk faster than our limits.
Mitigations:
- Body-size caps on auth POSTs (so 10MB password bodies don't burn argon2 cost).
- Repo-create throttle (10/hour/user); content-creation throttles on issues/comments/stars/forks.
- Webhook payload cap (25 MiB); response body cap (32 KiB stored).
- Job queue uses
FOR UPDATE SKIP LOCKEDso contention bounded. - pgx pool max-conns capped per process.
Out of scope (v1)
Documented here so they don't get assumed:
- State-actor adversaries. No claim to defend against an attacker with control over CDN/DNS/CA infrastructure.
- Side-channel attacks on the host. Spectre/Meltdown class defenses are the OS/runtime's responsibility.
- Physical access to servers. Standard ops practice; not in app layer.
- Coordinated disclosure pipeline. Future work.
- Penetration test by external party. Future work.
Review cadence
This document is reviewed at the start of every security-touching sprint (S35, S39 beta hardening) and on any major architecture change (S37 deploy, S44 GraphQL API). Significant updates require a PR with an explicit reviewer note in the description.
S39 hardening review (2026-05-09)
The S39 internal pen-test (3 days, scoped to the OWASP top set + auth + git + webhook SSRF) noted the following considerations for v1 — none introduce a new attacker class, but they sharpen how A1–A6 are addressed:
- A1 — compromised account. The S38 introduction of the
finalized "sign out everywhere" surface (per-account session
epoch) is the operator's primary lever. The audit flagged that
rotating the session signing key
(
docs/internal/runbooks/rotate-secrets.md) is also a global kill-switch — useful for "we suspect the cookie database leaked." Documented; no code change. - A2 — public viewer. The render.go fix landing in S39
(
internal/web/render/render.go) closes a class of silent- blank-page bugs that, while not a vulnerability themselves, made it harder to notice missing authorization gates during development. Fail-loud at parse time is now the rule. - A4 — webhook subscriber. The SSRF defense
(
internal/security/ssrf/) gets re-tested every release; S39 added theaudit-a11yandload-testCI scaffolding but did not change the SSRF surface. - A6 — resource exhaustion. The k6 scenarios in
tests/load/k6/scenarios/exercise the rate-limit floors. The S39 spec calls out "0% 5xx errors; rate-limit-driven 429s expected and counted" — confirmed in the load-test design.
Out-of-band watchlist (track separately)
These don't fit the A1–A6 attacker model but operators should keep an eye on them:
- Dependency-supply-chain on the Go side.
go.sumpinning is enforced; we don't yet do reproducible-build verification. - The docs subdomain serving from Spaces. A bucket
policy mistake there could let an attacker stage a phishing
page on
docs.shithub.sh. Mitigated by Caddy's CSP and the explicit reverse-proxy origin (deploy/docs-site/Caddyfile.snippet). - PAT prefix recognition by external secret scanners.
shp_is documented indocs/public/user/personal-access- tokens.mdand recognised by GitGuardian/GitHub's scanners; if we ever rotate the prefix, coordinate with them so leaked tokens still get caught upstream.
View source
| 1 | # Threat model — v1 |
| 2 | |
| 3 | A short, concrete document describing who shithub defends against, |
| 4 | what we protect, and how the controls map to attackers. This is the |
| 5 | v1 baseline; it evolves with the codebase. Per the S35 spec, "two |
| 6 | or three pages, not thirty." Pair with `security-checklist.md` for |
| 7 | the per-control test references. |
| 8 | |
| 9 | ## Assets |
| 10 | |
| 11 | In rough decreasing order of consequence if compromised: |
| 12 | |
| 13 | 1. **User credentials.** Password hashes, TOTP secrets, recovery |
| 14 | codes, PATs, SSH public keys. Direct path to account takeover. |
| 15 | 2. **Repository content.** Source code, issues, PR discussions, |
| 16 | private-repo files. Privacy + IP value; for some users, source |
| 17 | IS the product. |
| 18 | 3. **Webhook secrets + delivery payloads.** A leaked webhook secret |
| 19 | lets an attacker spoof events to subscriber systems. Payloads |
| 20 | often contain user content that hadn't been published. |
| 21 | 4. **Site-admin actions.** Suspending users, deleting repos, |
| 22 | impersonating accounts. Trust violations here cascade. |
| 23 | 5. **Server-side resources.** CPU (argon2 hashing, git ops), disk |
| 24 | (repo storage, response-body cap), DB connections. |
| 25 | |
| 26 | ## Attackers |
| 27 | |
| 28 | ### A1 — Compromised user account |
| 29 | |
| 30 | The attacker authenticates as a real user (phished password, leaked |
| 31 | PAT). They have whatever the user has. |
| 32 | |
| 33 | **Mitigations:** |
| 34 | - Per-account session-epoch invalidation: the user can "log out |
| 35 | everywhere" and burn every session at once. |
| 36 | - TOTP 2FA gate (S06) — when enabled, the password alone doesn't |
| 37 | log in. Recovery codes one-shot. |
| 38 | - Audit log on security-relevant actions (auth, settings, admin) |
| 39 | surfaces the attack to the user + admins. |
| 40 | - Suspended-account gate (`policy.Can`) lets an admin freeze a |
| 41 | hijacked account without nuking the user's data. |
| 42 | - Common-password blocklist + argon2id make password-spraying |
| 43 | impractical. |
| 44 | |
| 45 | ### A2 — Malicious public-repo viewer |
| 46 | |
| 47 | An anonymous or freshly-signed-up user crafts payloads against the |
| 48 | public-repo surface: XSS via issue body, CSRF from a public-org page, |
| 49 | SSRF via a webhook to internal infrastructure. |
| 50 | |
| 51 | **Mitigations:** |
| 52 | - All user content is markdown-rendered through `internal/markdown` |
| 53 | with `bluemonday` UGC policy; ad-hoc `template.HTML(...)` is |
| 54 | lint-blocked outside designated render helpers. |
| 55 | - CSRF protection at the router root (nosurf-derived); state- |
| 56 | changing routes inherit it. PAT-only API and git transports are |
| 57 | explicit exemptions documented in the lint script. |
| 58 | - SSRF defense (`internal/security/ssrf`) on every outbound HTTP: |
| 59 | IP block-list + dial-the-IP transport defeats DNS rebinding. |
| 60 | - Webhook secret-decrypt failure auto-disables the hook so a |
| 61 | compromised key doesn't keep delivering forever. |
| 62 | - CSP, Frame-Options DENY, COOP, CORP cut off the embed/clickjack |
| 63 | surface. |
| 64 | |
| 65 | ### A3 — Abusive signup automation |
| 66 | |
| 67 | Botnets create accounts in bulk to spam issues, host abuse content, |
| 68 | or burn through resources. |
| 69 | |
| 70 | **Mitigations:** |
| 71 | - Per-IP signup throttle (S05): 5/hour. |
| 72 | - Per-/24 signup throttle (S35): 20/hour. Catches spray-from-many- |
| 73 | IPs-on-the-same-network patterns. |
| 74 | - Honeypot field on the signup form (silently treats success). |
| 75 | - Email verification required (configurable) gates account |
| 76 | activation behind a real inbox. |
| 77 | - Captcha integration is deferred (vendor decision pending); the |
| 78 | per-/24 throttle is the live primary defense. |
| 79 | |
| 80 | ### A4 — Supply-chain via webhook subscriber |
| 81 | |
| 82 | A compromised subscriber URL receives webhook deliveries and uses |
| 83 | the captured payloads to escalate (phishing, replay). |
| 84 | |
| 85 | **Mitigations:** |
| 86 | - HMAC-SHA256 signing on every delivery; subscribers can verify |
| 87 | authenticity. Per-webhook secret stored AEAD-encrypted at rest. |
| 88 | - Idempotency key on each delivery so replays are detectable. |
| 89 | - SSRF defense rejects subscriber URLs that resolve to private IPs |
| 90 | (operator can opt in for self-hosted CI via `AllowedHosts`). |
| 91 | - Auto-disable on persistent failure (50 consecutive). Damage is |
| 92 | bounded. |
| 93 | |
| 94 | ### A5 — Insider with admin access |
| 95 | |
| 96 | A site admin abuses privileges (looking at user data, mass-deleting |
| 97 | repos, impersonating users to act on their behalf). |
| 98 | |
| 99 | **Mitigations:** |
| 100 | - Impersonation defaults read-only (`policy.Can` + |
| 101 | `DenyImpersonationReadOnly`); writes require a typed-name confirm. |
| 102 | - Visible red sticky banner on every page during impersonation. |
| 103 | - Audit row carries BOTH the real admin id and the impersonated id |
| 104 | (`meta.impersonated_user_id`) for forensics. |
| 105 | - Bootstrap-admin CLI is the only out-of-band elevation path; all |
| 106 | subsequent grants happen through `/admin/users/{id}` and audit. |
| 107 | - 404 (not 403) for non-admin `/admin` access prevents privilege |
| 108 | enumeration. |
| 109 | |
| 110 | ### A6 — Resource exhaustion |
| 111 | |
| 112 | A determined attacker tries to consume CPU, DB connections, or disk |
| 113 | faster than our limits. |
| 114 | |
| 115 | **Mitigations:** |
| 116 | - Body-size caps on auth POSTs (so 10MB password bodies don't burn |
| 117 | argon2 cost). |
| 118 | - Repo-create throttle (10/hour/user); content-creation throttles |
| 119 | on issues/comments/stars/forks. |
| 120 | - Webhook payload cap (25 MiB); response body cap (32 KiB stored). |
| 121 | - Job queue uses `FOR UPDATE SKIP LOCKED` so contention bounded. |
| 122 | - pgx pool max-conns capped per process. |
| 123 | |
| 124 | ## Out of scope (v1) |
| 125 | |
| 126 | Documented here so they don't get assumed: |
| 127 | |
| 128 | - **State-actor adversaries.** No claim to defend against an attacker |
| 129 | with control over CDN/DNS/CA infrastructure. |
| 130 | - **Side-channel attacks on the host.** Spectre/Meltdown class |
| 131 | defenses are the OS/runtime's responsibility. |
| 132 | - **Physical access to servers.** Standard ops practice; not in app |
| 133 | layer. |
| 134 | - **Coordinated disclosure pipeline.** Future work. |
| 135 | - **Penetration test by external party.** Future work. |
| 136 | |
| 137 | ## Review cadence |
| 138 | |
| 139 | This document is reviewed at the start of every security-touching |
| 140 | sprint (S35, S39 beta hardening) and on any major architecture |
| 141 | change (S37 deploy, S44 GraphQL API). Significant updates require a |
| 142 | PR with an explicit reviewer note in the description. |
| 143 | |
| 144 | ## S39 hardening review (2026-05-09) |
| 145 | |
| 146 | The S39 internal pen-test (3 days, scoped to the OWASP top set + |
| 147 | auth + git + webhook SSRF) noted the following considerations |
| 148 | for v1 — none introduce a new attacker class, but they sharpen |
| 149 | how A1–A6 are addressed: |
| 150 | |
| 151 | - **A1 — compromised account.** The S38 introduction of the |
| 152 | finalized "sign out everywhere" surface (per-account session |
| 153 | epoch) is the operator's primary lever. The audit flagged that |
| 154 | rotating the session signing key |
| 155 | (`docs/internal/runbooks/rotate-secrets.md`) is also a global |
| 156 | kill-switch — useful for "we suspect the cookie database |
| 157 | leaked." Documented; no code change. |
| 158 | - **A2 — public viewer.** The render.go fix landing in S39 |
| 159 | (`internal/web/render/render.go`) closes a class of silent- |
| 160 | blank-page bugs that, while not a vulnerability themselves, |
| 161 | made it harder to notice missing authorization gates during |
| 162 | development. Fail-loud at parse time is now the rule. |
| 163 | - **A4 — webhook subscriber.** The SSRF defense |
| 164 | (`internal/security/ssrf/`) gets re-tested every release; S39 |
| 165 | added the `audit-a11y` and `load-test` CI scaffolding but did |
| 166 | not change the SSRF surface. |
| 167 | - **A6 — resource exhaustion.** The k6 scenarios in |
| 168 | `tests/load/k6/scenarios/` exercise the rate-limit floors. The |
| 169 | S39 spec calls out "0% 5xx errors; rate-limit-driven 429s |
| 170 | expected and counted" — confirmed in the load-test design. |
| 171 | |
| 172 | ## Out-of-band watchlist (track separately) |
| 173 | |
| 174 | These don't fit the A1–A6 attacker model but operators should |
| 175 | keep an eye on them: |
| 176 | |
| 177 | - **Dependency-supply-chain on the Go side.** `go.sum` pinning |
| 178 | is enforced; we don't yet do reproducible-build verification. |
| 179 | - **The docs subdomain serving from Spaces.** A bucket |
| 180 | policy mistake there could let an attacker stage a phishing |
| 181 | page on `docs.shithub.sh`. Mitigated by Caddy's CSP |
| 182 | and the explicit reverse-proxy origin |
| 183 | (`deploy/docs-site/Caddyfile.snippet`). |
| 184 | - **PAT prefix recognition by external secret scanners.** |
| 185 | `shp_` is documented in `docs/public/user/personal-access- |
| 186 | tokens.md` and recognised by GitGuardian/GitHub's scanners; |
| 187 | if we ever rotate the prefix, coordinate with them so leaked |
| 188 | tokens still get caught upstream. |