diff --git a/.planning/phases/11-visual-design-responsive-layout-cleanup/11-SECURITY.md b/.planning/phases/11-visual-design-responsive-layout-cleanup/11-SECURITY.md new file mode 100644 index 0000000..e5eeed0 --- /dev/null +++ b/.planning/phases/11-visual-design-responsive-layout-cleanup/11-SECURITY.md @@ -0,0 +1,84 @@ +--- +phase: 11 +slug: visual-design-responsive-layout-cleanup +status: complete +threats_open: 0 +asvs_level: 1 +created: 2026-06-17 +--- + +# Phase 11 — Security + +> Per-phase security contract: threat register, accepted risks, and audit trail. +> Mode: retroactive-STRIDE — no formal threat model existed at authoring time; register built from implementation. + +--- + +## Trust Boundaries + +| Boundary | Description | Data Crossing | +|----------|-------------|---------------| +| Browser / Vue Router | Navigation guard enforces auth and role separation before any route component resolves | JWT access token (memory), user role claim | +| Browser / API | Document content fetched via authenticated API client using httpOnly cookie; rendered into sandboxed blob URL iframe | Document binary content | +| Build pipeline / Production bundle | `stats.html` bundle visualizer written only when `ANALYZE=true`; gitignored; never shipped in `dist/` | Source file names, module sizes | +| Admin layout / User layout | `AdminLayout.vue` registered exclusively on the `/admin` route subtree, which carries `requiresAdmin: true` meta | Admin-only navigation structure | + +--- + +## Threat Register + +| Threat ID | Category | Component | Disposition | Mitigation | Status | +|-----------|----------|-----------|-------------|------------|--------| +| S-01 | Spoofing | `router/index.js` — lazy route resolution | mitigate | Vue Router 4 `beforeEach` is async and fully resolves before any component is loaded or mounted. Guard at line 93 attempts `authStore.refresh()` before allowing any non-public route. Lazy loading (Plan 11-02) does not change guard sequencing. Verified: router guard tests in `router.guard.test.js` mock all lazy components and confirm guard fires before resolution. | CLOSED | +| S-02 | Spoofing | `App.vue` — hamburger header renders before route settles | mitigate | The hamburger header is inside the `v-else` branch (line 4 of App.vue template), which only renders after `route.meta.layout !== 'auth'` AND `!route.matched.some(r => r.meta.requiresAdmin)`. An unauthenticated navigation is redirected to `/login` before the component tree mounts. Header reveals no session-identifying data; it shows only the app name. | CLOSED | +| S-03 | Spoofing | `AdminLayout.vue` — admin drawer implies admin role | mitigate | `AdminLayout.vue` is only reachable via the `/admin` route subtree (router `index.js` line 47). That route has `meta: { requiresAdmin: true }` (line 48). The guard at lines 104-110 confirms `authStore.user?.role === 'admin'` via `to.matched.some()`; non-admins are redirected to `/`. Tested in `router.guard.test.js` lines 115-135 (child route inheritance). | CLOSED | +| T-01 | Tampering | All Phase 11 templates — content injection via `v-html` | mitigate | Grep over all 10 Phase 11 implementation files finds zero uses of `v-html`, `innerHTML`, or `eval`. All user-data interpolation uses Vue template double-brace syntax (`{{ }}`), which auto-escapes. Folder names, file names, error messages, and share handles are all rendered via text interpolation only. | CLOSED | +| T-02 | Tampering | `StorageBrowser.vue` — drag-and-drop DOM manipulation | accept | The drag-and-drop implementation uses native `dataTransfer.setData('text/plain', file.id)` (line 349). The received `fileId` on drop is a UUID read from the in-memory file object, not from `dataTransfer.getData()`. No untrusted DOM string is used to construct API calls. Accepted: the drag event does not introduce a new injection surface. | CLOSED | +| R-01 | Repudiation | All Phase 11 changes | accept | Phase 11 introduces no event logging, audit trail, or backend changes. Repudiation threats are not applicable to pure CSS/layout frontend changes. No user actions introduced in this phase bypass existing backend audit logging (all document/folder mutations still go through the authenticated API). | CLOSED | +| I-01 | Information Disclosure | `StorageBrowser.vue` — CSS-hidden responsive columns | accept | The `hidden sm:block` / `hidden md:block` classes (lines 41-42, 97-98, 144-145) hide "Size" and "Modified" metadata columns on narrow viewports. These values are already rendered in the DOM and inspectable via browser DevTools. They contain file size bytes and ISO dates — no access-controlled content, PII, or credentials. All data in this component is already visible in the authenticated user's own file list. Risk accepted: CSS column hiding is a display optimization, not a security boundary. | CLOSED | +| I-02 | Information Disclosure | `vite.config.js` — `stats.html` bundle analyzer output | mitigate | The visualizer is conditionally activated only when `ANALYZE=true` is set (strict string equality at line 8 of `vite.config.js`). `frontend/stats.html` is listed in `.gitignore` (confirmed at line 8 of root `.gitignore`). `frontend/dist/` is also gitignored (line 6). The output file is placed in the project root (not `dist/`), so it cannot be served by the production Vite dev server or shipped in the build artifact. | CLOSED | +| I-03 | Information Disclosure | `AppSidebar.vue` — admin navigation link visible to non-admins via DOM | accept | The Admin link in `AppSidebar.vue` is conditionally rendered with `v-if="authStore.user?.role === 'admin'"` (line 169). `v-if` removes the DOM node entirely for non-admin users — it is not CSS-hidden. Even if it were CSS-hidden, the link itself only navigates to `/admin`, which the router guard blocks. No admin data or content is exposed. | CLOSED | +| I-04 | Information Disclosure | `DocumentPreviewModal.vue` — document content in iframe | mitigate | Document content is fetched via `fetchDocumentContent(docId)` using the project's authenticated API client (bearer token injected per `client.js`). The response blob is wrapped in `URL.createObjectURL()` and assigned to a sandboxed `