@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.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
- package/dist/chunk-ZSF5XP3B.js.map +1 -0
- package/dist/index.d.ts +630 -27
- package/dist/index.js +5819 -853
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
- package/package.json +5 -5
- package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +59 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +69 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/autosre-change-correlation.ts +780 -0
- package/src/lib/autosre-findings.ts +725 -0
- package/src/lib/autosre-ontology.ts +914 -0
- package/src/lib/autosre-remediation-gate.ts +1663 -0
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/eval-cases-store.ts +63 -1
- package/src/lib/evidence-references.ts +6 -0
- package/src/lib/housekeeping.test.ts +153 -0
- package/src/lib/housekeeping.ts +193 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/store/ingest.ts +94 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +68 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +181 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/autosre-routes.ts +1019 -0
- package/src/plugins/connected-routes/manifest.ts +13 -1
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +285 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/default-span-enrichment.ts +13 -1
- package/src/plugins/eval-cases-routes.ts +65 -19
- package/src/plugins/evidence-retrieval-routes.ts +300 -4
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- package/dist/chunk-UGWDOGGH.js.map +0 -1
- package/dist/chunk-XKQTVIRE.js.map +0 -1
- /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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
const sessionValue = getCookie(cookieHeader, SESSION_COOKIE);
|
|
183
|
+
let authenticated = false;
|
|
142
184
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
if (
|
|
147
|
-
return c.
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
|
|
225
|
+
if (sessionValue) {
|
|
226
|
+
if (!configuredPassword) {
|
|
227
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
228
|
+
}
|
|
160
229
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
.
|
|
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
|
|
210
|
-
|
|
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,
|
|
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,
|
|
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.";
|