forked from ericc-ch/copilot-api
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsession-middleware.ts
More file actions
284 lines (251 loc) · 11.2 KB
/
session-middleware.ts
File metadata and controls
284 lines (251 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
/**
* Session middleware for the /admin/* routes and logout endpoint.
*
* Responsibilities:
* 1. Validate the `sid` cookie on every /admin request (except /admin/login).
* 2. Enforce HTTPS-or-loopback: refuse plain HTTP from non-loopback addresses.
* 3. CSRF check on every state-changing (non-GET/HEAD) request.
* 4. Expose the resolved session on the Hono context (`c.get("session")`).
* 5. Provide the POST /admin/session/logout endpoint.
*/
import type { Context, MiddlewareHandler } from "hono"
import consola from "consola"
import { Hono } from "hono"
import crypto from "node:crypto"
import { CSRF_HEADER, extractCsrfCookie, verifyCsrfToken } from "./csrf"
import {
clearSessionCookieValue,
deleteSession,
extractSessionId,
getSession,
sessionCookieValue,
type SessionRow,
} from "./session"
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type SessionVar = { session: SessionRow }
// ---------------------------------------------------------------------------
// HTTPS / loopback guard
// ---------------------------------------------------------------------------
const LOOPBACK_RE = /^(?:127\.\d+\.\d+\.\d+|::1|localhost)$/
/**
* Constant-time string equality. Used to compare CSRF tokens against the
* canonical value stored in the sessions table so a server restart doesn't
* force users to re-login (the HMAC secret changes across processes but
* the DB token does not).
*/
function constantTimeEq(a: string, b: string): boolean {
if (a.length !== b.length) return false
try {
return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b))
} catch {
return false
}
}
function stripBracketsAndPort(host: string): string {
// IPv6 addresses in Host headers arrive as [::1] or [::1]:port
const ipv6Match = /^\[([^\]]+)\](?::\d+)?$/.exec(host)
if (ipv6Match) return ipv6Match[1]
// IPv4 / hostname with optional :port
const colonIdx = host.lastIndexOf(":")
return colonIdx === -1 ? host : host.slice(0, colonIdx)
}
function isLoopback(hostOrHostname: string): boolean {
// WHATWG URL.hostname preserves brackets for IPv6: [::1] → strip them
const bare = hostOrHostname.replaceAll(/^\[|\]$/g, "")
return LOOPBACK_RE.test(bare)
}
/**
* Returns true when the request is safe to serve:
* - over HTTPS (regardless of host), or
* - over HTTP but only from a loopback address, or
* - the operator has explicitly opted into plain HTTP via the
* `ADMIN_INSECURE_HTTP=true` env var (LAN-only convenience for
* self-hosted setups behind a trusted network — session cookies travel
* in the clear and CAN be sniffed; never expose to the open internet).
*
* X-Forwarded-Proto is only consulted when the TRUST_PROXY env var is set to
* "true". Without that flag, any client could forge the header and bypass the
* HTTPS requirement.
*/
export function isRequestAllowed(c: Context): boolean {
// Operator-acknowledged plain-HTTP bypass. Documented in start.ts banner.
if (process.env.ADMIN_INSECURE_HTTP === "true") return true
const trustProxy = process.env.TRUST_PROXY === "true"
const proto = c.req.header("x-forwarded-proto") ?? ""
const host = c.req.header("host") ?? ""
const url = new URL(c.req.url)
// HTTPS check: only trust X-Forwarded-Proto behind a known proxy
const isHttps = (trustProxy && proto === "https") || url.protocol === "https:"
if (isHttps) return true
// Plain HTTP — only allow loopback addresses
const hostNoPort = stripBracketsAndPort(host)
return isLoopback(hostNoPort) || isLoopback(url.hostname)
}
// ---------------------------------------------------------------------------
// Session middleware (applied to all protected /admin/* routes)
// ---------------------------------------------------------------------------
export const sessionMiddleware: MiddlewareHandler<{
Variables: SessionVar
}> = async (c, next) => {
// Enforce HTTPS-or-loopback
if (!isRequestAllowed(c)) {
return c.text("HTTPS required for non-loopback access", 403)
}
const cookieHeader = c.req.header("cookie")
const sessionId = extractSessionId(cookieHeader)
// /admin/api/* is the JSON surface consumed by the React SPA. Returning an
// HTML redirect when a session is missing would be useless to the client —
// serve a 401 JSON instead so the SPA's fetch wrapper can bounce the user
// to /admin/login itself. Pages outside /api still get the HTML redirect.
const isJsonApi = c.req.path.startsWith("/admin/api/")
if (!sessionId) {
if (isJsonApi) {
return c.json({ error: "Not authenticated" }, 401)
}
return c.redirect("/admin/login", 302)
}
// CSRF check on state-changing methods BEFORE the DB session lookup.
// This prevents an attacker with a stolen sid from repeatedly causing DB
// writes (expiry slide) while probing CSRF validity.
const method = c.req.method.toUpperCase()
if (method !== "GET" && method !== "HEAD") {
const fetchSite = c.req.header("sec-fetch-site")
const tokenHeader = c.req.header(CSRF_HEADER)
const tokenCookie = extractCsrfCookie(cookieHeader)
// Sec-Fetch-Site is defense-in-depth on top of the HMAC double-submit
// CSRF token check below (which is the actual cryptographic guarantee).
// We require `same-origin` by default; some older browsers and
// ADMIN_INSECURE_HTTP=true LAN deployments don't always emit the header,
// so we skip the strict check when the bypass is set OR the request
// already includes a valid CSRF token pair (verified below).
const insecureBypass = process.env.ADMIN_INSECURE_HTTP === "true"
if (!insecureBypass && fetchSite !== "same-origin") {
consola.warn("[admin] CSRF: Sec-Fetch-Site must be same-origin")
return c.json({ error: "CSRF: Sec-Fetch-Site must be same-origin" }, 403)
}
// Accept CSRF token from either header (AJAX) or form body field (HTML form)
const tokenBody = await extractCsrfBody(c)
const effectiveToken = tokenHeader ?? tokenBody
if (!effectiveToken || !tokenCookie) {
consola.warn("[admin] CSRF: missing token")
return c.json({ error: "CSRF: missing token" }, 403)
}
// Verify against the HMAC of the session id with our in-process secret.
// This works as long as the server hasn't restarted since the session
// was created. After a restart, the secret regenerates and HMAC fails —
// so we ALSO accept the canonical csrf_token stored in the sessions
// table (which was written at login with the secret of THAT process
// and survives restarts). This way restarts no longer force users to
// re-login: the sessions row is the source of truth.
const sessionRow = getSession(sessionId)
const dbToken = sessionRow?.csrf_token
const matchesHmac =
verifyCsrfToken(sessionId, effectiveToken)
&& verifyCsrfToken(sessionId, tokenCookie)
const matchesDb =
dbToken !== undefined
&& constantTimeEq(effectiveToken, dbToken)
&& constantTimeEq(tokenCookie, dbToken)
if (!matchesHmac && !matchesDb) {
consola.warn("[admin] CSRF: token mismatch")
return c.json({ error: "CSRF: token mismatch" }, 403)
}
}
const session = getSession(sessionId)
if (!session) {
// Session expired or not found — clear cookie and redirect (or 401 JSON
// for the /admin/api/* surface — see comment above).
const headers = new Headers()
headers.set("Set-Cookie", clearSessionCookieValue())
if (isJsonApi) {
headers.set("Content-Type", "application/json")
return new Response(JSON.stringify({ error: "Session expired" }), {
status: 401,
headers,
})
}
headers.set("Location", "/admin/login")
return new Response(null, { status: 302, headers })
}
c.set("session", session)
await next()
// Refresh the session cookie Max-Age on every authenticated response so
// the browser's sliding window stays in sync with the server-side expiry.
c.res.headers.append("Set-Cookie", sessionCookieValue(session.id))
}
/**
* Defense-in-depth guard: re-verify the underlying key is still admin-tier
* and not revoked, on every request to a session-protected admin route.
*
* The login flow already rejects non-admin keys (src/admin/login.tsx), so the
* only way to obtain a session is to authenticate as admin. This middleware
* protects against a regression in that flow, AND against the case where the
* key is revoked after the session is created (in which case the session
* must be terminated and the user redirected to login).
*/
export const requireAdminSession: MiddlewareHandler<{
Variables: SessionVar
}> = async (c, next) => {
const session = c.get("session")
// Lazy require to avoid a cycle with services/keys → lib/db.
const { findKeyById } = await import("~/services/keys")
const key = findKeyById(session.key_id)
if (!key || key.revoked_at !== null || key.tier !== "admin") {
// The session refers to a key that's no longer trustworthy. Tear the
// session down and bounce to login so the operator has to re-authenticate.
// For the JSON API surface, return a 401 instead of an HTML redirect.
deleteSession(session.id)
const headers = new Headers()
headers.set("Set-Cookie", clearSessionCookieValue())
if (c.req.path.startsWith("/admin/api/")) {
headers.set("Content-Type", "application/json")
return new Response(JSON.stringify({ error: "Key revoked" }), {
status: 401,
headers,
})
}
headers.set("Location", "/admin/login")
return new Response(null, { status: 302, headers })
}
await next()
}
/** Try to read a CSRF token from an application/x-www-form-urlencoded body.
*
* Important: we parse with `{ all: true }` so multi-value form fields
* (e.g. allowed_models checkboxes) come back as arrays. Hono caches the
* parsed body on the request object, and the FIRST call's options win — if
* we used the default (all=false), downstream handlers would see flattened
* single-value fields no matter what they request later. (See keys/route.tsx
* scope edit for the affected handler.)
*/
async function extractCsrfBody(c: Context): Promise<string | undefined> {
const ct = c.req.header("content-type") ?? ""
if (!ct.includes("application/x-www-form-urlencoded")) return undefined
try {
const body = await c.req.parseBody({ all: true })
const val = body["csrf_token"]
// With { all: true }, a single-occurrence field is still a string;
// duplicates become an array. We only ever expect one csrf_token, but
// defend against both shapes.
if (typeof val === "string") return val
if (Array.isArray(val) && typeof val[0] === "string") return val[0]
return undefined
} catch {
return undefined
}
}
// ---------------------------------------------------------------------------
// Logout route: POST /admin/session/logout
// ---------------------------------------------------------------------------
const sessionApp = new Hono()
sessionApp.post("/logout", (c) => {
const cookieHeader = c.req.header("cookie")
const sessionId = extractSessionId(cookieHeader)
if (sessionId) deleteSession(sessionId)
const headers = new Headers({ Location: "/admin/login" })
headers.set("Set-Cookie", clearSessionCookieValue())
return new Response(null, { status: 303, headers })
})
export { sessionApp }