@obsunified/collector 2.0.1 → 2.2.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 (126) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
  6. package/dist/chunk-ZSF5XP3B.js.map +1 -0
  7. package/dist/index.d.ts +630 -27
  8. package/dist/index.js +5819 -853
  9. package/dist/index.js.map +1 -1
  10. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  11. package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
  12. package/package.json +5 -5
  13. package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
  14. package/src/analyses/derive.ts +26 -25
  15. package/src/analyses/investigations.ts +8 -8
  16. package/src/analyses/tier0.ts +22 -22
  17. package/src/auth/dashboard-auth.test.ts +791 -0
  18. package/src/auth/dashboard-auth.ts +187 -38
  19. package/src/auth/login-rate-limit.test.ts +129 -0
  20. package/src/auth/login-rate-limit.ts +143 -0
  21. package/src/framework/body-limit.test.ts +165 -0
  22. package/src/framework/collector.ts +355 -115
  23. package/src/framework/env.ts +9 -0
  24. package/src/framework/limits.ts +31 -0
  25. package/src/framework/scheduled-handlers.test.ts +342 -0
  26. package/src/index.ts +59 -1
  27. package/src/lib/action-retention.test.ts +472 -0
  28. package/src/lib/action-retention.ts +420 -0
  29. package/src/lib/actions-store.ts +6 -12
  30. package/src/lib/ai-store/calls.ts +2 -2
  31. package/src/lib/ai-store/sessions.ts +3 -3
  32. package/src/lib/ai-store/spans.ts +1 -1
  33. package/src/lib/ai-store/types.ts +2 -1
  34. package/src/lib/alerts-store.test.ts +153 -0
  35. package/src/lib/alerts-store.ts +122 -21
  36. package/src/lib/analyses-runner.test.ts +75 -0
  37. package/src/lib/analyses-runner.ts +69 -3
  38. package/src/lib/analyses-store-latest.test.ts +61 -0
  39. package/src/lib/analyses-store.ts +17 -10
  40. package/src/lib/autosre-change-correlation.ts +780 -0
  41. package/src/lib/autosre-findings.ts +725 -0
  42. package/src/lib/autosre-ontology.ts +914 -0
  43. package/src/lib/autosre-remediation-gate.ts +1663 -0
  44. package/src/lib/blob-store.ts +1 -1
  45. package/src/lib/eval-cases-store.ts +63 -1
  46. package/src/lib/evidence-references.ts +6 -0
  47. package/src/lib/housekeeping.test.ts +153 -0
  48. package/src/lib/housekeeping.ts +193 -0
  49. package/src/lib/identity-index/action-lookups.ts +6 -6
  50. package/src/lib/identity-index/key-lookups.ts +2 -2
  51. package/src/lib/identity-index/user-lookup.ts +8 -5
  52. package/src/lib/identity-index.test.ts +2 -3
  53. package/src/lib/metrics-store-series.test.ts +99 -0
  54. package/src/lib/metrics-store.test.ts +23 -0
  55. package/src/lib/metrics-store.ts +27 -3
  56. package/src/lib/projects-store.test.ts +25 -0
  57. package/src/lib/projects-store.ts +4 -3
  58. package/src/lib/sql-db-dialect.test.ts +12 -3
  59. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  60. package/src/lib/sql-db-postgres.test.ts +314 -0
  61. package/src/lib/sql-db-postgres.ts +392 -54
  62. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  63. package/src/lib/sql-db.ts +40 -6
  64. package/src/lib/store/ingest.ts +94 -6
  65. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  66. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  67. package/src/lib/webhook-url.test.ts +122 -0
  68. package/src/lib/webhook-url.ts +155 -0
  69. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  70. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  71. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  72. package/src/migrations/044_actions_started_at_index.sql +10 -0
  73. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  74. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  75. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  76. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  77. package/src/migrations/049_alert_state_notified.sql +17 -0
  78. package/src/migrations/050_autosre_causal_ontology.sql +216 -0
  79. package/src/migrations/alert-state-notified.test.ts +111 -0
  80. package/src/migrations/parity.test.ts +68 -0
  81. package/src/migrations/project-scoped-keys.test.ts +432 -0
  82. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  83. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  84. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  85. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  86. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  87. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  88. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  89. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  90. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  91. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  92. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  93. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  94. package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
  95. package/src/migrations-postgres/README.md +58 -5
  96. package/src/otlp/decode/body.test.ts +76 -0
  97. package/src/otlp/decode/body.ts +67 -6
  98. package/src/plugins/_json.ts +21 -0
  99. package/src/plugins/action-graph-processor.ts +6 -12
  100. package/src/plugins/ai-receiver.ts +5 -12
  101. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  102. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  103. package/src/plugins/alerts-evaluator.test.ts +464 -3
  104. package/src/plugins/alerts-evaluator.ts +181 -41
  105. package/src/plugins/alerts-routes.ts +10 -6
  106. package/src/plugins/ask-routes.ts +4 -5
  107. package/src/plugins/autosre-routes.ts +1019 -0
  108. package/src/plugins/connected-routes/manifest.ts +13 -1
  109. package/src/plugins/connected-routes.test.ts +106 -0
  110. package/src/plugins/connected-routes.ts +285 -5
  111. package/src/plugins/dashboard-routes.test.ts +120 -0
  112. package/src/plugins/dashboard-routes.ts +11 -2
  113. package/src/plugins/default-span-enrichment.ts +13 -1
  114. package/src/plugins/eval-cases-routes.ts +65 -19
  115. package/src/plugins/evidence-retrieval-routes.ts +300 -4
  116. package/src/plugins/identity-receiver.ts +9 -11
  117. package/src/plugins/json-body.test.ts +91 -0
  118. package/src/plugins/onboarding-routes.test.ts +105 -0
  119. package/src/plugins/onboarding-routes.ts +26 -11
  120. package/src/plugins/projects-routes.ts +2 -1
  121. package/src/plugins/replay-isolation.test.ts +922 -0
  122. package/src/plugins/replay-receiver.ts +8 -8
  123. package/src/plugins/usage-receiver.ts +3 -6
  124. package/dist/chunk-UGWDOGGH.js.map +0 -1
  125. package/dist/chunk-XKQTVIRE.js.map +0 -1
  126. /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
