also looking at this
Close unauthenticated /api/requests CRUD, and make the session cookie HttpOnly
#77
11 files
+552
-57
| @@ -0,0 +1,80 @@ | ||
| 1 | import { authCookie, clearAuthCookie } from '@stacksjs/auth' | |
| 2 | ||
| 3 | /** | |
| 4 | * Session-cookie plumbing for the auth endpoints. | |
| 5 | * | |
| 6 | * ## Why this exists | |
| 7 | * | |
| 8 | * The session credential used to be written by the BROWSER: | |
| 9 | * `resources/stores/auth.ts` took the token out of the login JSON and did | |
| 10 | * `document.cookie = 'auth-token=…'`. A cookie set that way cannot be | |
| 11 | * `HttpOnly` — the whole point of `document.cookie` is that scripts can | |
| 12 | * write it, and anything a script can write a script can read. So the | |
| 13 | * session token sat in `document.cookie` for 30 days, readable by any | |
| 14 | * injected script. On an app whose core feature is rendering user-submitted | |
| 15 | * rich HTML, that turns a single XSS into full session theft, and the reviewer | |
| 16 | * anonymity the product promises is downstream of those sessions. | |
| 17 | * | |
| 18 | * The fix is to move the write to the server, where `HttpOnly` is available. | |
| 19 | * | |
| 20 | * ## Why this needed almost no new machinery | |
| 21 | * | |
| 22 | * The framework already does cookie auth end-to-end — the app simply wasn't | |
| 23 | * using it: | |
| 24 | * | |
| 25 | * - `authCookie()` (`@stacksjs/auth`) serialises the cookie and ALWAYS | |
| 26 | * includes `HttpOnly`. It resolves the name through `authCookieName()` | |
| 27 | * (`config.auth.cookie.name`, else `defaultTokenName`, else `auth-token`), | |
| 28 | * the lifetime from `config.auth.tokenExpiry`, and `Secure` from | |
| 29 | * `shouldSecureAuthCookie()`, which deliberately omits `Secure` on | |
| 30 | * loopback so sessions still work over plain-http localhost. | |
| 31 | * - `requestToken()` (which `Auth.user()`, `Auth.logout()` and the `auth` | |
| 32 | * middleware all route through) checks `bearerToken()`, then the | |
| 33 | * `Authorization: Bearer` header, then FALLS BACK TO THE COOKIE. So the | |
| 34 | * API already authenticates a cookie-only request. Nothing server-side had | |
| 35 | * to change to accept this. | |
| 36 | * | |
| 37 | * `config/auth.ts` sets `defaultTokenName: 'auth-token'` and declares no | |
| 38 | * `cookie` block, so the resolved name is `auth-token` — which is already what | |
| 39 | * `resources/stores/auth.ts` and `resources/middleware/auth.ts` key off. The | |
| 40 | * three stay in lock-step for free. | |
| 41 | * | |
| 42 | * ## Consequences worth knowing | |
| 43 | * | |
| 44 | * - **The `/api/*` proxy is now load-bearing for auth, not just convenience.** | |
| 45 | * The cookie is `SameSite=Lax`, so it is only sent on same-site requests. The | |
| 46 | * deploy contract already requires the host to proxy `/api/*` onto the page | |
| 47 | * origin (see DEPLOY.md); without it the cookie is never sent and every | |
| 48 | * request is anonymous. | |
| 49 | * - **CSRF posture is unchanged, not worsened.** `requestToken()` already | |
| 50 | * accepted the cookie before this change, so cookie-borne requests were | |
| 51 | * always possible; `SameSite=Lax` is what blocks cross-site state change, and | |
| 52 | * it withholds the cookie on cross-site POST/PUT/DELETE. That matters because | |
| 53 | * every mutating route in `routes/api.ts` carries `.skipCsrf()`. It holds as | |
| 54 | * long as no GET mutates state — currently true, and worth keeping true. | |
| 55 | */ | |
| 56 | ||
| 57 | /** JSON response that also installs the HttpOnly session cookie. */ | |
| 58 | export function withSessionCookie(body: unknown, token: string, status = 200): Response { | |
| 59 | return new Response(JSON.stringify(body), { | |
| 60 | status, | |
| 61 | headers: { | |
| 62 | 'Content-Type': 'application/json', | |
| 63 | 'Set-Cookie': authCookie(token), | |
| 64 | // A cached auth response would hand one visitor another's session. | |
| 65 | 'Cache-Control': 'no-store', | |
| 66 | }, | |
| 67 | }) | |
| 68 | } | |
| 69 | ||
| 70 | /** JSON response that clears the session cookie. */ | |
| 71 | export function withClearedSessionCookie(body: unknown, status = 200): Response { | |
| 72 | return new Response(JSON.stringify(body), { | |
| 73 | status, | |
| 74 | headers: { | |
| 75 | 'Content-Type': 'application/json', | |
| 76 | 'Set-Cookie': clearAuthCookie(), | |
| 77 | 'Cache-Control': 'no-store', | |
| 78 | }, | |
| 79 | }) | |
| 80 | } | |