@schlessera/brain-ui-server 0.30.1 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -5
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +49 -25
- package/dist/app.js.map +1 -1
- package/dist/config/env.d.ts +6 -5
- package/dist/config/env.d.ts.map +1 -1
- package/dist/config/env.js +13 -7
- package/dist/config/env.js.map +1 -1
- package/dist/files/walker.d.ts.map +1 -1
- package/dist/files/walker.js +70 -10
- package/dist/files/walker.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +25 -5
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +255 -47
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/body-limit.d.ts +15 -0
- package/dist/middleware/body-limit.d.ts.map +1 -0
- package/dist/middleware/body-limit.js +41 -0
- package/dist/middleware/body-limit.js.map +1 -0
- package/dist/middleware/origin.d.ts +20 -12
- package/dist/middleware/origin.d.ts.map +1 -1
- package/dist/middleware/origin.js +71 -25
- package/dist/middleware/origin.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +5 -1
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +67 -26
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +7 -2
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.js +2 -2
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/models.d.ts.map +1 -1
- package/dist/routes/models.js +22 -10
- package/dist/routes/models.js.map +1 -1
- package/dist/routes/pi-auth.d.ts.map +1 -1
- package/dist/routes/pi-auth.js +12 -4
- package/dist/routes/pi-auth.js.map +1 -1
- package/dist/routes/push.d.ts.map +1 -1
- package/dist/routes/push.js +12 -4
- package/dist/routes/push.js.map +1 -1
- package/dist/routes/render.d.ts +6 -0
- package/dist/routes/render.d.ts.map +1 -1
- package/dist/routes/render.js +8 -11
- package/dist/routes/render.js.map +1 -1
- package/dist/routes/share.d.ts +2 -0
- package/dist/routes/share.d.ts.map +1 -1
- package/dist/routes/share.js +30 -31
- package/dist/routes/share.js.map +1 -1
- package/dist/routes/skills.d.ts.map +1 -1
- package/dist/routes/skills.js +22 -8
- package/dist/routes/skills.js.map +1 -1
- package/dist/routes/web-search.d.ts.map +1 -1
- package/dist/routes/web-search.js +7 -2
- package/dist/routes/web-search.js.map +1 -1
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +2 -1
- package/dist/share/staging.js.map +1 -1
- package/dist/ws/clients.d.ts +13 -4
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +41 -5
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +10 -2
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/host.d.ts +5 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +18 -1
- package/dist/ws/host.js.map +1 -1
- package/package.json +3 -3
- package/src/app.ts +55 -23
- package/src/config/env.ts +18 -7
- package/src/files/walker.ts +66 -10
- package/src/index.ts +8 -0
- package/src/middleware/auth.ts +311 -48
- package/src/middleware/body-limit.ts +47 -0
- package/src/middleware/origin.ts +92 -24
- package/src/middleware/passkeys.ts +80 -29
- package/src/routes/brain.ts +7 -3
- package/src/routes/files.ts +2 -2
- package/src/routes/models.ts +17 -10
- package/src/routes/pi-auth.ts +10 -4
- package/src/routes/push.ts +10 -4
- package/src/routes/render.ts +14 -10
- package/src/routes/share.ts +35 -34
- package/src/routes/skills.ts +18 -8
- package/src/routes/web-search.ts +6 -2
- package/src/share/staging.ts +2 -1
- package/src/ws/clients.ts +45 -7
- package/src/ws/connection.ts +11 -2
- package/src/ws/host.ts +23 -1
package/src/middleware/auth.ts
CHANGED
|
@@ -3,8 +3,13 @@ import type { Logger } from "@opentelemetry/api-logs";
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import type { Context, MiddlewareHandler } from "hono";
|
|
5
5
|
import { getSignedCookie, setSignedCookie, deleteCookie } from "hono/cookie";
|
|
6
|
+
import type { Database } from "bun:sqlite";
|
|
6
7
|
import { isTailscaleAllowed, clientIp } from "./tailscale.js";
|
|
7
8
|
import type { AuthConfig } from "../config/env.js";
|
|
9
|
+
import { readJsonBody } from "./body-limit.js";
|
|
10
|
+
import { requireJson } from "./origin.js";
|
|
11
|
+
import { setSetting } from "../db/settings.js";
|
|
12
|
+
import type { ClientSet } from "../ws/clients.js";
|
|
8
13
|
|
|
9
14
|
/**
|
|
10
15
|
* Authentication for a remote surface to an agent with write access to the
|
|
@@ -40,15 +45,39 @@ export interface AuthRuntime extends AuthConfig {
|
|
|
40
45
|
|
|
41
46
|
const COOKIE_NAME = "brain_ui_session";
|
|
42
47
|
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
|
|
43
|
-
|
|
48
|
+
const SESSIONS_EPOCH_KEY = "auth.sessionsEpoch";
|
|
49
|
+
const SESSION_REVOKED_CLOSE_CODE = 1008;
|
|
50
|
+
const SESSION_REVOKED_CLOSE_REASON = "Sessions invalidated";
|
|
51
|
+
export const LOGIN_RATE_LIMIT = 5; // failures per window, per client IP
|
|
44
52
|
const LOGIN_RATE_WINDOW_MS = 60_000; // per minute
|
|
45
53
|
// A global cap in addition to the per-IP one: the per-IP key is derived from
|
|
46
54
|
// X-Forwarded-For, which a client behind a trusted proxy can rotate to mint a
|
|
47
55
|
// fresh bucket per request. The global cap bounds brute force across rotated
|
|
48
|
-
// IPs.
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
56
|
+
// IPs. At 100 genuine failures/minute it stays well above a human's needs and
|
|
57
|
+
// makes a global lockout substantially harder to weaponize, while still
|
|
58
|
+
// bounding distributed online guessing. Per-IP failures remain at 5/minute.
|
|
59
|
+
// Passkeys get 5 matched-but-invalid assertions/minute in an independent
|
|
60
|
+
// `pk:` bucket: assertions are not guessable, but verification still consumes
|
|
61
|
+
// resources. Password and passkey verification therefore each allow two
|
|
62
|
+
// in-flight operations per IP and eight process-wide. Two tolerates a browser
|
|
63
|
+
// retry/double-submit; eight bounds either verifier's comparable CPU cost.
|
|
64
|
+
// Keeping the reservation pools separate preserves passkeys' independent
|
|
65
|
+
// admission path while each pool still has a process-wide ceiling. The failure
|
|
66
|
+
// map and both reservation maps cap at 1,024 source keys, comfortably above
|
|
67
|
+
// the global password-failure budget while bounding forged-IP memory growth.
|
|
68
|
+
export const GLOBAL_LOGIN_RATE_LIMIT = 100; // failures per window, all IPs combined
|
|
69
|
+
export const PASSKEY_LOGIN_RATE_LIMIT = 5;
|
|
70
|
+
const PASSWORD_VERIFY_IN_FLIGHT_PER_IP = 2;
|
|
71
|
+
const PASSWORD_VERIFY_IN_FLIGHT_GLOBAL = 8;
|
|
72
|
+
const PASSKEY_VERIFY_IN_FLIGHT_PER_IP = 2;
|
|
73
|
+
const PASSKEY_VERIFY_IN_FLIGHT_GLOBAL = 8;
|
|
74
|
+
const LOGIN_BUCKET_CAP = 1_024;
|
|
75
|
+
// Client-derived password buckets always start `pw:ip:`, which cannot equal
|
|
76
|
+
// the fixed `pw:global` key for any client-controlled key suffix. Keeping these
|
|
77
|
+
// namespaces disjoint prevents a per-IP saturated write from clamping the
|
|
78
|
+
// authoritative global failure counter to the smaller per-IP limit.
|
|
79
|
+
const PASSWORD_IP_BUCKET_PREFIX = "pw:ip:";
|
|
80
|
+
const PASSWORD_GLOBAL_BUCKET = "pw:global";
|
|
52
81
|
|
|
53
82
|
export function resolveAuthMode(auth: AuthRuntime, log?: Logger): AuthMode {
|
|
54
83
|
if (auth.mode) return auth.mode;
|
|
@@ -139,7 +168,11 @@ export function assertAuthConfig(mode: AuthMode, auth: AuthRuntime, log?: Logger
|
|
|
139
168
|
}
|
|
140
169
|
|
|
141
170
|
/** Middleware guarding /api/* according to the resolved mode. */
|
|
142
|
-
export function authGuard(
|
|
171
|
+
export function authGuard(
|
|
172
|
+
mode: AuthMode,
|
|
173
|
+
auth: AuthRuntime,
|
|
174
|
+
db: Database
|
|
175
|
+
): MiddlewareHandler {
|
|
143
176
|
switch (mode) {
|
|
144
177
|
case "tailscale":
|
|
145
178
|
return async (c, next) => {
|
|
@@ -162,7 +195,7 @@ export function authGuard(mode: AuthMode, auth: AuthRuntime): MiddlewareHandler
|
|
|
162
195
|
};
|
|
163
196
|
case "password":
|
|
164
197
|
return async (c, next) => {
|
|
165
|
-
if (await hasValidSession(c, auth)) {
|
|
198
|
+
if (await hasValidSession(c, auth, db)) {
|
|
166
199
|
await next();
|
|
167
200
|
} else {
|
|
168
201
|
return c.json(
|
|
@@ -188,7 +221,8 @@ export function authGuard(mode: AuthMode, auth: AuthRuntime): MiddlewareHandler
|
|
|
188
221
|
export async function isWsAuthorized(
|
|
189
222
|
c: Context,
|
|
190
223
|
mode: AuthMode,
|
|
191
|
-
auth: AuthRuntime
|
|
224
|
+
auth: AuthRuntime,
|
|
225
|
+
db: Database
|
|
192
226
|
): Promise<boolean> {
|
|
193
227
|
switch (mode) {
|
|
194
228
|
case "none":
|
|
@@ -198,7 +232,7 @@ export async function isWsAuthorized(
|
|
|
198
232
|
case "proxy":
|
|
199
233
|
return hasProxyAuth(c, auth);
|
|
200
234
|
case "password":
|
|
201
|
-
return hasValidSession(c, auth);
|
|
235
|
+
return hasValidSession(c, auth, db);
|
|
202
236
|
}
|
|
203
237
|
}
|
|
204
238
|
|
|
@@ -209,9 +243,14 @@ export async function isWsAuthorized(
|
|
|
209
243
|
* both password login and passkey login (middleware/passkeys.ts) call this, so
|
|
210
244
|
* authGuard / isWsAuthorized / TTL semantics stay identical across methods.
|
|
211
245
|
*/
|
|
212
|
-
export async function issueSessionCookie(
|
|
246
|
+
export async function issueSessionCookie(
|
|
247
|
+
c: Context,
|
|
248
|
+
auth: AuthRuntime,
|
|
249
|
+
db: Database
|
|
250
|
+
): Promise<void> {
|
|
213
251
|
const secret = auth.cookieSecret ?? "";
|
|
214
|
-
|
|
252
|
+
const epoch = sessionsEpoch(db);
|
|
253
|
+
await setSignedCookie(c, COOKIE_NAME, `${Date.now()}.${epoch}`, secret, {
|
|
215
254
|
httpOnly: true,
|
|
216
255
|
sameSite: "Strict",
|
|
217
256
|
// Always Secure — every real deployment serves over HTTPS, and we do not
|
|
@@ -223,22 +262,71 @@ export async function issueSessionCookie(c: Context, auth: AuthRuntime): Promise
|
|
|
223
262
|
});
|
|
224
263
|
}
|
|
225
264
|
|
|
226
|
-
async function hasValidSession(
|
|
265
|
+
async function hasValidSession(
|
|
266
|
+
c: Context,
|
|
267
|
+
auth: AuthRuntime,
|
|
268
|
+
db: Database
|
|
269
|
+
): Promise<boolean> {
|
|
227
270
|
const secret = auth.cookieSecret ?? "";
|
|
228
271
|
if (!secret) return false;
|
|
272
|
+
let value: string | false | undefined;
|
|
229
273
|
try {
|
|
230
|
-
|
|
231
|
-
if (typeof value !== "string" || value.length === 0) return false;
|
|
232
|
-
// The cookie value is the issue timestamp. Validate its age server-side:
|
|
233
|
-
// Max-Age is client-discardable, so the signature alone does not bound a
|
|
234
|
-
// session's lifetime — this does.
|
|
235
|
-
const issuedAt = Number(value);
|
|
236
|
-
if (!Number.isFinite(issuedAt)) return false;
|
|
237
|
-
const ageSeconds = (Date.now() - issuedAt) / 1000;
|
|
238
|
-
return ageSeconds >= 0 && ageSeconds < SESSION_TTL_SECONDS;
|
|
274
|
+
value = await getSignedCookie(c, secret, COOKIE_NAME);
|
|
239
275
|
} catch {
|
|
240
276
|
return false;
|
|
241
277
|
}
|
|
278
|
+
if (typeof value !== "string" || value.length === 0) return false;
|
|
279
|
+
|
|
280
|
+
// Both fields are decimal integers. Requiring the exact shape rejects every
|
|
281
|
+
// pre-epoch cookie instead of accidentally interpreting its timestamp as a
|
|
282
|
+
// current session.
|
|
283
|
+
const match = /^(\d+)\.(\d+)$/.exec(value);
|
|
284
|
+
if (!match) return false;
|
|
285
|
+
const issuedAt = Number(match[1]);
|
|
286
|
+
const cookieEpoch = Number(match[2]);
|
|
287
|
+
if (!Number.isSafeInteger(issuedAt) || !Number.isSafeInteger(cookieEpoch)) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
const ageSeconds = (Date.now() - issuedAt) / 1000;
|
|
291
|
+
if (ageSeconds < 0 || ageSeconds >= SESSION_TTL_SECONDS) return false;
|
|
292
|
+
|
|
293
|
+
// Deliberately outside the cookie-parser catch: corrupt authoritative auth
|
|
294
|
+
// state refuses loudly and must never degrade to epoch zero.
|
|
295
|
+
return cookieEpoch === sessionsEpoch(db);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Read authoritative session state. A missing row is first-run epoch zero;
|
|
300
|
+
* any present row that is not a non-negative safe integer is corruption.
|
|
301
|
+
*/
|
|
302
|
+
function sessionsEpoch(db: Database): number {
|
|
303
|
+
const row = db
|
|
304
|
+
.query("SELECT value FROM settings WHERE key = ?")
|
|
305
|
+
.get(SESSIONS_EPOCH_KEY) as { value: string } | null;
|
|
306
|
+
if (!row) return 0;
|
|
307
|
+
|
|
308
|
+
let value: unknown;
|
|
309
|
+
try {
|
|
310
|
+
value = JSON.parse(row.value);
|
|
311
|
+
} catch {
|
|
312
|
+
throw new Error(`Corrupt ${SESSIONS_EPOCH_KEY}: expected a JSON integer`);
|
|
313
|
+
}
|
|
314
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
315
|
+
throw new Error(`Corrupt ${SESSIONS_EPOCH_KEY}: expected a non-negative integer`);
|
|
316
|
+
}
|
|
317
|
+
return value as number;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Invalidate every issued session and disconnect every attached client. */
|
|
321
|
+
export function bumpSessionsEpoch(db: Database, clients: ClientSet): number {
|
|
322
|
+
const current = sessionsEpoch(db);
|
|
323
|
+
if (current === Number.MAX_SAFE_INTEGER) {
|
|
324
|
+
throw new Error(`${SESSIONS_EPOCH_KEY} cannot be advanced safely`);
|
|
325
|
+
}
|
|
326
|
+
const next = current + 1;
|
|
327
|
+
setSetting(db, SESSIONS_EPOCH_KEY, next);
|
|
328
|
+
clients.closeAll(SESSION_REVOKED_CLOSE_CODE, SESSION_REVOKED_CLOSE_REASON);
|
|
329
|
+
return next;
|
|
242
330
|
}
|
|
243
331
|
|
|
244
332
|
// --- proxy mode helpers ---
|
|
@@ -253,28 +341,161 @@ function hasProxyAuth(c: Context, auth: AuthRuntime): boolean {
|
|
|
253
341
|
return !!user && user.trim().length > 0;
|
|
254
342
|
}
|
|
255
343
|
|
|
256
|
-
// --- login rate limiter (in-memory
|
|
344
|
+
// --- login rate limiter (in-memory failure windows + verification reservations) ---
|
|
257
345
|
|
|
258
346
|
interface Bucket {
|
|
259
|
-
|
|
347
|
+
failures: number;
|
|
260
348
|
resetAt: number;
|
|
261
349
|
}
|
|
262
350
|
const loginBuckets = new Map<string, Bucket>();
|
|
263
351
|
|
|
352
|
+
interface InFlightBucket {
|
|
353
|
+
count: number;
|
|
354
|
+
expiresAt: number;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
interface VerificationReservations {
|
|
358
|
+
acquire(ip: string): boolean;
|
|
359
|
+
release(ip: string): void;
|
|
360
|
+
reset(): void;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function pruneExpiredBuckets(now: number): void {
|
|
364
|
+
for (const [key, bucket] of loginBuckets) {
|
|
365
|
+
if (now >= bucket.resetAt) loginBuckets.delete(key);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function makeBucketRoom(): void {
|
|
370
|
+
while (loginBuckets.size >= LOGIN_BUCKET_CAP) {
|
|
371
|
+
// Keep the global password bucket authoritative. The cap is larger than
|
|
372
|
+
// its maximum number of per-IP contributors in one window, so ordinary
|
|
373
|
+
// password traffic never needs to evict an active per-IP failure record.
|
|
374
|
+
const oldest = [...loginBuckets.keys()].find(
|
|
375
|
+
(key) => key !== PASSWORD_GLOBAL_BUCKET
|
|
376
|
+
);
|
|
377
|
+
if (oldest === undefined) break;
|
|
378
|
+
loginBuckets.delete(oldest);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function bucketForWrite(key: string, now: number): Bucket {
|
|
383
|
+
pruneExpiredBuckets(now);
|
|
384
|
+
const existing = loginBuckets.get(key);
|
|
385
|
+
if (existing) return existing;
|
|
386
|
+
makeBucketRoom();
|
|
387
|
+
const bucket = { failures: 0, resetAt: now + LOGIN_RATE_WINDOW_MS };
|
|
388
|
+
loginBuckets.set(key, bucket);
|
|
389
|
+
return bucket;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Record one genuine authentication failure in a fixed one-minute window. */
|
|
393
|
+
export function recordLoginFailure(key: string, limit: number): void {
|
|
394
|
+
const bucket = bucketForWrite(key, Date.now());
|
|
395
|
+
// Saturate: callers only care whether the limit was reached, and this avoids
|
|
396
|
+
// an unbounded counter if a test or future caller records after blocking.
|
|
397
|
+
bucket.failures = Math.min(limit, bucket.failures + 1);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Read without allocating: junk requests cannot grow the failure map. */
|
|
401
|
+
export function isLoginBlocked(key: string, limit: number): boolean {
|
|
402
|
+
const now = Date.now();
|
|
403
|
+
const bucket = loginBuckets.get(key);
|
|
404
|
+
if (!bucket) return false;
|
|
405
|
+
if (now >= bucket.resetAt) {
|
|
406
|
+
loginBuckets.delete(key);
|
|
407
|
+
return false;
|
|
408
|
+
}
|
|
409
|
+
return bucket.failures >= limit;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function createVerificationReservations(
|
|
413
|
+
perIpLimit: number,
|
|
414
|
+
globalLimit: number
|
|
415
|
+
): VerificationReservations {
|
|
416
|
+
const buckets = new Map<string, InFlightBucket>();
|
|
417
|
+
let totalInFlight = 0;
|
|
418
|
+
|
|
419
|
+
function prune(now: number): void {
|
|
420
|
+
for (const [key, bucket] of buckets) {
|
|
421
|
+
if (bucket.count === 0 && now >= bucket.expiresAt) buckets.delete(key);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
acquire(ip) {
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
prune(now);
|
|
429
|
+
if (totalInFlight >= globalLimit) return false;
|
|
430
|
+
|
|
431
|
+
let bucket = buckets.get(ip);
|
|
432
|
+
if (bucket && bucket.count >= perIpLimit) return false;
|
|
433
|
+
if (!bucket) {
|
|
434
|
+
while (buckets.size >= LOGIN_BUCKET_CAP) {
|
|
435
|
+
const idle = [...buckets].find(([, entry]) => entry.count === 0);
|
|
436
|
+
if (!idle) return false;
|
|
437
|
+
buckets.delete(idle[0]);
|
|
438
|
+
}
|
|
439
|
+
bucket = { count: 0, expiresAt: now + LOGIN_RATE_WINDOW_MS };
|
|
440
|
+
buckets.set(ip, bucket);
|
|
441
|
+
}
|
|
442
|
+
bucket.count++;
|
|
443
|
+
bucket.expiresAt = now + LOGIN_RATE_WINDOW_MS;
|
|
444
|
+
totalInFlight++;
|
|
445
|
+
return true;
|
|
446
|
+
},
|
|
447
|
+
release(ip) {
|
|
448
|
+
const bucket = buckets.get(ip);
|
|
449
|
+
if (!bucket || bucket.count === 0) return;
|
|
450
|
+
bucket.count--;
|
|
451
|
+
bucket.expiresAt = Date.now() + LOGIN_RATE_WINDOW_MS;
|
|
452
|
+
totalInFlight--;
|
|
453
|
+
},
|
|
454
|
+
reset() {
|
|
455
|
+
buckets.clear();
|
|
456
|
+
totalInFlight = 0;
|
|
457
|
+
},
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const passwordVerifications = createVerificationReservations(
|
|
462
|
+
PASSWORD_VERIFY_IN_FLIGHT_PER_IP,
|
|
463
|
+
PASSWORD_VERIFY_IN_FLIGHT_GLOBAL
|
|
464
|
+
);
|
|
465
|
+
const passkeyVerifications = createVerificationReservations(
|
|
466
|
+
PASSKEY_VERIFY_IN_FLIGHT_PER_IP,
|
|
467
|
+
PASSKEY_VERIFY_IN_FLIGHT_GLOBAL
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
function acquirePasswordVerification(ip: string): boolean {
|
|
471
|
+
return passwordVerifications.acquire(ip);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function releasePasswordVerification(ip: string): void {
|
|
475
|
+
passwordVerifications.release(ip);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Internal middleware coordination; not exported from the package root. */
|
|
479
|
+
export function acquirePasskeyVerification(ip: string): boolean {
|
|
480
|
+
return passkeyVerifications.acquire(ip);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Internal middleware coordination; not exported from the package root. */
|
|
484
|
+
export function releasePasskeyVerification(ip: string): void {
|
|
485
|
+
passkeyVerifications.release(ip);
|
|
486
|
+
}
|
|
487
|
+
|
|
264
488
|
/** Test-only: clear all rate-limit buckets (they are module-global state). */
|
|
265
489
|
export function resetLoginRateLimiter(): void {
|
|
266
490
|
loginBuckets.clear();
|
|
491
|
+
passwordVerifications.reset();
|
|
492
|
+
passkeyVerifications.reset();
|
|
267
493
|
}
|
|
268
494
|
|
|
495
|
+
/** Attempt-counting bucket retained for passkey option generation. */
|
|
269
496
|
export function consumeLoginToken(key: string, limit: number): boolean {
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (!bucket || now >= bucket.resetAt) {
|
|
273
|
-
bucket = { tokens: limit, resetAt: now + LOGIN_RATE_WINDOW_MS };
|
|
274
|
-
loginBuckets.set(key, bucket);
|
|
275
|
-
}
|
|
276
|
-
if (bucket.tokens <= 0) return false;
|
|
277
|
-
bucket.tokens--;
|
|
497
|
+
if (isLoginBlocked(key, limit)) return false;
|
|
498
|
+
recordLoginFailure(key, limit);
|
|
278
499
|
return true;
|
|
279
500
|
}
|
|
280
501
|
|
|
@@ -283,6 +504,10 @@ export function authRoutes(
|
|
|
283
504
|
mode: AuthMode,
|
|
284
505
|
auth: AuthRuntime,
|
|
285
506
|
deps: {
|
|
507
|
+
/** Authoritative session state. */
|
|
508
|
+
db: Database;
|
|
509
|
+
/** Live sockets closed whenever the global session epoch advances. */
|
|
510
|
+
clients: ClientSet;
|
|
286
511
|
/**
|
|
287
512
|
* When provided and returning true, password login is refused (the app
|
|
288
513
|
* injects passkeys' passwordLoginDisabled so the shared password dies
|
|
@@ -294,29 +519,36 @@ export function authRoutes(
|
|
|
294
519
|
log?: Logger;
|
|
295
520
|
/** Failed-login counter — the same `auth.failures` instrument passkeys use. */
|
|
296
521
|
failures?: Counter;
|
|
297
|
-
|
|
522
|
+
/** Test-only password verifier; production uses Bun.password.verify. */
|
|
523
|
+
verifyPassword?: (password: string, hash: string) => Promise<boolean>;
|
|
524
|
+
}
|
|
298
525
|
): Hono {
|
|
299
526
|
const app = new Hono();
|
|
300
527
|
const { log, failures } = deps;
|
|
528
|
+
const verifyPassword = deps.verifyPassword ?? Bun.password.verify;
|
|
529
|
+
let warnedUntrustedForwardedFor = false;
|
|
301
530
|
|
|
302
|
-
app.post("/auth/login", async (c) => {
|
|
531
|
+
app.post("/auth/login", requireJson(), async (c) => {
|
|
303
532
|
if (mode !== "password") {
|
|
304
533
|
return c.json({ error: "Password login is not enabled" }, 400);
|
|
305
534
|
}
|
|
306
535
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
536
|
+
if (
|
|
537
|
+
!auth.trustProxy &&
|
|
538
|
+
!warnedUntrustedForwardedFor &&
|
|
539
|
+
c.req.header("x-forwarded-for")
|
|
540
|
+
) {
|
|
541
|
+
warnedUntrustedForwardedFor = true;
|
|
312
542
|
log?.emit({
|
|
313
543
|
severityText: "WARN",
|
|
314
|
-
body: "
|
|
315
|
-
attributes: {
|
|
544
|
+
body: "X-Forwarded-For ignored in password mode",
|
|
545
|
+
attributes: { "auth.mode": "password", "auth.trust_proxy": false },
|
|
316
546
|
});
|
|
317
|
-
return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
|
|
318
547
|
}
|
|
319
548
|
|
|
549
|
+
const key = clientIp(c, auth.trustProxy, auth.trustProxyHops) || "unknown";
|
|
550
|
+
const perIpBucket = `${PASSWORD_IP_BUCKET_PREFIX}${key}`;
|
|
551
|
+
|
|
320
552
|
if (deps.passwordDisabled?.(c)) {
|
|
321
553
|
return c.json(
|
|
322
554
|
{ error: "Password login is disabled — sign in with a passkey." },
|
|
@@ -324,23 +556,46 @@ export function authRoutes(
|
|
|
324
556
|
);
|
|
325
557
|
}
|
|
326
558
|
|
|
559
|
+
// Refuse a blocked client before reading its body: admission control is
|
|
560
|
+
// cheapest when it costs no parsing.
|
|
561
|
+
const perIpBlocked = isLoginBlocked(perIpBucket, LOGIN_RATE_LIMIT);
|
|
562
|
+
const globalBlocked = isLoginBlocked(
|
|
563
|
+
PASSWORD_GLOBAL_BUCKET,
|
|
564
|
+
GLOBAL_LOGIN_RATE_LIMIT
|
|
565
|
+
);
|
|
566
|
+
if (perIpBlocked || globalBlocked) {
|
|
567
|
+
failures?.add(1, { reason: "rate_limited", method: "password" });
|
|
568
|
+
log?.emit({
|
|
569
|
+
severityText: "WARN",
|
|
570
|
+
body: "login rate limited",
|
|
571
|
+
attributes: { ip: key, limit: perIpBlocked ? "ip" : "global" },
|
|
572
|
+
});
|
|
573
|
+
return c.json({ error: "Too many attempts. Try again in a minute." }, 429);
|
|
574
|
+
}
|
|
575
|
+
|
|
327
576
|
const hash = auth.passwordHash ?? "";
|
|
328
577
|
const secret = auth.cookieSecret ?? "";
|
|
329
578
|
let body: { password?: unknown };
|
|
330
579
|
try {
|
|
331
|
-
|
|
580
|
+
const result = await readJsonBody<{ password?: unknown }>(c);
|
|
581
|
+
if (result instanceof Response) return result;
|
|
582
|
+
body = result;
|
|
332
583
|
} catch {
|
|
333
584
|
return c.json({ error: "Invalid request body" }, 400);
|
|
334
585
|
}
|
|
335
586
|
const password = typeof body.password === "string" ? body.password : "";
|
|
336
587
|
if (!password || !hash || !secret) {
|
|
337
|
-
|
|
588
|
+
return c.json({ error: "Invalid credentials" }, 401);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (!acquirePasswordVerification(key)) {
|
|
592
|
+
failures?.add(1, { reason: "in_flight_limited", method: "password" });
|
|
338
593
|
log?.emit({
|
|
339
594
|
severityText: "WARN",
|
|
340
|
-
body: "
|
|
595
|
+
body: "password verification capacity reached",
|
|
341
596
|
attributes: { ip: key },
|
|
342
597
|
});
|
|
343
|
-
return c.json({ error: "
|
|
598
|
+
return c.json({ error: "Too many login verifications in progress." }, 429);
|
|
344
599
|
}
|
|
345
600
|
|
|
346
601
|
// A verify that THROWS is not a wrong password — it is a hash Bun cannot
|
|
@@ -349,9 +604,11 @@ export function authRoutes(
|
|
|
349
604
|
let ok = false;
|
|
350
605
|
let verifyError: unknown = null;
|
|
351
606
|
try {
|
|
352
|
-
ok = await
|
|
607
|
+
ok = await verifyPassword(password, hash);
|
|
353
608
|
} catch (err) {
|
|
354
609
|
verifyError = err;
|
|
610
|
+
} finally {
|
|
611
|
+
releasePasswordVerification(key);
|
|
355
612
|
}
|
|
356
613
|
if (verifyError) {
|
|
357
614
|
failures?.add(1, { reason: "verify_error", method: "password" });
|
|
@@ -365,6 +622,8 @@ export function authRoutes(
|
|
|
365
622
|
return c.json({ error: "Invalid credentials" }, 401);
|
|
366
623
|
}
|
|
367
624
|
if (!ok) {
|
|
625
|
+
recordLoginFailure(perIpBucket, LOGIN_RATE_LIMIT);
|
|
626
|
+
recordLoginFailure(PASSWORD_GLOBAL_BUCKET, GLOBAL_LOGIN_RATE_LIMIT);
|
|
368
627
|
failures?.add(1, { reason: "invalid_password", method: "password" });
|
|
369
628
|
log?.emit({
|
|
370
629
|
severityText: "WARN",
|
|
@@ -374,7 +633,7 @@ export function authRoutes(
|
|
|
374
633
|
return c.json({ error: "Invalid credentials" }, 401);
|
|
375
634
|
}
|
|
376
635
|
|
|
377
|
-
await issueSessionCookie(c, auth);
|
|
636
|
+
await issueSessionCookie(c, auth, deps.db);
|
|
378
637
|
log?.emit({
|
|
379
638
|
severityText: "INFO",
|
|
380
639
|
body: "login succeeded",
|
|
@@ -383,7 +642,11 @@ export function authRoutes(
|
|
|
383
642
|
return c.json({ ok: true });
|
|
384
643
|
});
|
|
385
644
|
|
|
386
|
-
app.post("/auth/logout", (c) => {
|
|
645
|
+
app.post("/auth/logout", async (c) => {
|
|
646
|
+
if (mode === "password" && !(await hasValidSession(c, auth, deps.db))) {
|
|
647
|
+
return c.json({ error: "Authentication required", authRequired: true }, 401);
|
|
648
|
+
}
|
|
649
|
+
if (mode === "password") bumpSessionsEpoch(deps.db, deps.clients);
|
|
387
650
|
deleteCookie(c, COOKIE_NAME, { path: "/" });
|
|
388
651
|
return c.json({ ok: true });
|
|
389
652
|
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
|
|
3
|
+
/** Ordinary JSON control-plane requests never need to be large. */
|
|
4
|
+
export const JSON_BODY_LIMIT_BYTES = 256 * 1024;
|
|
5
|
+
|
|
6
|
+
/** Render requests may contain inlined images and SVG. */
|
|
7
|
+
export const RENDER_BODY_LIMIT_BYTES = 5_000_000;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read and parse a JSON request without replacing the request object.
|
|
11
|
+
*
|
|
12
|
+
* Content-Length can reject an oversized request before its stream is touched.
|
|
13
|
+
* Lengthless and chunked bodies are counted while streaming and cancelled as
|
|
14
|
+
* soon as they cross the cap. Returning the 413 response lets route-local
|
|
15
|
+
* invalid-JSON handling remain distinct from an oversized request.
|
|
16
|
+
*/
|
|
17
|
+
export async function readJsonBody<T = unknown>(
|
|
18
|
+
c: Context,
|
|
19
|
+
maxBytes = JSON_BODY_LIMIT_BYTES
|
|
20
|
+
): Promise<T | Response> {
|
|
21
|
+
const contentLength = c.req.header("content-length");
|
|
22
|
+
if (contentLength !== undefined) {
|
|
23
|
+
const declaredBytes = Number(contentLength);
|
|
24
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
|
|
25
|
+
return c.json({ error: "Request body too large" }, 413);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const reader = c.req.raw.body?.getReader();
|
|
30
|
+
if (!reader) return JSON.parse("") as T;
|
|
31
|
+
|
|
32
|
+
const decoder = new TextDecoder();
|
|
33
|
+
let bytesRead = 0;
|
|
34
|
+
let text = "";
|
|
35
|
+
while (true) {
|
|
36
|
+
const { done, value } = await reader.read();
|
|
37
|
+
if (done) break;
|
|
38
|
+
bytesRead += value.byteLength;
|
|
39
|
+
if (bytesRead > maxBytes) {
|
|
40
|
+
await reader.cancel().catch(() => undefined);
|
|
41
|
+
return c.json({ error: "Request body too large" }, 413);
|
|
42
|
+
}
|
|
43
|
+
text += decoder.decode(value, { stream: true });
|
|
44
|
+
}
|
|
45
|
+
text += decoder.decode();
|
|
46
|
+
return JSON.parse(text) as T;
|
|
47
|
+
}
|