Skip to content

Commit 12f4abf

Browse files
MartinCastroAlvarezmartin-castro-laminr-aiclaude
authored
docs(security): refresh threat model §4 for the endpoints shipped since the original pass (#216)
docs/threat-model.md §4 STRIDE'd only the original 6 endpoint groups (registry/list/detail/create-update/delete/shell) with pre-merge "lands in PR #N" language. The surface has grown a lot since; this adds STRIDE coverage (§4.7–4.16) for every endpoint added after, citing the real mitigations + their tests: - 4.7 login/logout (#168/#190) — generic-403 no-enumeration, CSRF, policy-before-session, session-fixation, no password logging. - 4.8 autocomplete (#97) — target-model view gate, no cross-model leak. - 4.9 actions runner (#101) — whitelist via get_actions, never getattr. - 4.10 bulk PATCH (#103) — per-row perms, cap, per-row LogEntry. - 4.11 history (#158/#162) — object-view gate, change-message field-name note, the LogEntry get-queryset-rule exception. - 4.12 delete-preview (#164) — delete-perm gate, counts-only (< HTML admin). - 4.13 inline writes (#183) — per-row-state gates, atomic rollback, deny-by-default unknown inline, generic-400 (no str(exc)). - 4.14 panel hook (#111) — declared-panels-only resolution. - 4.15 schema (#108) — staff-gated static envelope schema. - 4.16 PWA manifest+SW (#86/#200/#208) — no per-user manifest data, scope ≤ mount, no-store honored, cache-on-logout, origin check. Keeps the security doc current with the shipped wire surface — an audit-readiness must for a public security-sensitive package. Docs-only (Tier 1). Co-authored-by: Martin Castro Laminrs <mcastro@laminr.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 92aa9a2 commit 12f4abf

1 file changed

Lines changed: 109 additions & 0 deletions

File tree

docs/threat-model.md

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,115 @@ Same matrix as list with these additions:
160160
| I | The injected `<meta name="dar-mount">` leaks more than the mount | The view sets exactly one meta tag with the resolved mount string (escaped). | S-29 |
161161
| E | T-4 reads `csrftoken` cookie via JS to issue forged requests | `csrftoken` is not `HttpOnly` (by design — the SPA needs it). Mitigation is upstream CSP + same-site cookies. | S-65 |
162162

163+
> **Status note (2026-05-27 refresh).** §4.1–4.6 above were written
164+
> pre-merge ("lands in PR #N"); all six are now on `main` and tested.
165+
> The endpoint surface has since grown — §4.7–4.16 below STRIDE the
166+
> endpoints added after the original pass.
167+
168+
### 4.7 Auth — `POST /api/v1/login/` + `/logout/` (#168, #190 / `api/views/auth.py`)
169+
170+
The package's React-login primitive: a thin JSON shell over Django's
171+
`authenticate` / `login` / `logout`. The **only** anonymous-writable
172+
endpoints in the package — so the highest-scrutiny surface.
173+
174+
| STRIDE | Threat | Mitigation | Acceptance / Test |
175+
| ------ | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ----------------- |
176+
| S | T-1 username enumeration via differential responses/timing | One generic `403 invalid_credentials` for unknown-user / wrong-password / inactive / non-staff. Django's `ModelBackend` runs the hasher even for unknown users (no timing oracle). | `test_auth.py::test_unknown_user_returns_generic_403`, `…wrong_password…` |
177+
| S | T-4 CSRF-forges a login/logout from another origin | CSRF enforced (no `@csrf_exempt`); shell sets the cookie. | `test_auth.py::test_login_without_csrf_is_403`, `…logout_without_csrf…` |
178+
| E | T-2 (valid non-staff) gains a staff session | Access policy (`is_admin_user`) runs **before** `login()`; a valid-but-unauthorized user gets **no** session cookie. | `test_auth.py::test_valid_nonstaff_user_returns_generic_403_no_session` |
179+
| S | Session fixation — reuse a pre-login session id | `django.contrib.auth.login` rotates the session key. | (Django built-in) |
180+
| I | Password leaks into logs/response | Password is read from the body and passed straight to `authenticate`; never logged or echoed. | code review |
181+
| D | T-1 brute-forces credentials | Out of scope (consumer's job) — documented `django-ratelimit`/`django-axes` recommendation. QSEC-01. | n/a |
182+
|| Shell served to anon under `REACT_LOGIN` | Shell carries no user data; every data API call still 403s until auth. | `test_spa_index.py::test_react_login_on_anon_gets_shell_not_redirect` |
183+
184+
### 4.8 `GET /api/v1/<app>/<model>/autocomplete/` (#97)
185+
186+
FK-picker typeahead. Same auth + resolve-via-`_registry` gate as list.
187+
188+
- **I — cross-model leak:** results come from the **target** model's
189+
`has_view_permission` + its `get_queryset`; an operator never sees
190+
rows of a model they can't view. **T — search injection:** delegates
191+
to the admin's search machinery (no raw ORM). **D:** results capped /
192+
paginated. **E:** unregistered/reserved target → 404.
193+
194+
### 4.9 `POST /api/v1/<app>/<model>/actions/<name>/` (#101)
195+
196+
- **E — arbitrary callable:** the action name is re-resolved via
197+
`ModelAdmin.get_actions(request)` — never `getattr` on a
198+
client-supplied string, so only whitelisted admin actions run.
199+
**T — acting on out-of-scope rows:** the action runs over
200+
`get_queryset(request)`-scoped pks; `has_*_permission` gates per
201+
action. **S:** CSRF required (unsafe method).
202+
203+
### 4.10 `PATCH /api/v1/<app>/<model>/bulk/` (#103)
204+
205+
- **T/E — mass edit beyond scope:** `has_change_permission(request,
206+
obj)` checked **per row**; readonly/excluded fields rejected per row.
207+
**S:** CSRF required. **D:** capped at `_BULK_MAX_UPDATES` (400 over
208+
the cap). **R:** each row emits a `LogEntry`. **I:** `no-store`.
209+
210+
### 4.11 `GET /api/v1/<app>/<model>/<pk>/history/` (#158, #162)
211+
212+
LogEntry timeline (`api/views/history.py`, reads via `api/audit.py`).
213+
214+
- **I — read history of an unviewable object:** gated by the object's
215+
own `has_view_permission`; missing/unviewable → 404 (no oracle).
216+
**I — field-name disclosure** via `change_message`: reveals which
217+
fields changed (names, not values) — identical to Django's HTML admin
218+
history; documented non-blocking observation (audit on PR #162).
219+
**Rule-10 exception:** `LogEntry` is Django's own framework table, not
220+
a consumer model, so it's read via `LogEntry.objects.filter(...)`
221+
the get-queryset rule is categorically inapplicable (documented in
222+
`api/audit.py`). **I:** `no-store`. **D:** paginated, clamped.
223+
224+
### 4.12 `GET /api/v1/<app>/<model>/<pk>/delete-preview/` (#164)
225+
226+
- **I — cascade-structure disclosure:** gated by
227+
`has_delete_permission` (not view) so the preview never reveals
228+
cascade for a row the user couldn't delete. Exposes **counts** +
229+
protected-reprs only — *less* than Django's HTML confirm page (the
230+
full deletable tree is computed but discarded). **Never mutates**
231+
(GET, preview only). **I:** `no-store`.
232+
233+
### 4.13 Inline formset writes — `PATCH … {"inlines": …}` (#183 / `api/inlines_write.py`)
234+
235+
- **E — per-row state escalation:** add→`has_add_permission`,
236+
change→`has_change_permission`, delete→`has_delete_permission`, each
237+
against the parent; a single failing gate → 403 and the **whole**
238+
PATCH rolls back (`transaction.atomic`). **T — mass-assign via an
239+
unknown inline:** an `inlines` key not matching a declared inline →
240+
400 (deny-by-default; never silently ignored). **Rule-3:** writes
241+
round-trip through `inline.get_formset(...).save()` (consumer
242+
`clean()`/`save_formset` + signals preserved), never a per-row
243+
`save()` loop. **I:** malformed payload → fixed generic 400 message
244+
(no `str(exc)` echo — CodeQL `py/stack-trace-exposure` cleared, #191).
245+
246+
### 4.14 Panel hook — `GET /api/v1/<app>/<model>/<pk>/panel/<name>/` (#111)
247+
248+
- **E — arbitrary method invocation:** the panel name resolves only
249+
against the `ModelAdmin`'s declared `panels` mapping (opt-in mixin),
250+
never `getattr` on a client string. **I:** output serialized through
251+
the same conservative serializer + denylist. **S:** staff-gated +
252+
object resolved via `get_queryset`.
253+
254+
### 4.15 `GET /api/v1/schema/` (#108)
255+
256+
- **I — surface enumeration:** staff-gated; the envelope schema is
257+
static (OpenAPI 3.1 shape), not a per-consumer model dump, so a
258+
non-staff user gets 403 and a staff user learns only the wire
259+
contract they're already entitled to. Reserved label (`schema`) can't
260+
be shadowed by a consumer app.
261+
262+
### 4.16 PWA — `<mount>/web.manifest` + `<mount>/sw.js` (#86, #200, #208)
263+
264+
| STRIDE | Threat | Mitigation | Test |
265+
| ------ | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ---- |
266+
| I | Anonymous manifest leaks per-user data | Manifest is computed at request time from mount + AdminSite header + static fields — **no** user data. | `test_pwa.py::test_manifest_carries_no_per_user_data` |
267+
| T | SW claims scope beyond the mount (intercepts sibling Django views) | Served with `Service-Worker-Allowed: <mount>`; SW `fetch` passes through anything outside the mount. | `test_pwa.py::test_sw_served_with_scope_header` |
268+
| I | SW caches a `no-store` API read → payload outlives the session | SW skips caching any response whose `Cache-Control` includes `no-store`; **cache-purge on logout** (`dar:purge`). | `test_pwa.py::test_sw_embeds_mount_and_security_guards` |
269+
| S/T | Cross-origin frame drives the SW cache via `postMessage` | Message handler verifies `event.origin === self.location.origin` (CodeQL `js/missing-origin-check`, #208). | `test_pwa.py` (origin-check assertion) |
270+
| T | SW caches a mutation (replay risk) | Non-GET requests are never cached/replayed. | `test_pwa.py` |
271+
163272
---
164273

165274
## 5. Supply-chain

0 commit comments

Comments
 (0)