@schlessera/brain-ui-server 0.31.0 → 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.
Files changed (95) hide show
  1. package/README.md +46 -5
  2. package/dist/app.d.ts.map +1 -1
  3. package/dist/app.js +49 -25
  4. package/dist/app.js.map +1 -1
  5. package/dist/config/env.d.ts +6 -5
  6. package/dist/config/env.d.ts.map +1 -1
  7. package/dist/config/env.js +13 -7
  8. package/dist/config/env.js.map +1 -1
  9. package/dist/files/walker.d.ts.map +1 -1
  10. package/dist/files/walker.js +70 -10
  11. package/dist/files/walker.js.map +1 -1
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/middleware/auth.d.ts +25 -5
  17. package/dist/middleware/auth.d.ts.map +1 -1
  18. package/dist/middleware/auth.js +255 -47
  19. package/dist/middleware/auth.js.map +1 -1
  20. package/dist/middleware/body-limit.d.ts +15 -0
  21. package/dist/middleware/body-limit.d.ts.map +1 -0
  22. package/dist/middleware/body-limit.js +41 -0
  23. package/dist/middleware/body-limit.js.map +1 -0
  24. package/dist/middleware/origin.d.ts +20 -12
  25. package/dist/middleware/origin.d.ts.map +1 -1
  26. package/dist/middleware/origin.js +71 -25
  27. package/dist/middleware/origin.js.map +1 -1
  28. package/dist/middleware/passkeys.d.ts +5 -1
  29. package/dist/middleware/passkeys.d.ts.map +1 -1
  30. package/dist/middleware/passkeys.js +67 -26
  31. package/dist/middleware/passkeys.js.map +1 -1
  32. package/dist/routes/brain.d.ts.map +1 -1
  33. package/dist/routes/brain.js +7 -2
  34. package/dist/routes/brain.js.map +1 -1
  35. package/dist/routes/files.js +2 -2
  36. package/dist/routes/files.js.map +1 -1
  37. package/dist/routes/models.d.ts.map +1 -1
  38. package/dist/routes/models.js +22 -10
  39. package/dist/routes/models.js.map +1 -1
  40. package/dist/routes/pi-auth.d.ts.map +1 -1
  41. package/dist/routes/pi-auth.js +12 -4
  42. package/dist/routes/pi-auth.js.map +1 -1
  43. package/dist/routes/push.d.ts.map +1 -1
  44. package/dist/routes/push.js +12 -4
  45. package/dist/routes/push.js.map +1 -1
  46. package/dist/routes/render.d.ts +6 -0
  47. package/dist/routes/render.d.ts.map +1 -1
  48. package/dist/routes/render.js +8 -11
  49. package/dist/routes/render.js.map +1 -1
  50. package/dist/routes/share.d.ts +2 -0
  51. package/dist/routes/share.d.ts.map +1 -1
  52. package/dist/routes/share.js +30 -31
  53. package/dist/routes/share.js.map +1 -1
  54. package/dist/routes/skills.d.ts.map +1 -1
  55. package/dist/routes/skills.js +22 -8
  56. package/dist/routes/skills.js.map +1 -1
  57. package/dist/routes/web-search.d.ts.map +1 -1
  58. package/dist/routes/web-search.js +7 -2
  59. package/dist/routes/web-search.js.map +1 -1
  60. package/dist/share/staging.d.ts.map +1 -1
  61. package/dist/share/staging.js +2 -1
  62. package/dist/share/staging.js.map +1 -1
  63. package/dist/ws/clients.d.ts +13 -4
  64. package/dist/ws/clients.d.ts.map +1 -1
  65. package/dist/ws/clients.js +41 -5
  66. package/dist/ws/clients.js.map +1 -1
  67. package/dist/ws/connection.d.ts.map +1 -1
  68. package/dist/ws/connection.js +10 -2
  69. package/dist/ws/connection.js.map +1 -1
  70. package/dist/ws/host.d.ts +5 -0
  71. package/dist/ws/host.d.ts.map +1 -1
  72. package/dist/ws/host.js +18 -1
  73. package/dist/ws/host.js.map +1 -1
  74. package/package.json +3 -3
  75. package/src/app.ts +55 -23
  76. package/src/config/env.ts +18 -7
  77. package/src/files/walker.ts +66 -10
  78. package/src/index.ts +8 -0
  79. package/src/middleware/auth.ts +311 -48
  80. package/src/middleware/body-limit.ts +47 -0
  81. package/src/middleware/origin.ts +92 -24
  82. package/src/middleware/passkeys.ts +80 -29
  83. package/src/routes/brain.ts +7 -3
  84. package/src/routes/files.ts +2 -2
  85. package/src/routes/models.ts +17 -10
  86. package/src/routes/pi-auth.ts +10 -4
  87. package/src/routes/push.ts +10 -4
  88. package/src/routes/render.ts +14 -10
  89. package/src/routes/share.ts +35 -34
  90. package/src/routes/skills.ts +18 -8
  91. package/src/routes/web-search.ts +6 -2
  92. package/src/share/staging.ts +2 -1
  93. package/src/ws/clients.ts +45 -7
  94. package/src/ws/connection.ts +11 -2
  95. package/src/ws/host.ts +23 -1
@@ -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
- export const LOGIN_RATE_LIMIT = 5; // attempts per window, per client IP
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. It stays well above a human's needs and, combined with the argon2id
49
- // verify cost, makes online guessing infeasible without being a lockout an
50
- // attacker could weaponize to deny the owner access.
51
- export const GLOBAL_LOGIN_RATE_LIMIT = 20; // attempts per window, all IPs combined
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(mode: AuthMode, auth: AuthRuntime): MiddlewareHandler {
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(c: Context, auth: AuthRuntime): Promise<void> {
246
+ export async function issueSessionCookie(
247
+ c: Context,
248
+ auth: AuthRuntime,
249
+ db: Database
250
+ ): Promise<void> {
213
251
  const secret = auth.cookieSecret ?? "";
214
- await setSignedCookie(c, COOKIE_NAME, String(Date.now()), secret, {
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(c: Context, auth: AuthRuntime): Promise<boolean> {
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
- const value = await getSignedCookie(c, secret, COOKIE_NAME);
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 token bucket, per client IP) ---
344
+ // --- login rate limiter (in-memory failure windows + verification reservations) ---
257
345
 
258
346
  interface Bucket {
259
- tokens: number;
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
- const now = Date.now();
271
- let bucket = loginBuckets.get(key);
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
- const key = clientIp(c, auth.trustProxy, auth.trustProxyHops) || "unknown";
308
- const perIpOk = consumeLoginToken(`ip:${key}`, LOGIN_RATE_LIMIT);
309
- const globalOk = consumeLoginToken("global", GLOBAL_LOGIN_RATE_LIMIT);
310
- if (!perIpOk || !globalOk) {
311
- failures?.add(1, { reason: "rate_limited", method: "password" });
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: "login rate limited",
315
- attributes: { ip: key, limit: perIpOk ? "global" : "ip" },
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
- body = await c.req.json();
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
- failures?.add(1, { reason: "invalid_password", method: "password" });
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: "login failed",
595
+ body: "password verification capacity reached",
341
596
  attributes: { ip: key },
342
597
  });
343
- return c.json({ error: "Invalid credentials" }, 401);
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 Bun.password.verify(password, hash);
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
+ }