@thotischner/observability-mcp 3.0.1 → 3.1.1
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/analysis/history.d.ts +36 -2
- package/dist/analysis/history.js +60 -2
- package/dist/analysis/history.test.js +46 -0
- package/dist/auth/csrf.d.ts +6 -0
- package/dist/auth/csrf.js +4 -0
- package/dist/auth/csrf.test.js +22 -0
- package/dist/auth/lockout.d.ts +72 -0
- package/dist/auth/lockout.js +134 -0
- package/dist/auth/lockout.test.d.ts +1 -0
- package/dist/auth/lockout.test.js +133 -0
- package/dist/auth/middleware.d.ts +5 -0
- package/dist/auth/middleware.js +6 -1
- package/dist/auth/middleware.test.js +31 -0
- package/dist/auth/password-policy.d.ts +52 -0
- package/dist/auth/password-policy.js +125 -0
- package/dist/auth/password-policy.test.d.ts +1 -0
- package/dist/auth/password-policy.test.js +111 -0
- package/dist/auth/revocation.d.ts +93 -0
- package/dist/auth/revocation.js +193 -0
- package/dist/auth/revocation.test.d.ts +1 -0
- package/dist/auth/revocation.test.js +136 -0
- package/dist/auth/session.d.ts +7 -0
- package/dist/auth/session.js +6 -0
- package/dist/auth/session.test.js +21 -0
- package/dist/conformance/mcp-2025-11-25.test.js +14 -0
- package/dist/connectors/interface.d.ts +5 -1
- package/dist/connectors/loki.d.ts +45 -1
- package/dist/connectors/loki.js +141 -8
- package/dist/connectors/loki.test.js +171 -1
- package/dist/index.js +244 -4
- package/dist/openapi.js +39 -0
- package/dist/openapi.test.js +1 -0
- package/dist/security/csp.d.ts +64 -0
- package/dist/security/csp.js +135 -0
- package/dist/security/csp.test.d.ts +1 -0
- package/dist/security/csp.test.js +97 -0
- package/dist/tools/query-logs-schema.test.d.ts +1 -0
- package/dist/tools/query-logs-schema.test.js +38 -0
- package/dist/tools/query-logs.d.ts +40 -0
- package/dist/tools/query-logs.js +69 -3
- package/dist/tools/validation.d.ts +13 -0
- package/dist/tools/validation.js +74 -0
- package/dist/tools/validation.test.js +54 -1
- package/dist/types.d.ts +48 -0
- package/dist/ui/index.html +42 -15
- package/package.json +1 -1
|
@@ -27,6 +27,14 @@ export interface AnomalyHistoryConfig {
|
|
|
27
27
|
requestTimeoutMs?: number;
|
|
28
28
|
/** Inject fetch for tests. */
|
|
29
29
|
fetchImpl?: typeof fetch;
|
|
30
|
+
/** In-process ring retention window (ms). Default 3 600 000 (1h).
|
|
31
|
+
* Powers the Health-tab sparkline; independent of remote-write. */
|
|
32
|
+
retentionMs?: number;
|
|
33
|
+
/** Hard cap on ring entries (defends memory under a score storm).
|
|
34
|
+
* Default 5 000. */
|
|
35
|
+
ringMax?: number;
|
|
36
|
+
/** Clock injection for tests. Returns epoch ms. */
|
|
37
|
+
now?: () => number;
|
|
30
38
|
}
|
|
31
39
|
export declare function fromEnv(env?: NodeJS.ProcessEnv): AnomalyHistoryConfig;
|
|
32
40
|
/**
|
|
@@ -42,7 +50,14 @@ export declare class AnomalyHistory {
|
|
|
42
50
|
private readonly maxBufferSize;
|
|
43
51
|
private readonly requestTimeoutMs;
|
|
44
52
|
private readonly fetchImpl;
|
|
53
|
+
private readonly retentionMs;
|
|
54
|
+
private readonly ringMax;
|
|
55
|
+
private readonly nowFn;
|
|
45
56
|
private buffer;
|
|
57
|
+
/** Bounded, time-windowed in-process tier of the sink — powers the
|
|
58
|
+
* Health-tab sparkline. Captured on every record() regardless of
|
|
59
|
+
* remote-write so the sparkline works out of the box. */
|
|
60
|
+
private ring;
|
|
46
61
|
private timer?;
|
|
47
62
|
private flushing;
|
|
48
63
|
constructor(cfg?: AnomalyHistoryConfig);
|
|
@@ -52,9 +67,28 @@ export declare class AnomalyHistory {
|
|
|
52
67
|
start(): void;
|
|
53
68
|
/** Stop the flush timer + flush one last time. */
|
|
54
69
|
stop(): Promise<void>;
|
|
55
|
-
/** Add one anomaly
|
|
56
|
-
*
|
|
70
|
+
/** Add one anomaly. Always captured into the in-process ring (powers
|
|
71
|
+
* the sparkline); additionally buffered for remote-write only when the
|
|
72
|
+
* sink is enabled. Triggers a synchronous flush past maxBufferSize. */
|
|
57
73
|
record(entry: AnomalyRecord): Promise<void>;
|
|
74
|
+
/** Append to the ring + evict anything outside the retention window or
|
|
75
|
+
* beyond the hard cap. O(n) prune is fine — anomaly records are
|
|
76
|
+
* infrequent and n is bounded by ringMax. */
|
|
77
|
+
private pushRing;
|
|
78
|
+
/**
|
|
79
|
+
* Recent records from the in-process ring, oldest-first. Filters by
|
|
80
|
+
* service and/or tenant when supplied and drops anything older than the
|
|
81
|
+
* retention window. Returns a fresh array (safe for the caller to map).
|
|
82
|
+
*/
|
|
83
|
+
recent(opts?: {
|
|
84
|
+
service?: string;
|
|
85
|
+
tenant?: string;
|
|
86
|
+
}): AnomalyRecord[];
|
|
87
|
+
/** Distinct services with at least one record in the retention window,
|
|
88
|
+
* optionally tenant-scoped. */
|
|
89
|
+
recentServices(tenant?: string): string[];
|
|
90
|
+
/** Retention window in ms — surfaced so the API/UI can label the range. */
|
|
91
|
+
get windowMs(): number;
|
|
58
92
|
/** Send the current buffer to the remote-write endpoint. Drops the
|
|
59
93
|
* buffer on success OR failure — history is best-effort. */
|
|
60
94
|
flush(): Promise<void>;
|
package/dist/analysis/history.js
CHANGED
|
@@ -45,7 +45,14 @@ export class AnomalyHistory {
|
|
|
45
45
|
maxBufferSize;
|
|
46
46
|
requestTimeoutMs;
|
|
47
47
|
fetchImpl;
|
|
48
|
+
retentionMs;
|
|
49
|
+
ringMax;
|
|
50
|
+
nowFn;
|
|
48
51
|
buffer = [];
|
|
52
|
+
/** Bounded, time-windowed in-process tier of the sink — powers the
|
|
53
|
+
* Health-tab sparkline. Captured on every record() regardless of
|
|
54
|
+
* remote-write so the sparkline works out of the box. */
|
|
55
|
+
ring = [];
|
|
49
56
|
timer;
|
|
50
57
|
flushing = false;
|
|
51
58
|
constructor(cfg = {}) {
|
|
@@ -56,6 +63,9 @@ export class AnomalyHistory {
|
|
|
56
63
|
this.maxBufferSize = cfg.maxBufferSize ?? 500;
|
|
57
64
|
this.requestTimeoutMs = cfg.requestTimeoutMs ?? 5_000;
|
|
58
65
|
this.fetchImpl = cfg.fetchImpl ?? fetch;
|
|
66
|
+
this.retentionMs = cfg.retentionMs ?? 60 * 60 * 1000;
|
|
67
|
+
this.ringMax = cfg.ringMax ?? 5_000;
|
|
68
|
+
this.nowFn = cfg.now ?? Date.now;
|
|
59
69
|
}
|
|
60
70
|
/** Whether the history sink is enabled (URL configured). */
|
|
61
71
|
isEnabled() {
|
|
@@ -84,9 +94,11 @@ export class AnomalyHistory {
|
|
|
84
94
|
if (this.isEnabled())
|
|
85
95
|
await this.flush().catch(() => undefined);
|
|
86
96
|
}
|
|
87
|
-
/** Add one anomaly
|
|
88
|
-
*
|
|
97
|
+
/** Add one anomaly. Always captured into the in-process ring (powers
|
|
98
|
+
* the sparkline); additionally buffered for remote-write only when the
|
|
99
|
+
* sink is enabled. Triggers a synchronous flush past maxBufferSize. */
|
|
89
100
|
async record(entry) {
|
|
101
|
+
this.pushRing(entry);
|
|
90
102
|
if (!this.isEnabled())
|
|
91
103
|
return;
|
|
92
104
|
this.buffer.push(entry);
|
|
@@ -94,6 +106,52 @@ export class AnomalyHistory {
|
|
|
94
106
|
await this.flush().catch(() => undefined);
|
|
95
107
|
}
|
|
96
108
|
}
|
|
109
|
+
/** Append to the ring + evict anything outside the retention window or
|
|
110
|
+
* beyond the hard cap. O(n) prune is fine — anomaly records are
|
|
111
|
+
* infrequent and n is bounded by ringMax. */
|
|
112
|
+
pushRing(entry) {
|
|
113
|
+
this.ring.push(entry);
|
|
114
|
+
const cutoff = this.nowFn() - this.retentionMs;
|
|
115
|
+
this.ring = this.ring.filter((r) => {
|
|
116
|
+
const t = Date.parse(r.ts);
|
|
117
|
+
return Number.isFinite(t) && t >= cutoff;
|
|
118
|
+
});
|
|
119
|
+
if (this.ring.length > this.ringMax) {
|
|
120
|
+
this.ring = this.ring.slice(this.ring.length - this.ringMax);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Recent records from the in-process ring, oldest-first. Filters by
|
|
125
|
+
* service and/or tenant when supplied and drops anything older than the
|
|
126
|
+
* retention window. Returns a fresh array (safe for the caller to map).
|
|
127
|
+
*/
|
|
128
|
+
recent(opts = {}) {
|
|
129
|
+
const cutoff = this.nowFn() - this.retentionMs;
|
|
130
|
+
return this.ring
|
|
131
|
+
.filter((r) => {
|
|
132
|
+
const t = Date.parse(r.ts);
|
|
133
|
+
if (!Number.isFinite(t) || t < cutoff)
|
|
134
|
+
return false;
|
|
135
|
+
if (opts.service && r.service !== opts.service)
|
|
136
|
+
return false;
|
|
137
|
+
if (opts.tenant && r.tenant !== opts.tenant)
|
|
138
|
+
return false;
|
|
139
|
+
return true;
|
|
140
|
+
})
|
|
141
|
+
.sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
|
|
142
|
+
}
|
|
143
|
+
/** Distinct services with at least one record in the retention window,
|
|
144
|
+
* optionally tenant-scoped. */
|
|
145
|
+
recentServices(tenant) {
|
|
146
|
+
const seen = new Set();
|
|
147
|
+
for (const r of this.recent({ tenant }))
|
|
148
|
+
seen.add(r.service);
|
|
149
|
+
return [...seen];
|
|
150
|
+
}
|
|
151
|
+
/** Retention window in ms — surfaced so the API/UI can label the range. */
|
|
152
|
+
get windowMs() {
|
|
153
|
+
return this.retentionMs;
|
|
154
|
+
}
|
|
97
155
|
/** Send the current buffer to the remote-write endpoint. Drops the
|
|
98
156
|
* buffer on success OR failure — history is best-effort. */
|
|
99
157
|
async flush() {
|
|
@@ -71,6 +71,52 @@ test("flush: bearer token forwarded as Authorization header", async () => {
|
|
|
71
71
|
await h.flush();
|
|
72
72
|
assert.equal(captured.headers["authorization"], "Bearer tok-abc");
|
|
73
73
|
});
|
|
74
|
+
// --- Q21: in-process ring (Health-tab sparkline) ----------------------
|
|
75
|
+
const NOW = Date.parse("2026-06-06T12:00:00.000Z");
|
|
76
|
+
function at(msAgo) {
|
|
77
|
+
return new Date(NOW - msAgo).toISOString();
|
|
78
|
+
}
|
|
79
|
+
test("ring captures records even when remote-write is disabled", async () => {
|
|
80
|
+
const h = new AnomalyHistory({ now: () => NOW });
|
|
81
|
+
assert.equal(h.isEnabled(), false);
|
|
82
|
+
await h.record(entry({ ts: at(0), score: 0.5 }));
|
|
83
|
+
const recent = h.recent();
|
|
84
|
+
assert.equal(recent.length, 1);
|
|
85
|
+
assert.equal(recent[0].score, 0.5);
|
|
86
|
+
// ...but the remote-write buffer stays empty when disabled.
|
|
87
|
+
assert.equal(h.bufferSize(), 0);
|
|
88
|
+
});
|
|
89
|
+
test("recent: drops records outside the retention window", async () => {
|
|
90
|
+
const h = new AnomalyHistory({ now: () => NOW, retentionMs: 60 * 60 * 1000 });
|
|
91
|
+
await h.record(entry({ ts: at(30 * 60 * 1000) })); // 30m ago — kept
|
|
92
|
+
await h.record(entry({ ts: at(90 * 60 * 1000) })); // 90m ago — evicted on next push/prune
|
|
93
|
+
const recent = h.recent();
|
|
94
|
+
assert.equal(recent.length, 1);
|
|
95
|
+
assert.equal(recent[0].ts, at(30 * 60 * 1000));
|
|
96
|
+
});
|
|
97
|
+
test("recent: oldest-first and filterable by service + tenant", async () => {
|
|
98
|
+
const h = new AnomalyHistory({ now: () => NOW });
|
|
99
|
+
await h.record(entry({ ts: at(3000), service: "payment", tenant: "a", score: 0.1 }));
|
|
100
|
+
await h.record(entry({ ts: at(1000), service: "payment", tenant: "a", score: 0.3 }));
|
|
101
|
+
await h.record(entry({ ts: at(2000), service: "orders", tenant: "a", score: 0.2 }));
|
|
102
|
+
await h.record(entry({ ts: at(500), service: "payment", tenant: "b", score: 0.9 }));
|
|
103
|
+
const pay = h.recent({ service: "payment", tenant: "a" });
|
|
104
|
+
assert.deepEqual(pay.map((r) => r.score), [0.1, 0.3], "oldest-first, service+tenant filtered");
|
|
105
|
+
const tenantA = h.recentServices("a").sort();
|
|
106
|
+
assert.deepEqual(tenantA, ["orders", "payment"]);
|
|
107
|
+
assert.deepEqual(h.recentServices("b"), ["payment"]);
|
|
108
|
+
});
|
|
109
|
+
test("ring honours the hard cap (ringMax)", async () => {
|
|
110
|
+
const h = new AnomalyHistory({ now: () => NOW, ringMax: 3 });
|
|
111
|
+
for (let i = 0; i < 10; i++)
|
|
112
|
+
await h.record(entry({ ts: at(10_000 - i), score: i / 10 }));
|
|
113
|
+
const recent = h.recent();
|
|
114
|
+
assert.equal(recent.length, 3, "ring capped at ringMax");
|
|
115
|
+
});
|
|
116
|
+
test("windowMs surfaces the retention window", () => {
|
|
117
|
+
assert.equal(new AnomalyHistory({ retentionMs: 1234 }).windowMs, 1234);
|
|
118
|
+
assert.equal(new AnomalyHistory({}).windowMs, 60 * 60 * 1000);
|
|
119
|
+
});
|
|
74
120
|
test("flush: clears the buffer on success", async () => {
|
|
75
121
|
const h = new AnomalyHistory({
|
|
76
122
|
url: "https://tsdb/api/v1/write",
|
package/dist/auth/csrf.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface CsrfConfig {
|
|
|
13
13
|
/** Set cookies with `Secure` flag. Default mirrors the existing
|
|
14
14
|
* session-cookie behaviour: only when the request is on https. */
|
|
15
15
|
secureCookie: (req: Request) => boolean;
|
|
16
|
+
/** Optional predicate to exempt specific requests from CSRF entirely.
|
|
17
|
+
* Used for unauthenticated browser-initiated POSTs that can't carry a
|
|
18
|
+
* token by construction — e.g. CSP violation reports, which the browser
|
|
19
|
+
* sends with no credentials and no custom headers. Keep this list
|
|
20
|
+
* minimal: an exempt endpoint must be safe to accept cross-site. */
|
|
21
|
+
skip?: (req: Request) => boolean;
|
|
16
22
|
}
|
|
17
23
|
export declare function csrfBypassFromEnv(env?: NodeJS.ProcessEnv): boolean;
|
|
18
24
|
/** Issue a fresh token cookie if the request doesn't already carry a
|
package/dist/auth/csrf.js
CHANGED
|
@@ -69,6 +69,10 @@ export function buildCsrfEnforcer(cfg) {
|
|
|
69
69
|
next();
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
|
+
if (cfg.skip?.(req)) {
|
|
73
|
+
next();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
72
76
|
if (cfg.bypassBearer) {
|
|
73
77
|
const auth = req.headers["authorization"];
|
|
74
78
|
if (typeof auth === "string" && /^Bearer\s+/i.test(auth)) {
|
package/dist/auth/csrf.test.js
CHANGED
|
@@ -89,6 +89,28 @@ test("enforcer: bearer auth bypasses CSRF when bypassBearer=true", () => {
|
|
|
89
89
|
});
|
|
90
90
|
assert.equal(r.nexted, true);
|
|
91
91
|
});
|
|
92
|
+
test("enforcer: skip predicate exempts a matching request (no token needed)", () => {
|
|
93
|
+
// Mirror the production predicate, which checks BOTH req.path and
|
|
94
|
+
// req.originalUrl — under `app.use("/api", ...)` Express strips the
|
|
95
|
+
// mount prefix from req.path (→ "/csp-violations"), so originalUrl is
|
|
96
|
+
// what actually matches at runtime.
|
|
97
|
+
const skip = (r) => r.method === "POST" &&
|
|
98
|
+
(r.path === "/api/csp-violations" || (r.originalUrl || "").split("?")[0] === "/api/csp-violations");
|
|
99
|
+
const mw = buildCsrfEnforcer({ bypassBearer: false, secureCookie: () => false, skip });
|
|
100
|
+
// Mounted shape: path stripped to "/csp-violations", originalUrl intact.
|
|
101
|
+
const mounted = call(mw, { method: "POST", path: "/csp-violations", originalUrl: "/api/csp-violations", headers: {} });
|
|
102
|
+
assert.equal(mounted.nexted, true, "originalUrl match must exempt under the /api mount");
|
|
103
|
+
// Query string can't widen the match.
|
|
104
|
+
const withQuery = call(mw, { method: "POST", path: "/csp-violations", originalUrl: "/api/csp-violations?x=1", headers: {} });
|
|
105
|
+
assert.equal(withQuery.nexted, true);
|
|
106
|
+
// A different path is still enforced (rejected without a token).
|
|
107
|
+
const other = call(mw, { method: "POST", path: "/settings", originalUrl: "/api/settings", headers: {} });
|
|
108
|
+
assert.equal(other.nexted, false);
|
|
109
|
+
assert.equal(other.res.status_, 403);
|
|
110
|
+
// GET is exempt anyway (safe method) regardless of skip.
|
|
111
|
+
const get = call(mw, { method: "GET", path: "/settings", originalUrl: "/api/settings", headers: {} });
|
|
112
|
+
assert.equal(get.nexted, true);
|
|
113
|
+
});
|
|
92
114
|
test("enforcer: X-API-Key also bypasses when bypassBearer=true", () => {
|
|
93
115
|
const mw = buildCsrfEnforcer(defaultCfg({ bypassBearer: true }));
|
|
94
116
|
const r = call(mw, {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-account lockout for the management-plane "basic" auth login.
|
|
3
|
+
*
|
|
4
|
+
* The per-IP rate limiter on /api/auth/login (20/min) blunts a noisy
|
|
5
|
+
* brute-force, but it's keyed on the source IP — a distributed attempt, or
|
|
6
|
+
* a slow drip under the IP cap, can still grind a single account's
|
|
7
|
+
* password. This adds a per-username failed-login counter with progressive
|
|
8
|
+
* backoff: after N failures inside a sliding window the account is
|
|
9
|
+
* temporarily locked, and each subsequent lock lasts longer
|
|
10
|
+
* (base · 2^(level-1), capped). A successful login clears the streak.
|
|
11
|
+
*
|
|
12
|
+
* State lives in the shared {@link SessionStore} so a Redis-backed
|
|
13
|
+
* deployment locks consistently across replicas (and self-cleans via TTL).
|
|
14
|
+
* The in-memory default keeps the single-process behaviour with no new dep.
|
|
15
|
+
*
|
|
16
|
+
* Lockout is tracked by the *submitted* username, whether or not it exists,
|
|
17
|
+
* so it can't be used as a user-enumeration oracle and a known username
|
|
18
|
+
* can't be singled out. Read-modify-write is best-effort under concurrency
|
|
19
|
+
* (matching the store's eventually-consistent contract) — the worst case is
|
|
20
|
+
* a couple of extra attempts slipping past the threshold, which the IP rate
|
|
21
|
+
* limiter still bounds. It is never a lockout bypass for the *locked* state:
|
|
22
|
+
* once `lockedUntil` is written, every replica that reads it honours it.
|
|
23
|
+
*/
|
|
24
|
+
import type { SessionStore } from "../transport/sessionStore.js";
|
|
25
|
+
export interface LockoutConfig {
|
|
26
|
+
/** Consecutive failures within the window before a lock triggers. */
|
|
27
|
+
maxFailures: number;
|
|
28
|
+
/** Sliding window (seconds) over which failures accumulate. */
|
|
29
|
+
windowSeconds: number;
|
|
30
|
+
/** First lock duration (seconds). Doubles each subsequent lock. */
|
|
31
|
+
baseLockSeconds: number;
|
|
32
|
+
/** Upper bound on a single lock duration (seconds). */
|
|
33
|
+
maxLockSeconds: number;
|
|
34
|
+
}
|
|
35
|
+
export declare const DEFAULT_LOCKOUT_CONFIG: LockoutConfig;
|
|
36
|
+
export interface LockoutStatus {
|
|
37
|
+
locked: boolean;
|
|
38
|
+
/** Seconds the caller must wait, when locked. */
|
|
39
|
+
retryAfterSeconds?: number;
|
|
40
|
+
/** Attempts left before a lock, when not locked. */
|
|
41
|
+
remainingAttempts?: number;
|
|
42
|
+
}
|
|
43
|
+
/** Resolve config from env, falling back to the defaults. */
|
|
44
|
+
export declare function lockoutConfigFromEnv(env?: NodeJS.ProcessEnv): LockoutConfig;
|
|
45
|
+
/** True when OMCP_AUTH_LOCKOUT_DISABLED is set to a truthy value. */
|
|
46
|
+
export declare function lockoutDisabledFromEnv(env?: NodeJS.ProcessEnv): boolean;
|
|
47
|
+
export declare class AccountLockout {
|
|
48
|
+
private readonly store;
|
|
49
|
+
private readonly cfg;
|
|
50
|
+
private readonly now;
|
|
51
|
+
/** TTL applied to every persisted entry so stale streaks self-clean. */
|
|
52
|
+
private readonly ttlSeconds;
|
|
53
|
+
constructor(store: SessionStore, cfg?: Partial<LockoutConfig>, now?: () => number);
|
|
54
|
+
private key;
|
|
55
|
+
/** Lock duration for a given (1-based) lock level, capped. */
|
|
56
|
+
private lockDuration;
|
|
57
|
+
/**
|
|
58
|
+
* Inspect lock state without mutating it. Call before verifying the
|
|
59
|
+
* password — a locked account should never reach the (expensive) hash
|
|
60
|
+
* comparison.
|
|
61
|
+
*/
|
|
62
|
+
check(username: string): Promise<LockoutStatus>;
|
|
63
|
+
/** Failures still inside the sliding window (0 if the window lapsed). */
|
|
64
|
+
private activeFailures;
|
|
65
|
+
/**
|
|
66
|
+
* Record a failed login. Returns the resulting status — `locked: true`
|
|
67
|
+
* when this failure tripped (or fell inside) a lock.
|
|
68
|
+
*/
|
|
69
|
+
recordFailure(username: string): Promise<LockoutStatus>;
|
|
70
|
+
/** Clear the streak on a successful login. Keeps no history. */
|
|
71
|
+
recordSuccess(username: string): Promise<void>;
|
|
72
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-account lockout for the management-plane "basic" auth login.
|
|
3
|
+
*
|
|
4
|
+
* The per-IP rate limiter on /api/auth/login (20/min) blunts a noisy
|
|
5
|
+
* brute-force, but it's keyed on the source IP — a distributed attempt, or
|
|
6
|
+
* a slow drip under the IP cap, can still grind a single account's
|
|
7
|
+
* password. This adds a per-username failed-login counter with progressive
|
|
8
|
+
* backoff: after N failures inside a sliding window the account is
|
|
9
|
+
* temporarily locked, and each subsequent lock lasts longer
|
|
10
|
+
* (base · 2^(level-1), capped). A successful login clears the streak.
|
|
11
|
+
*
|
|
12
|
+
* State lives in the shared {@link SessionStore} so a Redis-backed
|
|
13
|
+
* deployment locks consistently across replicas (and self-cleans via TTL).
|
|
14
|
+
* The in-memory default keeps the single-process behaviour with no new dep.
|
|
15
|
+
*
|
|
16
|
+
* Lockout is tracked by the *submitted* username, whether or not it exists,
|
|
17
|
+
* so it can't be used as a user-enumeration oracle and a known username
|
|
18
|
+
* can't be singled out. Read-modify-write is best-effort under concurrency
|
|
19
|
+
* (matching the store's eventually-consistent contract) — the worst case is
|
|
20
|
+
* a couple of extra attempts slipping past the threshold, which the IP rate
|
|
21
|
+
* limiter still bounds. It is never a lockout bypass for the *locked* state:
|
|
22
|
+
* once `lockedUntil` is written, every replica that reads it honours it.
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_LOCKOUT_CONFIG = {
|
|
25
|
+
maxFailures: 5,
|
|
26
|
+
windowSeconds: 15 * 60,
|
|
27
|
+
baseLockSeconds: 60,
|
|
28
|
+
maxLockSeconds: 60 * 60,
|
|
29
|
+
};
|
|
30
|
+
function nowSeconds() {
|
|
31
|
+
return Math.floor(Date.now() / 1000);
|
|
32
|
+
}
|
|
33
|
+
/** Resolve config from env, falling back to the defaults. */
|
|
34
|
+
export function lockoutConfigFromEnv(env = process.env) {
|
|
35
|
+
const num = (raw, fallback) => {
|
|
36
|
+
if (raw === undefined)
|
|
37
|
+
return fallback;
|
|
38
|
+
const n = Number(raw);
|
|
39
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
maxFailures: num(env.OMCP_AUTH_LOCKOUT_MAX_FAILURES, DEFAULT_LOCKOUT_CONFIG.maxFailures),
|
|
43
|
+
windowSeconds: num(env.OMCP_AUTH_LOCKOUT_WINDOW, DEFAULT_LOCKOUT_CONFIG.windowSeconds),
|
|
44
|
+
baseLockSeconds: num(env.OMCP_AUTH_LOCKOUT_BASE, DEFAULT_LOCKOUT_CONFIG.baseLockSeconds),
|
|
45
|
+
maxLockSeconds: num(env.OMCP_AUTH_LOCKOUT_MAX, DEFAULT_LOCKOUT_CONFIG.maxLockSeconds),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** True when OMCP_AUTH_LOCKOUT_DISABLED is set to a truthy value. */
|
|
49
|
+
export function lockoutDisabledFromEnv(env = process.env) {
|
|
50
|
+
const v = env.OMCP_AUTH_LOCKOUT_DISABLED?.trim().toLowerCase();
|
|
51
|
+
return v === "1" || v === "true" || v === "yes";
|
|
52
|
+
}
|
|
53
|
+
export class AccountLockout {
|
|
54
|
+
store;
|
|
55
|
+
cfg;
|
|
56
|
+
now;
|
|
57
|
+
/** TTL applied to every persisted entry so stale streaks self-clean. */
|
|
58
|
+
ttlSeconds;
|
|
59
|
+
constructor(store, cfg = {}, now = nowSeconds) {
|
|
60
|
+
this.store = store;
|
|
61
|
+
this.cfg = { ...DEFAULT_LOCKOUT_CONFIG, ...cfg };
|
|
62
|
+
this.now = now;
|
|
63
|
+
// Outlast both the streak window and the longest possible lock so an
|
|
64
|
+
// abandoned entry always expires, but a live lock never does early.
|
|
65
|
+
this.ttlSeconds = Math.max(this.cfg.windowSeconds, this.cfg.maxLockSeconds) + 60;
|
|
66
|
+
}
|
|
67
|
+
key(username) {
|
|
68
|
+
return `lockout:${username}`;
|
|
69
|
+
}
|
|
70
|
+
/** Lock duration for a given (1-based) lock level, capped. */
|
|
71
|
+
lockDuration(level) {
|
|
72
|
+
const exp = this.cfg.baseLockSeconds * Math.pow(2, Math.max(0, level - 1));
|
|
73
|
+
return Math.min(this.cfg.maxLockSeconds, Math.floor(exp));
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Inspect lock state without mutating it. Call before verifying the
|
|
77
|
+
* password — a locked account should never reach the (expensive) hash
|
|
78
|
+
* comparison.
|
|
79
|
+
*/
|
|
80
|
+
async check(username) {
|
|
81
|
+
const state = await this.store.get(this.key(username));
|
|
82
|
+
const now = this.now();
|
|
83
|
+
if (state?.lockedUntil && state.lockedUntil > now) {
|
|
84
|
+
return { locked: true, retryAfterSeconds: state.lockedUntil - now };
|
|
85
|
+
}
|
|
86
|
+
const failures = this.activeFailures(state, now);
|
|
87
|
+
return { locked: false, remainingAttempts: Math.max(0, this.cfg.maxFailures - failures) };
|
|
88
|
+
}
|
|
89
|
+
/** Failures still inside the sliding window (0 if the window lapsed). */
|
|
90
|
+
activeFailures(state, now) {
|
|
91
|
+
if (!state)
|
|
92
|
+
return 0;
|
|
93
|
+
if (now - state.firstFailureAt > this.cfg.windowSeconds)
|
|
94
|
+
return 0;
|
|
95
|
+
return state.failures;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Record a failed login. Returns the resulting status — `locked: true`
|
|
99
|
+
* when this failure tripped (or fell inside) a lock.
|
|
100
|
+
*/
|
|
101
|
+
async recordFailure(username) {
|
|
102
|
+
const k = this.key(username);
|
|
103
|
+
const now = this.now();
|
|
104
|
+
const prev = await this.store.get(k);
|
|
105
|
+
// If an existing lock is still active, just report it — don't extend
|
|
106
|
+
// it on every blocked attempt (that would let an attacker grow the
|
|
107
|
+
// legitimate user's lock unboundedly).
|
|
108
|
+
if (prev?.lockedUntil && prev.lockedUntil > now) {
|
|
109
|
+
return { locked: true, retryAfterSeconds: prev.lockedUntil - now };
|
|
110
|
+
}
|
|
111
|
+
// Continue the streak only if the window is still open; otherwise reset.
|
|
112
|
+
const windowOpen = prev && now - prev.firstFailureAt <= this.cfg.windowSeconds;
|
|
113
|
+
const next = windowOpen
|
|
114
|
+
? { ...prev, failures: prev.failures + 1 }
|
|
115
|
+
: { failures: 1, firstFailureAt: now, lockLevel: prev?.lockLevel ?? 0 };
|
|
116
|
+
if (next.failures >= this.cfg.maxFailures) {
|
|
117
|
+
// Trip a lock: escalate the level, set the deadline, reset the
|
|
118
|
+
// counter so the next batch of failures earns a longer lock.
|
|
119
|
+
next.lockLevel += 1;
|
|
120
|
+
const duration = this.lockDuration(next.lockLevel);
|
|
121
|
+
next.lockedUntil = now + duration;
|
|
122
|
+
next.failures = 0;
|
|
123
|
+
next.firstFailureAt = now;
|
|
124
|
+
await this.store.setEx(k, this.ttlSeconds, next);
|
|
125
|
+
return { locked: true, retryAfterSeconds: duration };
|
|
126
|
+
}
|
|
127
|
+
await this.store.setEx(k, this.ttlSeconds, next);
|
|
128
|
+
return { locked: false, remainingAttempts: this.cfg.maxFailures - next.failures };
|
|
129
|
+
}
|
|
130
|
+
/** Clear the streak on a successful login. Keeps no history. */
|
|
131
|
+
async recordSuccess(username) {
|
|
132
|
+
await this.store.del(this.key(username));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { InMemorySessionStore } from "../transport/sessionStore.js";
|
|
4
|
+
import { AccountLockout, lockoutConfigFromEnv, lockoutDisabledFromEnv, DEFAULT_LOCKOUT_CONFIG, } from "./lockout.js";
|
|
5
|
+
const cfg = { maxFailures: 3, windowSeconds: 100, baseLockSeconds: 60, maxLockSeconds: 600 };
|
|
6
|
+
function mk(now) {
|
|
7
|
+
return new AccountLockout(new InMemorySessionStore(), cfg, () => now.t);
|
|
8
|
+
}
|
|
9
|
+
test("unknown account is not locked and reports full remaining attempts", async () => {
|
|
10
|
+
const now = { t: 1000 };
|
|
11
|
+
const lock = mk(now);
|
|
12
|
+
const status = await lock.check("alice");
|
|
13
|
+
assert.equal(status.locked, false);
|
|
14
|
+
assert.equal(status.remainingAttempts, 3);
|
|
15
|
+
});
|
|
16
|
+
test("failures below the threshold decrement remaining attempts", async () => {
|
|
17
|
+
const now = { t: 1000 };
|
|
18
|
+
const lock = mk(now);
|
|
19
|
+
assert.deepEqual(await lock.recordFailure("alice"), { locked: false, remainingAttempts: 2 });
|
|
20
|
+
assert.deepEqual(await lock.recordFailure("alice"), { locked: false, remainingAttempts: 1 });
|
|
21
|
+
});
|
|
22
|
+
test("the Nth failure trips a lock with the base duration", async () => {
|
|
23
|
+
const now = { t: 1000 };
|
|
24
|
+
const lock = mk(now);
|
|
25
|
+
await lock.recordFailure("alice");
|
|
26
|
+
await lock.recordFailure("alice");
|
|
27
|
+
const tripped = await lock.recordFailure("alice");
|
|
28
|
+
assert.equal(tripped.locked, true);
|
|
29
|
+
assert.equal(tripped.retryAfterSeconds, 60);
|
|
30
|
+
// check() reflects the lock and counts down with the clock.
|
|
31
|
+
now.t += 10;
|
|
32
|
+
const status = await lock.check("alice");
|
|
33
|
+
assert.equal(status.locked, true);
|
|
34
|
+
assert.equal(status.retryAfterSeconds, 50);
|
|
35
|
+
});
|
|
36
|
+
test("a lapsed lock clears and lets attempts resume", async () => {
|
|
37
|
+
const now = { t: 1000 };
|
|
38
|
+
const lock = mk(now);
|
|
39
|
+
await lock.recordFailure("alice");
|
|
40
|
+
await lock.recordFailure("alice");
|
|
41
|
+
await lock.recordFailure("alice"); // locked for 60s
|
|
42
|
+
now.t += 61;
|
|
43
|
+
const status = await lock.check("alice");
|
|
44
|
+
assert.equal(status.locked, false);
|
|
45
|
+
});
|
|
46
|
+
test("progressive backoff doubles each lock, capped at maxLockSeconds", async () => {
|
|
47
|
+
const now = { t: 0 };
|
|
48
|
+
const lock = mk(now);
|
|
49
|
+
async function tripLock() {
|
|
50
|
+
let last = { locked: false };
|
|
51
|
+
for (let i = 0; i < cfg.maxFailures; i++)
|
|
52
|
+
last = await lock.recordFailure("bob");
|
|
53
|
+
return last.retryAfterSeconds;
|
|
54
|
+
}
|
|
55
|
+
// Level 1 → 60, level 2 → 120, level 3 → 240, level 4 → 480, level 5 → 600 (capped).
|
|
56
|
+
const durations = [];
|
|
57
|
+
for (let lvl = 0; lvl < 5; lvl++) {
|
|
58
|
+
const d = await tripLock();
|
|
59
|
+
durations.push(d);
|
|
60
|
+
now.t += d + 1; // wait out the lock before the next streak
|
|
61
|
+
}
|
|
62
|
+
assert.deepEqual(durations, [60, 120, 240, 480, 600]);
|
|
63
|
+
});
|
|
64
|
+
test("a blocked attempt during an active lock does not extend it", async () => {
|
|
65
|
+
const now = { t: 1000 };
|
|
66
|
+
const lock = mk(now);
|
|
67
|
+
await lock.recordFailure("alice");
|
|
68
|
+
await lock.recordFailure("alice");
|
|
69
|
+
const tripped = await lock.recordFailure("alice"); // locked 60s at t=1000
|
|
70
|
+
assert.equal(tripped.retryAfterSeconds, 60);
|
|
71
|
+
now.t += 30;
|
|
72
|
+
const blocked = await lock.recordFailure("alice"); // still locked, t=1030
|
|
73
|
+
assert.equal(blocked.locked, true);
|
|
74
|
+
// Deadline unchanged (1060) → 30s left, NOT re-extended to 60.
|
|
75
|
+
assert.equal(blocked.retryAfterSeconds, 30);
|
|
76
|
+
});
|
|
77
|
+
test("failures outside the window reset the streak", async () => {
|
|
78
|
+
const now = { t: 1000 };
|
|
79
|
+
const lock = mk(now);
|
|
80
|
+
await lock.recordFailure("alice"); // failures=1, firstFailureAt=1000
|
|
81
|
+
await lock.recordFailure("alice"); // failures=2
|
|
82
|
+
now.t += cfg.windowSeconds + 1; // window lapsed
|
|
83
|
+
const status = await lock.recordFailure("alice"); // resets to failures=1
|
|
84
|
+
assert.equal(status.locked, false);
|
|
85
|
+
assert.equal(status.remainingAttempts, 2);
|
|
86
|
+
});
|
|
87
|
+
test("recordSuccess clears the streak", async () => {
|
|
88
|
+
const now = { t: 1000 };
|
|
89
|
+
const lock = mk(now);
|
|
90
|
+
await lock.recordFailure("alice");
|
|
91
|
+
await lock.recordFailure("alice");
|
|
92
|
+
await lock.recordSuccess("alice");
|
|
93
|
+
const status = await lock.check("alice");
|
|
94
|
+
assert.equal(status.locked, false);
|
|
95
|
+
assert.equal(status.remainingAttempts, 3);
|
|
96
|
+
});
|
|
97
|
+
test("lockout is tracked per username — one account's lock doesn't touch another", async () => {
|
|
98
|
+
const now = { t: 1000 };
|
|
99
|
+
const lock = mk(now);
|
|
100
|
+
await lock.recordFailure("alice");
|
|
101
|
+
await lock.recordFailure("alice");
|
|
102
|
+
await lock.recordFailure("alice"); // alice locked
|
|
103
|
+
assert.equal((await lock.check("alice")).locked, true);
|
|
104
|
+
assert.equal((await lock.check("bob")).locked, false);
|
|
105
|
+
assert.equal((await lock.check("bob")).remainingAttempts, 3);
|
|
106
|
+
});
|
|
107
|
+
test("state persists with a TTL so it self-cleans", async () => {
|
|
108
|
+
const store = new InMemorySessionStore();
|
|
109
|
+
const now = { t: 1000 };
|
|
110
|
+
const lock = new AccountLockout(store, cfg, () => now.t);
|
|
111
|
+
await lock.recordFailure("alice");
|
|
112
|
+
// One key written under the lockout: prefix.
|
|
113
|
+
const keys = await store.keys("lockout:");
|
|
114
|
+
assert.deepEqual(keys, ["lockout:alice"]);
|
|
115
|
+
});
|
|
116
|
+
test("lockoutConfigFromEnv parses overrides and falls back on bad input", () => {
|
|
117
|
+
const parsed = lockoutConfigFromEnv({
|
|
118
|
+
OMCP_AUTH_LOCKOUT_MAX_FAILURES: "10",
|
|
119
|
+
OMCP_AUTH_LOCKOUT_WINDOW: "300",
|
|
120
|
+
OMCP_AUTH_LOCKOUT_BASE: "nonsense",
|
|
121
|
+
OMCP_AUTH_LOCKOUT_MAX: "-5",
|
|
122
|
+
});
|
|
123
|
+
assert.equal(parsed.maxFailures, 10);
|
|
124
|
+
assert.equal(parsed.windowSeconds, 300);
|
|
125
|
+
assert.equal(parsed.baseLockSeconds, DEFAULT_LOCKOUT_CONFIG.baseLockSeconds); // bad → default
|
|
126
|
+
assert.equal(parsed.maxLockSeconds, DEFAULT_LOCKOUT_CONFIG.maxLockSeconds); // negative → default
|
|
127
|
+
});
|
|
128
|
+
test("lockoutDisabledFromEnv recognises truthy values", () => {
|
|
129
|
+
assert.equal(lockoutDisabledFromEnv({ OMCP_AUTH_LOCKOUT_DISABLED: "true" }), true);
|
|
130
|
+
assert.equal(lockoutDisabledFromEnv({ OMCP_AUTH_LOCKOUT_DISABLED: "1" }), true);
|
|
131
|
+
assert.equal(lockoutDisabledFromEnv({ OMCP_AUTH_LOCKOUT_DISABLED: "no" }), false);
|
|
132
|
+
assert.equal(lockoutDisabledFromEnv({}), false);
|
|
133
|
+
});
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import type { Request, RequestHandler } from "express";
|
|
15
15
|
import { type SessionPayload, type SessionConfig } from "./session.js";
|
|
16
16
|
import type { OidcRuntime } from "./oidc/runtime.js";
|
|
17
|
+
import type { RevocationStore } from "./revocation.js";
|
|
17
18
|
export type AuthMode = "anonymous" | "basic" | "oidc";
|
|
18
19
|
export interface AuthRuntime {
|
|
19
20
|
mode: AuthMode;
|
|
@@ -30,6 +31,10 @@ export interface AuthRuntime {
|
|
|
30
31
|
* runtime coupling — middleware.ts still doesn't depend on the
|
|
31
32
|
* OIDC sub-module's node:crypto path. */
|
|
32
33
|
oidc?: OidcRuntime;
|
|
34
|
+
/** Session revocation blocklist. Present in basic / oidc mode; the
|
|
35
|
+
* session attacher consults it on every request so a revoked but
|
|
36
|
+
* not-yet-expired cookie is treated as logged out. */
|
|
37
|
+
revocation?: RevocationStore;
|
|
33
38
|
}
|
|
34
39
|
export interface AuthedRequest extends Request {
|
|
35
40
|
session?: SessionPayload;
|
package/dist/auth/middleware.js
CHANGED
|
@@ -34,8 +34,13 @@ export function buildSessionAttacher(runtime) {
|
|
|
34
34
|
// branch decides whether the check runs.
|
|
35
35
|
const raw = readCookie(req.headers.cookie || "");
|
|
36
36
|
const payload = verifySession(raw, sessionCfg);
|
|
37
|
-
|
|
37
|
+
// A valid signature is necessary but not sufficient: a session whose
|
|
38
|
+
// sid (or whose subject, before the revocation cutoff) is on the
|
|
39
|
+
// blocklist is treated as absent. isRevoked is a synchronous in-memory
|
|
40
|
+
// lookup, so this stays a no-await hot path.
|
|
41
|
+
if (payload && !runtime.revocation?.isRevoked(payload)) {
|
|
38
42
|
req.session = payload;
|
|
43
|
+
}
|
|
39
44
|
next();
|
|
40
45
|
};
|
|
41
46
|
}
|