ReviewOS

also looking at this

stacks/bench-review

Close unauthenticated /api/requests CRUD, and make the session cookie HttpOnly

#77
Merged glennmichael123 wants to merge fix/request-autocrud-and-httponly into main
11 files +552 -57
app/Support/sessionCookie.tsadded+80-0
Changes to app/Support/sessionCookie.ts
@@ -0,0 +1,80 @@
1import { 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. */
58export 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. */
71export 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}