@@ -1,7 +1,13 @@
1
- import type { MiddlewareHandler } from "hono";
1
+ import type { Context, MiddlewareHandler } from "hono";
2
2
  import type { CollectorEnv } from "../framework/env";
3
3
  import { ProjectsStore } from "../lib/projects-store";
4
4
  import { type SqlDb, sqlDbFor } from "../lib/sql-db";
5
+ import {
6
+ LoginRateLimiter,
7
+ type LoginRateLimitOptions,
8
+ loginClientKey,
9
+ UNKEYED_LOGIN_CLIENT_WARNING,
10
+ } from "./login-rate-limit";
5
11
 
6
12
  const SESSION_COOKIE = "obs_session";
7
13
  const SESSION_MAX_AGE = 7 * 24 * 60 * 60; // 7 days in seconds
@@ -104,17 +110,47 @@ function getCookie(
104
110
  return match ? decodeURIComponent(match[1]) : undefined;
105
111
  }
106
112
 
113
+ export interface DashboardAuthConfig {
114
+ password?: string;
115
+ token?: string;
116
+ /**
117
+ * Failed-login limiter for POST /auth/login (see login-rate-limit.ts).
118
+ * Pass `false` to disable; defaults to 10 failures per 15 minutes.
119
+ * `clientAddress` lets the host supply the connection's peer address;
120
+ * `trustProxyHeaders` honours forwarding headers from a trusted proxy.
121
+ * Without either, Workers use the edge-set client IP and every other
122
+ * runtime has no trustworthy identity: limiting is then skipped (never a
123
+ * shared bucket, which would let one client lock everyone out) and a
124
+ * warning is logged once via `warn` (default `console.warn`).
125
+ */
126
+ loginRateLimit?:
127
+ | (LoginRateLimitOptions & {
128
+ clientAddress?: (
129
+ c: Context<{ Bindings: CollectorEnv }>,
130
+ ) => string | undefined;
131
+ trustProxyHeaders?: boolean;
132
+ /** Sink for the one-time "limiter inactive" warning. */
133
+ warn?: (message: string) => void;
134
+ })
135
+ | false;
136
+ }
137
+
107
138
  /**
108
- * Creates auth routes and middleware for dashboard password login.
139
+ * Creates auth routes and middleware for dashboard password login and
140
+ * programmatic read/admin bearer token authentication.
109
141
  *
110
142
  * Routes added:
111
143
  * POST /auth/login — accepts { password }, sets session cookie
112
- * GET /auth/check — returns { authenticated: true/false }
144
+ * GET /auth/check — returns { authenticated: true/false } (supports cookie and Bearer token)
113
145
  * POST /auth/logout — clears session cookie
114
146
  *
115
- * The returned middleware validates the session cookie on protected routes.
147
+ * The returned middleware validates either:
148
+ * 1. Authorization: Bearer <token> (programmatic read/admin token for MCP/API)
149
+ * 2. obs_session cookie (human dashboard user session)
150
+ *
151
+ * Ingest keys are rejected on dashboard/internal routes — they are write-only.
116
152
  */
117
- export function createDashboardAuth(config: { password: string }): {
153
+ export function createDashboardAuth(config?: DashboardAuthConfig): {
118
154
  middleware: MiddlewareHandler<{
119
155
  Bindings: CollectorEnv;
120
156
  Variables: { projectId: string };
@@ -127,45 +163,99 @@ export function createDashboardAuth(config: { password: string }): {
127
163
  Bindings: CollectorEnv;
128
164
  Variables: { projectId: string };
129
165
  }> = async (c, next) => {
130
- if (!config.password) {
166
+ const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
167
+ const configuredToken =
168
+ config?.token ??
169
+ c.env.DASHBOARD_TOKEN ??
170
+ (c.env as unknown as Record<string, string | undefined>)
171
+ .OBS_DASHBOARD_TOKEN;
172
+
173
+ if (!configuredPassword && !configuredToken) {
131
174
  return c.json(
132
175
  {
133
176
  error:
134
- "DASHBOARD_PASSWORD is not configured. Set the DASHBOARD_PASSWORD environment variable.",
177
+ "Neither DASHBOARD_PASSWORD nor DASHBOARD_TOKEN is configured. Set at least one environment variable.",
135
178
  },
136
179
  500,
137
180
  );
138
181
  }
139
182
 
140
- const cookieHeader = c.req.header("Cookie");
141
- const sessionValue = getCookie(cookieHeader, SESSION_COOKIE);
183
+ let authenticated = false;
142
184
 
143
- if (!sessionValue) {
144
- // For API requests, return 401. For page requests, redirect to login.
145
- const accept = c.req.header("Accept") || "";
146
- if (accept.includes("text/html")) {
147
- return c.redirect("/dashboard/login");
185
+ const authHeader = c.req.header("Authorization");
186
+ if (authHeader) {
187
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
188
+ if (!match) {
189
+ return c.json({ error: "Unauthorized" }, 401);
190
+ }
191
+ const bearerToken = match[1].trim();
192
+ if (!bearerToken) {
193
+ return c.json({ error: "Unauthorized" }, 401);
148
194
  }
149
- return c.json({ error: "Unauthorized" }, 401);
150
- }
151
195
 
152
- // Session cookie format: payload.signature
153
- const dotIndex = sessionValue.lastIndexOf(".");
154
- if (dotIndex === -1) {
155
- return c.json({ error: "Invalid session" }, 401);
156
- }
196
+ // Ingest keys must never grant read/admin access to dashboard/internal endpoints.
197
+ const ingestKey = c.env.INGEST_KEY;
198
+ if (ingestKey && (await timingSafeEqualStr(bearerToken, ingestKey))) {
199
+ return c.json(
200
+ {
201
+ error:
202
+ "Unauthorized: ingest keys cannot be used for dashboard/internal access",
203
+ },
204
+ 401,
205
+ );
206
+ }
207
+
208
+ // Fail closed if no programmatic read/admin token is configured.
209
+ if (!configuredToken) {
210
+ return c.json({ error: "Unauthorized" }, 401);
211
+ }
212
+
213
+ // Verify bearer token in constant time.
214
+ const valid = await timingSafeEqualStr(bearerToken, configuredToken);
215
+ if (!valid) {
216
+ return c.json({ error: "Unauthorized" }, 401);
217
+ }
218
+
219
+ authenticated = true;
220
+ } else {
221
+ // Check session cookie for browser dashboard users.
222
+ const cookieHeader = c.req.header("Cookie");
223
+ const sessionValue = getCookie(cookieHeader, SESSION_COOKIE);
157
224
 
158
- const payload = sessionValue.substring(0, dotIndex);
159
- const sig = sessionValue.substring(dotIndex + 1);
225
+ if (sessionValue) {
226
+ if (!configuredPassword) {
227
+ return c.json({ error: "Unauthorized" }, 401);
228
+ }
160
229
 
161
- const valid = await verify(payload, sig, config.password);
162
- if (!valid) {
163
- return c.json({ error: "Invalid session" }, 401);
230
+ const dotIndex = sessionValue.lastIndexOf(".");
231
+ if (dotIndex === -1) {
232
+ return c.json({ error: "Invalid session" }, 401);
233
+ }
234
+
235
+ const payload = sessionValue.substring(0, dotIndex);
236
+ const sig = sessionValue.substring(dotIndex + 1);
237
+
238
+ const valid = await verify(payload, sig, configuredPassword);
239
+ if (!valid) {
240
+ return c.json({ error: "Invalid session" }, 401);
241
+ }
242
+
243
+ const token = parseSessionToken(payload);
244
+ if (!token || token.exp < Math.floor(Date.now() / 1000)) {
245
+ return c.json({ error: "Session expired" }, 401);
246
+ }
247
+
248
+ authenticated = true;
249
+ }
164
250
  }
165
251
 
166
- const token = parseSessionToken(payload);
167
- if (!token || token.exp < Math.floor(Date.now() / 1000)) {
168
- return c.json({ error: "Session expired" }, 401);
252
+ if (!authenticated) {
253
+ // For API requests, return 401. For page requests, redirect to login.
254
+ const accept = c.req.header("Accept") || "";
255
+ if (accept.includes("text/html")) {
256
+ return c.redirect("/dashboard/login");
257
+ }
258
+ return c.json({ error: "Unauthorized" }, 401);
169
259
  }
170
260
 
171
261
  // Resolve project for data-scoped routes. /internal/projects/* is exempt
@@ -197,24 +287,59 @@ export function createDashboardAuth(config: { password: string }): {
197
287
  const registerRoutes = (
198
288
  app: import("hono").Hono<{ Bindings: CollectorEnv }>,
199
289
  ) => {
290
+ const limiter =
291
+ config?.loginRateLimit === false
292
+ ? null
293
+ : new LoginRateLimiter(config?.loginRateLimit ?? {});
294
+ let warnedUnkeyedClient = false;
295
+
200
296
  app.post("/auth/login", async (c) => {
201
- if (!config.password) {
297
+ const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
298
+ if (!configuredPassword) {
202
299
  return c.json({ error: "Dashboard password not configured" }, 500);
203
300
  }
204
301
 
205
- const body = await c.req
206
- .json<{ password?: string }>()
207
- .catch(() => ({ password: undefined }));
302
+ const limitConfig = config?.loginRateLimit || undefined;
303
+ const clientKey = limiter
304
+ ? loginClientKey(c.req.raw.headers, {
305
+ clientAddress: limitConfig?.clientAddress?.(c),
306
+ trustProxyHeaders: limitConfig?.trustProxyHeaders,
307
+ })
308
+ : null;
309
+ if (limiter && clientKey === null && !warnedUnkeyedClient) {
310
+ warnedUnkeyedClient = true;
311
+ (limitConfig?.warn ?? console.warn)(UNKEYED_LOGIN_CLIENT_WARNING);
312
+ }
313
+ const verdict = clientKey === null ? null : limiter?.check(clientKey);
314
+ if (verdict && !verdict.allowed) {
315
+ c.header("Retry-After", String(verdict.retryAfterSeconds));
316
+ return c.json({ error: "Too many failed login attempts" }, 429);
317
+ }
318
+
319
+ let body: { password?: string } | null = null;
320
+ try {
321
+ body = await c.req.json<{ password?: string }>();
322
+ } catch (err) {
323
+ // The framework's body cap (hono/body-limit) surfaces an oversized
324
+ // streamed body as an error from the read; let it propagate so the
325
+ // middleware answers 413 instead of this handler calling it a bad
326
+ // password. Anything else is malformed JSON → 401 below.
327
+ if (err instanceof Error && err.name === "BodyLimitError") throw err;
328
+ body = null;
329
+ }
208
330
  if (
209
- !body.password ||
210
- !(await timingSafeEqualStr(body.password, config.password))
331
+ !body?.password ||
332
+ typeof body.password !== "string" ||
333
+ !(await timingSafeEqualStr(body.password, configuredPassword))
211
334
  ) {
335
+ if (clientKey !== null) limiter?.recordFailure(clientKey);
212
336
  return c.json({ error: "Invalid password" }, 401);
213
337
  }
338
+ if (clientKey !== null) limiter?.reset(clientKey);
214
339
 
215
340
  const expiresAt = Math.floor(Date.now() / 1000) + SESSION_MAX_AGE;
216
341
  const payload = createSessionToken(expiresAt);
217
- const sig = await sign(payload, config.password);
342
+ const sig = await sign(payload, configuredPassword);
218
343
  const cookieValue = `${payload}.${sig}`;
219
344
 
220
345
  const isSecure = c.req.url.startsWith("https:");
@@ -227,10 +352,34 @@ export function createDashboardAuth(config: { password: string }): {
227
352
  });
228
353
 
229
354
  app.get("/auth/check", async (c) => {
355
+ const configuredPassword = config?.password ?? c.env.DASHBOARD_PASSWORD;
356
+ const configuredToken =
357
+ config?.token ??
358
+ c.env.DASHBOARD_TOKEN ??
359
+ (c.env as unknown as Record<string, string | undefined>)
360
+ .OBS_DASHBOARD_TOKEN;
361
+
362
+ const authHeader = c.req.header("Authorization");
363
+ if (authHeader) {
364
+ const match = authHeader.match(/^Bearer\s+(.+)$/i);
365
+ if (match && configuredToken) {
366
+ const bearerToken = match[1].trim();
367
+ const ingestKey = c.env.INGEST_KEY;
368
+ if (ingestKey && (await timingSafeEqualStr(bearerToken, ingestKey))) {
369
+ return c.json({ authenticated: false });
370
+ }
371
+ const valid = await timingSafeEqualStr(bearerToken, configuredToken);
372
+ if (valid) {
373
+ return c.json({ authenticated: true });
374
+ }
375
+ }
376
+ return c.json({ authenticated: false });
377
+ }
378
+
230
379
  const cookieHeader = c.req.header("Cookie");
231
380
  const sessionValue = getCookie(cookieHeader, SESSION_COOKIE);
232
381
 
233
- if (!sessionValue) {
382
+ if (!sessionValue || !configuredPassword) {
234
383
  return c.json({ authenticated: false });
235
384
  }
236
385
 
@@ -242,7 +391,7 @@ export function createDashboardAuth(config: { password: string }): {
242
391
  const payload = sessionValue.substring(0, dotIndex);
243
392
  const sig = sessionValue.substring(dotIndex + 1);
244
393
 
245
- const valid = await verify(payload, sig, config.password);
394
+ const valid = await verify(payload, sig, configuredPassword);
246
395
  if (!valid) {
247
396
  return c.json({ authenticated: false });
248
397
  }
@@ -0,0 +1,129 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { LoginRateLimiter, loginClientKey } from "./login-rate-limit";
3
+
4
+ describe("LoginRateLimiter", () => {
5
+ it("blocks after the failure budget and recovers when the window ends", () => {
6
+ let now = 1_000_000;
7
+ const limiter = new LoginRateLimiter({
8
+ maxFailures: 3,
9
+ windowMs: 60_000,
10
+ now: () => now,
11
+ });
12
+ for (let i = 0; i < 3; i += 1) {
13
+ expect(limiter.check("1.2.3.4").allowed).toBe(true);
14
+ limiter.recordFailure("1.2.3.4");
15
+ }
16
+ const blocked = limiter.check("1.2.3.4");
17
+ expect(blocked.allowed).toBe(false);
18
+ expect(blocked.retryAfterSeconds).toBe(60);
19
+ // Other clients are unaffected.
20
+ expect(limiter.check("5.6.7.8").allowed).toBe(true);
21
+ now += 60_000;
22
+ expect(limiter.check("1.2.3.4").allowed).toBe(true);
23
+ });
24
+
25
+ it("a successful login clears the client's failures", () => {
26
+ const limiter = new LoginRateLimiter({ maxFailures: 2 });
27
+ limiter.recordFailure("k");
28
+ limiter.reset("k");
29
+ limiter.recordFailure("k");
30
+ expect(limiter.check("k").allowed).toBe(true);
31
+ });
32
+
33
+ it("evicts the oldest client past the tracking cap", () => {
34
+ const limiter = new LoginRateLimiter({ maxFailures: 1, maxClients: 2 });
35
+ limiter.recordFailure("a");
36
+ limiter.recordFailure("b");
37
+ limiter.recordFailure("c");
38
+ expect(limiter.check("a").allowed).toBe(true);
39
+ expect(limiter.check("c").allowed).toBe(false);
40
+ });
41
+
42
+ describe("loginClientKey", () => {
43
+ const headers = (init: Record<string, string>) => new Headers(init);
44
+ const spoofed = headers({
45
+ "cf-connecting-ip": "9.9.9.9",
46
+ "x-forwarded-for": "8.8.8.8, 10.0.0.1",
47
+ });
48
+
49
+ afterEach(() => {
50
+ vi.unstubAllGlobals();
51
+ });
52
+
53
+ it("keys on the connection peer and ignores spoofed headers by default", () => {
54
+ expect(loginClientKey(spoofed, { clientAddress: "203.0.113.7" })).toBe(
55
+ "203.0.113.7",
56
+ );
57
+ expect(
58
+ loginClientKey(spoofed, {
59
+ clientAddress: "203.0.113.7",
60
+ trustProxyHeaders: false,
61
+ }),
62
+ ).toBe("203.0.113.7");
63
+ });
64
+
65
+ it("prefers the trusted forwarded client over the proxy peer", () => {
66
+ // Behind a trusted proxy the peer is the proxy itself; the
67
+ // forwarded address is the real client.
68
+ expect(
69
+ loginClientKey(spoofed, {
70
+ clientAddress: "10.0.0.1",
71
+ trustProxyHeaders: true,
72
+ }),
73
+ ).toBe("9.9.9.9");
74
+ expect(
75
+ loginClientKey(headers({ "x-forwarded-for": "8.8.8.8, 10.0.0.1" }), {
76
+ clientAddress: "10.0.0.1",
77
+ trustProxyHeaders: true,
78
+ }),
79
+ ).toBe("8.8.8.8");
80
+ // Whitespace-only / empty header values do not count as forwarded.
81
+ expect(
82
+ loginClientKey(headers({ "x-forwarded-for": " " }), {
83
+ clientAddress: "10.0.0.1",
84
+ trustProxyHeaders: true,
85
+ }),
86
+ ).toBe("10.0.0.1");
87
+ });
88
+
89
+ it("falls back to the peer when a trusted proxy sent no headers", () => {
90
+ expect(
91
+ loginClientKey(headers({}), {
92
+ clientAddress: "10.0.0.1",
93
+ trustProxyHeaders: true,
94
+ }),
95
+ ).toBe("10.0.0.1");
96
+ expect(
97
+ loginClientKey(headers({}), { trustProxyHeaders: true }),
98
+ ).toBeNull();
99
+ });
100
+
101
+ it("ignores forwarding headers unless a trusted proxy is declared", () => {
102
+ // Direct client on Node: headers are attacker-chosen and there is no
103
+ // peer address → no key (never a shared bucket).
104
+ expect(loginClientKey(spoofed)).toBeNull();
105
+ expect(loginClientKey(headers({}))).toBeNull();
106
+ expect(loginClientKey(spoofed, { trustProxyHeaders: true })).toBe(
107
+ "9.9.9.9",
108
+ );
109
+ expect(
110
+ loginClientKey(headers({ "x-forwarded-for": "8.8.8.8, 10.0.0.1" }), {
111
+ trustProxyHeaders: true,
112
+ }),
113
+ ).toBe("8.8.8.8");
114
+ });
115
+
116
+ it("trusts CF-Connecting-IP only on the Cloudflare Workers runtime", () => {
117
+ vi.stubGlobal("navigator", { userAgent: "Cloudflare-Workers" });
118
+ expect(loginClientKey(spoofed)).toBe("9.9.9.9");
119
+ expect(
120
+ loginClientKey(headers({ "x-forwarded-for": "8.8.8.8" })),
121
+ ).toBeNull();
122
+ // A host-resolved peer still wins over the edge header on Workers
123
+ // unless the embedder opts into trusting proxy headers.
124
+ expect(loginClientKey(spoofed, { clientAddress: "203.0.113.7" })).toBe(
125
+ "203.0.113.7",
126
+ );
127
+ });
128
+ });
129
+ });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Fixed-window failure limiter for `POST /auth/login`. Only failed attempts
3
+ * count; a successful login clears the client's bucket. State is in-memory,
4
+ * so on Cloudflare Workers it is per isolate and on Node per process — it
5
+ * slows a single-origin brute force without any shared storage, and it can
6
+ * never lock an operator out for longer than one window.
7
+ */
8
+
9
+ export interface LoginRateLimitOptions {
10
+ /** Failed attempts allowed per window before 429. Default 10. */
11
+ maxFailures?: number;
12
+ /** Window length in milliseconds. Default 15 minutes. */
13
+ windowMs?: number;
14
+ /** Upper bound on tracked clients; the oldest bucket is evicted past it. */
15
+ maxClients?: number;
16
+ /** Clock override for tests. */
17
+ now?: () => number;
18
+ }
19
+
20
+ export interface LoginRateLimitVerdict {
21
+ allowed: boolean;
22
+ retryAfterSeconds: number;
23
+ }
24
+
25
+ interface Bucket {
26
+ failures: number;
27
+ windowStartedAt: number;
28
+ }
29
+
30
+ export class LoginRateLimiter {
31
+ private readonly buckets = new Map<string, Bucket>();
32
+ private readonly maxFailures: number;
33
+ private readonly windowMs: number;
34
+ private readonly maxClients: number;
35
+ private readonly now: () => number;
36
+
37
+ constructor(options: LoginRateLimitOptions = {}) {
38
+ this.maxFailures = Math.max(1, options.maxFailures ?? 10);
39
+ this.windowMs = Math.max(1_000, options.windowMs ?? 15 * 60_000);
40
+ this.maxClients = Math.max(1, options.maxClients ?? 10_000);
41
+ this.now = options.now ?? Date.now;
42
+ }
43
+
44
+ check(key: string): LoginRateLimitVerdict {
45
+ const bucket = this.live(key);
46
+ if (!bucket || bucket.failures < this.maxFailures) {
47
+ return { allowed: true, retryAfterSeconds: 0 };
48
+ }
49
+ const remaining = bucket.windowStartedAt + this.windowMs - this.now();
50
+ return {
51
+ allowed: false,
52
+ retryAfterSeconds: Math.max(1, Math.ceil(remaining / 1000)),
53
+ };
54
+ }
55
+
56
+ recordFailure(key: string): void {
57
+ const bucket = this.live(key);
58
+ if (bucket) {
59
+ bucket.failures += 1;
60
+ return;
61
+ }
62
+ if (this.buckets.size >= this.maxClients) {
63
+ const oldest = this.buckets.keys().next().value;
64
+ if (oldest !== undefined) this.buckets.delete(oldest);
65
+ }
66
+ this.buckets.set(key, { failures: 1, windowStartedAt: this.now() });
67
+ }
68
+
69
+ reset(key: string): void {
70
+ this.buckets.delete(key);
71
+ }
72
+
73
+ /** Bucket for `key` if its window is still open; expired buckets are dropped. */
74
+ private live(key: string): Bucket | undefined {
75
+ const bucket = this.buckets.get(key);
76
+ if (!bucket) return undefined;
77
+ if (bucket.windowStartedAt + this.windowMs <= this.now()) {
78
+ this.buckets.delete(key);
79
+ return undefined;
80
+ }
81
+ return bucket;
82
+ }
83
+ }
84
+
85
+ export interface LoginClientKeyOptions {
86
+ /** Transport-level peer address (e.g. the TCP remote address on Node). */
87
+ clientAddress?: string;
88
+ /**
89
+ * Honour `CF-Connecting-IP` / `X-Forwarded-For` ahead of `clientAddress`.
90
+ * Only set this when a trusted reverse proxy terminates every connection
91
+ * and rewrites those headers: behind such a proxy the transport peer is
92
+ * the proxy itself, so the forwarded address is the real client. Left
93
+ * off, those headers are ignored entirely and a direct client cannot pick
94
+ * its own bucket.
95
+ */
96
+ trustProxyHeaders?: boolean;
97
+ }
98
+
99
+ /** True under workerd, where `CF-Connecting-IP` is set by the Cloudflare edge. */
100
+ export const isCloudflareWorkersRuntime = (): boolean =>
101
+ typeof navigator !== "undefined" &&
102
+ typeof navigator.userAgent === "string" &&
103
+ navigator.userAgent.startsWith("Cloudflare-Workers");
104
+
105
+ /**
106
+ * Client identity for limiting, most specific trustworthy source first:
107
+ * 1. proxy headers, only when the host says a trusted proxy sets them
108
+ * (`CF-Connecting-IP`, then the first `X-Forwarded-For` hop);
109
+ * 2. the peer address the host resolved from the connection — the default
110
+ * on Node, and the fallback when a trusted proxy sent no header;
111
+ * 3. `CF-Connecting-IP` when running on Cloudflare Workers (edge-set).
112
+ *
113
+ * With `trustProxyHeaders` unset, forwarding headers never influence the key,
114
+ * so a direct client cannot escape its bucket by forging them.
115
+ *
116
+ * Returns `null` when none of these apply. There is deliberately no shared
117
+ * fallback bucket: one bucket for every client would let an anonymous
118
+ * attacker lock every operator out of the dashboard with a handful of wrong
119
+ * passwords, which is a worse failure than an unthrottled login endpoint
120
+ * guarded by a strong password. Callers skip limiting on `null` and must
121
+ * warn so the embedder supplies `clientAddress` or `trustProxyHeaders`.
122
+ */
123
+ export const loginClientKey = (
124
+ headers: { get(name: string): string | null },
125
+ options: LoginClientKeyOptions = {},
126
+ ): string | null => {
127
+ const cf = headers.get("cf-connecting-ip")?.trim();
128
+ if (options.trustProxyHeaders) {
129
+ if (cf) return cf;
130
+ const forwarded = headers.get("x-forwarded-for")?.split(",")[0]?.trim();
131
+ if (forwarded) return forwarded;
132
+ }
133
+ const peer = options.clientAddress?.trim();
134
+ if (peer) return peer;
135
+ if (cf && isCloudflareWorkersRuntime()) return cf;
136
+ return null;
137
+ };
138
+
139
+ export const UNKEYED_LOGIN_CLIENT_WARNING =
140
+ "[dashboard-auth] login rate limiting is inactive: no trustworthy client address. " +
141
+ "On Node, pass `loginRateLimit.clientAddress` (e.g. the TCP peer from @hono/node-server's getConnInfo); " +
142
+ "behind a trusted reverse proxy set `loginRateLimit.trustProxyHeaders`. " +
143
+ "Cloudflare Workers key on CF-Connecting-IP automatically.";