@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
|
@@ -2,6 +2,7 @@ import { test } from "node:test";
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { buildSessionAttacher, buildRequireSession } from "./middleware.js";
|
|
4
4
|
import { issueSession } from "./session.js";
|
|
5
|
+
import { RevocationStore } from "./revocation.js";
|
|
5
6
|
const secret = "x".repeat(48);
|
|
6
7
|
const sessionCfg = { secret };
|
|
7
8
|
function mkReq(opts = {}) {
|
|
@@ -56,6 +57,36 @@ test("session attacher — tampered cookie leaves session undefined and still fl
|
|
|
56
57
|
assert.equal(called, true);
|
|
57
58
|
assert.equal(req.session, undefined);
|
|
58
59
|
});
|
|
60
|
+
test("session attacher — a revoked session (by sid) is not attached", async () => {
|
|
61
|
+
const { cookie, payload } = issueSession({ sub: "alice", name: "Alice" }, sessionCfg);
|
|
62
|
+
const revocation = await RevocationStore.create();
|
|
63
|
+
await revocation.revokeSession(payload.sid);
|
|
64
|
+
const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg, revocation });
|
|
65
|
+
const req = mkReq({ cookieHeader: `omcp_session=${cookie}` });
|
|
66
|
+
let called = false;
|
|
67
|
+
attach(req, mkRes(), () => { called = true; });
|
|
68
|
+
assert.equal(called, true);
|
|
69
|
+
assert.equal(req.session, undefined);
|
|
70
|
+
});
|
|
71
|
+
test("session attacher — a subject-revoked session is not attached", async () => {
|
|
72
|
+
const { cookie } = issueSession({ sub: "bob", name: "Bob" }, sessionCfg);
|
|
73
|
+
const revocation = await RevocationStore.create();
|
|
74
|
+
// Revoke into the future so the just-issued session's iat is <= cutoff.
|
|
75
|
+
await revocation.revokeSubject("bob", { reason: "offboarded" });
|
|
76
|
+
const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg, revocation });
|
|
77
|
+
const req = mkReq({ cookieHeader: `omcp_session=${cookie}` });
|
|
78
|
+
attach(req, mkRes(), () => { });
|
|
79
|
+
assert.equal(req.session, undefined);
|
|
80
|
+
});
|
|
81
|
+
test("session attacher — an unrevoked session still attaches when a blocklist is present", async () => {
|
|
82
|
+
const { cookie } = issueSession({ sub: "carol", name: "Carol" }, sessionCfg);
|
|
83
|
+
const revocation = await RevocationStore.create();
|
|
84
|
+
await revocation.revokeSession("some-other-sid");
|
|
85
|
+
const attach = buildSessionAttacher({ mode: "basic", session: sessionCfg, revocation });
|
|
86
|
+
const req = mkReq({ cookieHeader: `omcp_session=${cookie}` });
|
|
87
|
+
attach(req, mkRes(), () => { });
|
|
88
|
+
assert.equal(req.session?.sub, "carol");
|
|
89
|
+
});
|
|
59
90
|
test("require-session — anonymous always allows", () => {
|
|
60
91
|
const gate = buildRequireSession({ mode: "anonymous" });
|
|
61
92
|
const req = mkReq();
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password policy for the management-plane "basic" auth mode.
|
|
3
|
+
*
|
|
4
|
+
* Where this is enforced: the only point in the system that ever sees a
|
|
5
|
+
* management password in plaintext is where one is *minted* — the
|
|
6
|
+
* `scripts/hash-password.mjs` helper. The users file stores scrypt
|
|
7
|
+
* hashes only, so password strength cannot be re-evaluated at load time
|
|
8
|
+
* (there is no plaintext to check), and there is no runtime endpoint that
|
|
9
|
+
* accepts a plaintext password to set. This module is therefore the
|
|
10
|
+
* canonical, dependency-free policy that the minting path enforces — and
|
|
11
|
+
* that any future "change my password" endpoint should call before
|
|
12
|
+
* hashing. Login (`verifyPassword`) never re-checks policy: that would
|
|
13
|
+
* lock out users whose passwords predate a tightened policy.
|
|
14
|
+
*
|
|
15
|
+
* "Basic" ruleset: a minimum length, a minimum number of character
|
|
16
|
+
* classes, a small builtin common-password denylist, and a guard against
|
|
17
|
+
* passwords that just echo the username. Deliberately small — this is a
|
|
18
|
+
* footgun guard for self-hosted operators, not a compliance engine.
|
|
19
|
+
*/
|
|
20
|
+
export interface PasswordPolicy {
|
|
21
|
+
/** Minimum length in Unicode code points. */
|
|
22
|
+
minLength: number;
|
|
23
|
+
/** Maximum length — a sanity bound so a megabyte "password" can't be
|
|
24
|
+
* fed into scrypt. */
|
|
25
|
+
maxLength: number;
|
|
26
|
+
/** How many of the four classes (lower/upper/digit/symbol) are required. */
|
|
27
|
+
minClasses: number;
|
|
28
|
+
/** When true, reject passwords on the builtin common-password list. */
|
|
29
|
+
denylistEnabled: boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare const DEFAULT_PASSWORD_POLICY: PasswordPolicy;
|
|
32
|
+
/**
|
|
33
|
+
* A small list of the most-abused passwords + obvious app-specific ones.
|
|
34
|
+
* Not exhaustive — the real defenders are length + classes. Lowercased;
|
|
35
|
+
* comparison is case-insensitive.
|
|
36
|
+
*/
|
|
37
|
+
export declare const COMMON_PASSWORD_DENYLIST: ReadonlySet<string>;
|
|
38
|
+
export interface PasswordCheckResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
/** Human-readable reasons the password was rejected; empty when ok. */
|
|
41
|
+
errors: string[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Validate a plaintext password against the policy. `username`, when
|
|
45
|
+
* given, additionally rejects passwords that are (or merely contain) the
|
|
46
|
+
* username — the single most common weak choice.
|
|
47
|
+
*/
|
|
48
|
+
export declare function validatePassword(password: string, policy?: PasswordPolicy, username?: string): PasswordCheckResult;
|
|
49
|
+
/** Resolve the policy from env, falling back to the defaults. */
|
|
50
|
+
export declare function passwordPolicyFromEnv(env?: NodeJS.ProcessEnv): PasswordPolicy;
|
|
51
|
+
/** True when the policy is turned off entirely via env. */
|
|
52
|
+
export declare function passwordPolicyDisabledFromEnv(env?: NodeJS.ProcessEnv): boolean;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Password policy for the management-plane "basic" auth mode.
|
|
3
|
+
*
|
|
4
|
+
* Where this is enforced: the only point in the system that ever sees a
|
|
5
|
+
* management password in plaintext is where one is *minted* — the
|
|
6
|
+
* `scripts/hash-password.mjs` helper. The users file stores scrypt
|
|
7
|
+
* hashes only, so password strength cannot be re-evaluated at load time
|
|
8
|
+
* (there is no plaintext to check), and there is no runtime endpoint that
|
|
9
|
+
* accepts a plaintext password to set. This module is therefore the
|
|
10
|
+
* canonical, dependency-free policy that the minting path enforces — and
|
|
11
|
+
* that any future "change my password" endpoint should call before
|
|
12
|
+
* hashing. Login (`verifyPassword`) never re-checks policy: that would
|
|
13
|
+
* lock out users whose passwords predate a tightened policy.
|
|
14
|
+
*
|
|
15
|
+
* "Basic" ruleset: a minimum length, a minimum number of character
|
|
16
|
+
* classes, a small builtin common-password denylist, and a guard against
|
|
17
|
+
* passwords that just echo the username. Deliberately small — this is a
|
|
18
|
+
* footgun guard for self-hosted operators, not a compliance engine.
|
|
19
|
+
*/
|
|
20
|
+
export const DEFAULT_PASSWORD_POLICY = {
|
|
21
|
+
minLength: 12,
|
|
22
|
+
maxLength: 1024,
|
|
23
|
+
minClasses: 3,
|
|
24
|
+
denylistEnabled: true,
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* A small list of the most-abused passwords + obvious app-specific ones.
|
|
28
|
+
* Not exhaustive — the real defenders are length + classes. Lowercased;
|
|
29
|
+
* comparison is case-insensitive.
|
|
30
|
+
*/
|
|
31
|
+
export const COMMON_PASSWORD_DENYLIST = new Set([
|
|
32
|
+
"password", "password1", "password123", "passw0rd", "p@ssw0rd", "p@ssword",
|
|
33
|
+
"123456", "1234567", "12345678", "123456789", "1234567890", "12345",
|
|
34
|
+
"qwerty", "qwerty123", "qwertyuiop", "asdfghjkl", "1q2w3e4r", "1qaz2wsx",
|
|
35
|
+
"letmein", "welcome", "welcome1", "admin", "admin123", "administrator",
|
|
36
|
+
"root", "toor", "changeme", "default", "guest", "iloveyou", "monkey",
|
|
37
|
+
"dragon", "sunshine", "princess", "football", "baseball", "abc123",
|
|
38
|
+
"654321", "111111", "000000", "superman", "trustno1", "master",
|
|
39
|
+
"hello123", "secret", "test", "test123", "user", "login", "passport",
|
|
40
|
+
"observability", "observability-mcp", "prometheus", "grafana", "loki",
|
|
41
|
+
]);
|
|
42
|
+
/**
|
|
43
|
+
* Count distinct character classes present. Classification is ASCII-based:
|
|
44
|
+
* a-z, A-Z, 0-9, and "everything else" (symbol). Non-ASCII letters (é, ü,
|
|
45
|
+
* emoji, CJK) therefore count as the symbol class, never lower/upper. This
|
|
46
|
+
* is deliberately conservative — it can only ever under-count classes, so
|
|
47
|
+
* it never lets a weaker password through than the rule implies.
|
|
48
|
+
*/
|
|
49
|
+
function countClasses(pw) {
|
|
50
|
+
let lower = false, upper = false, digit = false, symbol = false;
|
|
51
|
+
for (const ch of pw) {
|
|
52
|
+
if (ch >= "a" && ch <= "z")
|
|
53
|
+
lower = true;
|
|
54
|
+
else if (ch >= "A" && ch <= "Z")
|
|
55
|
+
upper = true;
|
|
56
|
+
else if (ch >= "0" && ch <= "9")
|
|
57
|
+
digit = true;
|
|
58
|
+
else
|
|
59
|
+
symbol = true;
|
|
60
|
+
}
|
|
61
|
+
return Number(lower) + Number(upper) + Number(digit) + Number(symbol);
|
|
62
|
+
}
|
|
63
|
+
/** Length in code points (so emoji / multibyte count as one). */
|
|
64
|
+
function codePointLength(s) {
|
|
65
|
+
let n = 0;
|
|
66
|
+
for (const _ of s)
|
|
67
|
+
n++;
|
|
68
|
+
return n;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Validate a plaintext password against the policy. `username`, when
|
|
72
|
+
* given, additionally rejects passwords that are (or merely contain) the
|
|
73
|
+
* username — the single most common weak choice.
|
|
74
|
+
*/
|
|
75
|
+
export function validatePassword(password, policy = DEFAULT_PASSWORD_POLICY, username) {
|
|
76
|
+
const errors = [];
|
|
77
|
+
const len = codePointLength(password);
|
|
78
|
+
if (len < policy.minLength) {
|
|
79
|
+
errors.push(`must be at least ${policy.minLength} characters (got ${len})`);
|
|
80
|
+
}
|
|
81
|
+
if (len > policy.maxLength) {
|
|
82
|
+
errors.push(`must be at most ${policy.maxLength} characters`);
|
|
83
|
+
}
|
|
84
|
+
if (policy.minClasses > 1) {
|
|
85
|
+
const classes = countClasses(password);
|
|
86
|
+
if (classes < policy.minClasses) {
|
|
87
|
+
errors.push(`must mix at least ${policy.minClasses} of: lowercase, uppercase, digit, symbol (got ${classes})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (policy.denylistEnabled && COMMON_PASSWORD_DENYLIST.has(password.toLowerCase())) {
|
|
91
|
+
errors.push("is on the common-password denylist");
|
|
92
|
+
}
|
|
93
|
+
if (username) {
|
|
94
|
+
const u = username.toLowerCase();
|
|
95
|
+
const p = password.toLowerCase();
|
|
96
|
+
if (u.length >= 3 && p.includes(u)) {
|
|
97
|
+
errors.push("must not contain the username");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { ok: errors.length === 0, errors };
|
|
101
|
+
}
|
|
102
|
+
/** Resolve the policy from env, falling back to the defaults. */
|
|
103
|
+
export function passwordPolicyFromEnv(env = process.env) {
|
|
104
|
+
const num = (raw, fallback) => {
|
|
105
|
+
if (raw === undefined)
|
|
106
|
+
return fallback;
|
|
107
|
+
const n = Number(raw);
|
|
108
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
|
|
109
|
+
};
|
|
110
|
+
const truthy = (raw) => {
|
|
111
|
+
const v = raw?.trim().toLowerCase();
|
|
112
|
+
return v === "1" || v === "true" || v === "yes";
|
|
113
|
+
};
|
|
114
|
+
return {
|
|
115
|
+
minLength: num(env.OMCP_PASSWORD_MIN_LENGTH, DEFAULT_PASSWORD_POLICY.minLength),
|
|
116
|
+
maxLength: DEFAULT_PASSWORD_POLICY.maxLength,
|
|
117
|
+
minClasses: num(env.OMCP_PASSWORD_MIN_CLASSES, DEFAULT_PASSWORD_POLICY.minClasses),
|
|
118
|
+
denylistEnabled: !truthy(env.OMCP_PASSWORD_DENYLIST_DISABLED),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** True when the policy is turned off entirely via env. */
|
|
122
|
+
export function passwordPolicyDisabledFromEnv(env = process.env) {
|
|
123
|
+
const v = env.OMCP_PASSWORD_POLICY_DISABLED?.trim().toLowerCase();
|
|
124
|
+
return v === "1" || v === "true" || v === "yes";
|
|
125
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { validatePassword, passwordPolicyFromEnv, passwordPolicyDisabledFromEnv, DEFAULT_PASSWORD_POLICY, COMMON_PASSWORD_DENYLIST, } from "./password-policy.js";
|
|
7
|
+
test("a strong password passes the default policy", () => {
|
|
8
|
+
const r = validatePassword("Tr0ub4dour&3xtra");
|
|
9
|
+
assert.equal(r.ok, true);
|
|
10
|
+
assert.deepEqual(r.errors, []);
|
|
11
|
+
});
|
|
12
|
+
test("too-short password is rejected with a length error", () => {
|
|
13
|
+
const r = validatePassword("Ab1!xy");
|
|
14
|
+
assert.equal(r.ok, false);
|
|
15
|
+
assert.ok(r.errors.some((e) => e.includes("at least 12")));
|
|
16
|
+
});
|
|
17
|
+
test("insufficient character classes is rejected", () => {
|
|
18
|
+
// 16 lowercase letters — long enough, but only one class.
|
|
19
|
+
const r = validatePassword("abcdefghijklmnop");
|
|
20
|
+
assert.equal(r.ok, false);
|
|
21
|
+
assert.ok(r.errors.some((e) => e.includes("at least 3 of")));
|
|
22
|
+
});
|
|
23
|
+
test("two classes still fails the default min of three", () => {
|
|
24
|
+
const r = validatePassword("abcdefghijkl1234"); // lower + digit only
|
|
25
|
+
assert.equal(r.ok, false);
|
|
26
|
+
assert.ok(r.errors.some((e) => e.includes("at least 3 of")));
|
|
27
|
+
});
|
|
28
|
+
test("exactly three classes passes", () => {
|
|
29
|
+
const r = validatePassword("abcdefghABCD1234"); // lower+upper+digit = 3
|
|
30
|
+
assert.equal(r.ok, true);
|
|
31
|
+
});
|
|
32
|
+
test("common-password denylist is enforced case-insensitively", () => {
|
|
33
|
+
// Permissive length/classes so ONLY the denylist can reject — the
|
|
34
|
+
// denylist matches the whole password exactly (case-insensitively).
|
|
35
|
+
const permissive = { minLength: 1, maxLength: 1024, minClasses: 1, denylistEnabled: true };
|
|
36
|
+
const r = validatePassword("PaSsWoRd123", permissive);
|
|
37
|
+
assert.equal(r.ok, false);
|
|
38
|
+
assert.ok(r.errors.some((e) => e.includes("denylist")));
|
|
39
|
+
});
|
|
40
|
+
test("denylist entries themselves are all rejected when long enough is waived", () => {
|
|
41
|
+
// Sanity: the canonical app-specific entries are present.
|
|
42
|
+
assert.ok(COMMON_PASSWORD_DENYLIST.has("observability-mcp"));
|
|
43
|
+
assert.ok(COMMON_PASSWORD_DENYLIST.has("prometheus"));
|
|
44
|
+
});
|
|
45
|
+
test("password containing the username is rejected", () => {
|
|
46
|
+
const r = validatePassword("alice-Secret-99x", DEFAULT_PASSWORD_POLICY, "alice");
|
|
47
|
+
assert.equal(r.ok, false);
|
|
48
|
+
assert.ok(r.errors.some((e) => e.includes("username")));
|
|
49
|
+
});
|
|
50
|
+
test("short usernames (<3 chars) do not trigger the username check", () => {
|
|
51
|
+
// "al" is too short to meaningfully match; password is otherwise strong.
|
|
52
|
+
const r = validatePassword("alXyZ12345678!", DEFAULT_PASSWORD_POLICY, "al");
|
|
53
|
+
assert.equal(r.ok, true);
|
|
54
|
+
});
|
|
55
|
+
test("code-point length counts multibyte characters as one", () => {
|
|
56
|
+
// 11 emoji = 11 code points < 12 → too short despite a large byte length.
|
|
57
|
+
const eleven = "😀".repeat(11);
|
|
58
|
+
assert.equal(validatePassword(eleven).ok, false);
|
|
59
|
+
// 12 of them clears length; emoji count as the "symbol" class only → 1 class.
|
|
60
|
+
const twelve = "😀".repeat(12);
|
|
61
|
+
const r = validatePassword(twelve);
|
|
62
|
+
assert.equal(r.ok, false); // fails on classes, not length
|
|
63
|
+
assert.ok(r.errors.some((e) => e.includes("at least 3 of")));
|
|
64
|
+
assert.ok(!r.errors.some((e) => e.includes("at least 12")));
|
|
65
|
+
});
|
|
66
|
+
test("over-long password is rejected (scrypt DoS guard)", () => {
|
|
67
|
+
const r = validatePassword("Aa1!".repeat(300)); // 1200 chars > 1024
|
|
68
|
+
assert.equal(r.ok, false);
|
|
69
|
+
assert.ok(r.errors.some((e) => e.includes("at most")));
|
|
70
|
+
});
|
|
71
|
+
test("disabling the denylist lets a denylisted password through", () => {
|
|
72
|
+
const permissive = { minLength: 1, maxLength: 1024, minClasses: 1, denylistEnabled: false };
|
|
73
|
+
// 'password123' is on the denylist; with it disabled only length/classes
|
|
74
|
+
// apply, which this permissive policy waives.
|
|
75
|
+
assert.equal(validatePassword("password123", permissive).ok, true);
|
|
76
|
+
});
|
|
77
|
+
test("passwordPolicyFromEnv parses overrides and ignores bad input", () => {
|
|
78
|
+
const p = passwordPolicyFromEnv({
|
|
79
|
+
OMCP_PASSWORD_MIN_LENGTH: "16",
|
|
80
|
+
OMCP_PASSWORD_MIN_CLASSES: "bad",
|
|
81
|
+
OMCP_PASSWORD_DENYLIST_DISABLED: "true",
|
|
82
|
+
});
|
|
83
|
+
assert.equal(p.minLength, 16);
|
|
84
|
+
assert.equal(p.minClasses, DEFAULT_PASSWORD_POLICY.minClasses); // bad → default
|
|
85
|
+
assert.equal(p.denylistEnabled, false);
|
|
86
|
+
});
|
|
87
|
+
test("passwordPolicyDisabledFromEnv recognises truthy values", () => {
|
|
88
|
+
assert.equal(passwordPolicyDisabledFromEnv({ OMCP_PASSWORD_POLICY_DISABLED: "1" }), true);
|
|
89
|
+
assert.equal(passwordPolicyDisabledFromEnv({ OMCP_PASSWORD_POLICY_DISABLED: "false" }), false);
|
|
90
|
+
assert.equal(passwordPolicyDisabledFromEnv({}), false);
|
|
91
|
+
});
|
|
92
|
+
test("CLI hash-password.mjs stays in sync with the canonical policy", () => {
|
|
93
|
+
// The CLI deliberately duplicates the policy to stay dependency-free
|
|
94
|
+
// (same precedent as the scrypt params). This guard fails loudly if the
|
|
95
|
+
// two drift: every canonical denylist entry + the defaults must appear
|
|
96
|
+
// verbatim in the script. (__dirname → mcp-server/src/auth)
|
|
97
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
98
|
+
const cliPath = join(here, "..", "..", "..", "scripts", "hash-password.mjs");
|
|
99
|
+
const cli = readFileSync(cliPath, "utf8");
|
|
100
|
+
for (const entry of COMMON_PASSWORD_DENYLIST) {
|
|
101
|
+
assert.ok(cli.includes(`"${entry}"`), `CLI denylist is missing "${entry}"`);
|
|
102
|
+
}
|
|
103
|
+
assert.ok(cli.includes(`"OMCP_PASSWORD_MIN_LENGTH", ${DEFAULT_PASSWORD_POLICY.minLength}`), "CLI minLength default drifted");
|
|
104
|
+
assert.ok(cli.includes(`"OMCP_PASSWORD_MIN_CLASSES", ${DEFAULT_PASSWORD_POLICY.minClasses}`), "CLI minClasses default drifted");
|
|
105
|
+
assert.ok(cli.includes(`PW_MAX_LENGTH = ${DEFAULT_PASSWORD_POLICY.maxLength}`), "CLI maxLength default drifted");
|
|
106
|
+
});
|
|
107
|
+
test("multiple violations are all reported", () => {
|
|
108
|
+
const r = validatePassword("admin"); // short, 1 class, on denylist
|
|
109
|
+
assert.equal(r.ok, false);
|
|
110
|
+
assert.ok(r.errors.length >= 2);
|
|
111
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session/JWT revocation blocklist for the management plane.
|
|
3
|
+
*
|
|
4
|
+
* OMCP sessions are stateless signed cookies (see ./session.ts) — there is
|
|
5
|
+
* no server-side session table to delete from, so a logout or a
|
|
6
|
+
* compromised-credential incident can't, on its own, invalidate an
|
|
7
|
+
* outstanding cookie before its `exp`. This blocklist closes that gap: a
|
|
8
|
+
* small append-only JSONL file of revocations that every request consults
|
|
9
|
+
* (in buildSessionAttacher) before a session payload is trusted.
|
|
10
|
+
*
|
|
11
|
+
* Two revocation shapes:
|
|
12
|
+
* - session: drop one specific session by its `sid`.
|
|
13
|
+
* - subject: drop every session for a `sub` issued at or before the
|
|
14
|
+
* revocation timestamp ("force re-login"). A fresh login afterwards
|
|
15
|
+
* mints a new session with a later `iat`, which is NOT caught — so
|
|
16
|
+
* subject-revoke is a logout-everywhere, not a permanent ban.
|
|
17
|
+
*
|
|
18
|
+
* Backend is an on-disk JSONL file (one entry per line, mode 0600). When
|
|
19
|
+
* no path is configured the store is in-memory only (lost on restart);
|
|
20
|
+
* set OMCP_AUTH_REVOCATION_FILE so revocations survive a restart.
|
|
21
|
+
*
|
|
22
|
+
* Multi-replica caveat: the file is read once at startup and the writing
|
|
23
|
+
* replica updates its own in-memory index immediately, but there is no
|
|
24
|
+
* live cross-replica propagation — a revocation issued on replica A is
|
|
25
|
+
* not seen by replica B until B restarts. A shared-store backend (Redis,
|
|
26
|
+
* mirroring the SCIM / transport stores) is the planned path to
|
|
27
|
+
* fleet-wide live propagation; see docs/access-control.md.
|
|
28
|
+
*/
|
|
29
|
+
export type RevocationKind = "session" | "subject";
|
|
30
|
+
export interface RevocationEntry {
|
|
31
|
+
kind: RevocationKind;
|
|
32
|
+
/** sid for kind "session"; sub for kind "subject". */
|
|
33
|
+
value: string;
|
|
34
|
+
/** Revocation time, seconds since epoch. */
|
|
35
|
+
revokedAt: number;
|
|
36
|
+
/** Optional free-text reason (truncated by the caller). */
|
|
37
|
+
reason?: string;
|
|
38
|
+
/** Optional actor who issued the revocation (admin sub). */
|
|
39
|
+
by?: string;
|
|
40
|
+
}
|
|
41
|
+
export interface RevocationStoreConfig {
|
|
42
|
+
/** JSONL file path. Omitted → in-memory only. */
|
|
43
|
+
path?: string;
|
|
44
|
+
/** Clock injection for tests. Returns seconds since epoch. */
|
|
45
|
+
now?: () => number;
|
|
46
|
+
}
|
|
47
|
+
/** The minimal session shape isRevoked needs to make a decision. */
|
|
48
|
+
export interface RevocableSession {
|
|
49
|
+
sub: string;
|
|
50
|
+
iat: number;
|
|
51
|
+
sid?: string;
|
|
52
|
+
}
|
|
53
|
+
export declare class RevocationStore {
|
|
54
|
+
/** Revoked individual session ids. */
|
|
55
|
+
private readonly sids;
|
|
56
|
+
/** sub → latest subject-revocation cutoff (seconds since epoch). */
|
|
57
|
+
private readonly subjectCutoffs;
|
|
58
|
+
/** Full ordered history, kept so list() can mirror the file. */
|
|
59
|
+
private readonly entries;
|
|
60
|
+
private readonly path?;
|
|
61
|
+
private readonly now;
|
|
62
|
+
/** Serialises appends so two concurrent revokes can't interleave a line. */
|
|
63
|
+
private writeChain;
|
|
64
|
+
private constructor();
|
|
65
|
+
/** Build a store, loading any existing on-disk blocklist. */
|
|
66
|
+
static create(cfg?: RevocationStoreConfig): Promise<RevocationStore>;
|
|
67
|
+
/** True when this store persists to disk. */
|
|
68
|
+
get persistent(): boolean;
|
|
69
|
+
get filePath(): string | undefined;
|
|
70
|
+
/** Number of revocation entries currently held. */
|
|
71
|
+
get size(): number;
|
|
72
|
+
private load;
|
|
73
|
+
/** Apply an entry to the in-memory indices + history (no write). */
|
|
74
|
+
private index;
|
|
75
|
+
private persist;
|
|
76
|
+
/** Revoke one session by its sid. Idempotent. */
|
|
77
|
+
revokeSession(sid: string, opts?: {
|
|
78
|
+
reason?: string;
|
|
79
|
+
by?: string;
|
|
80
|
+
}): Promise<RevocationEntry>;
|
|
81
|
+
/** Revoke every session for a subject issued at or before now. */
|
|
82
|
+
revokeSubject(sub: string, opts?: {
|
|
83
|
+
reason?: string;
|
|
84
|
+
by?: string;
|
|
85
|
+
}): Promise<RevocationEntry>;
|
|
86
|
+
/**
|
|
87
|
+
* Decide whether a session is revoked. Pure + synchronous so it can run
|
|
88
|
+
* on the hot path of every request without an await.
|
|
89
|
+
*/
|
|
90
|
+
isRevoked(session: RevocableSession): boolean;
|
|
91
|
+
/** Snapshot of all entries, newest last (file order). */
|
|
92
|
+
list(): RevocationEntry[];
|
|
93
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session/JWT revocation blocklist for the management plane.
|
|
3
|
+
*
|
|
4
|
+
* OMCP sessions are stateless signed cookies (see ./session.ts) — there is
|
|
5
|
+
* no server-side session table to delete from, so a logout or a
|
|
6
|
+
* compromised-credential incident can't, on its own, invalidate an
|
|
7
|
+
* outstanding cookie before its `exp`. This blocklist closes that gap: a
|
|
8
|
+
* small append-only JSONL file of revocations that every request consults
|
|
9
|
+
* (in buildSessionAttacher) before a session payload is trusted.
|
|
10
|
+
*
|
|
11
|
+
* Two revocation shapes:
|
|
12
|
+
* - session: drop one specific session by its `sid`.
|
|
13
|
+
* - subject: drop every session for a `sub` issued at or before the
|
|
14
|
+
* revocation timestamp ("force re-login"). A fresh login afterwards
|
|
15
|
+
* mints a new session with a later `iat`, which is NOT caught — so
|
|
16
|
+
* subject-revoke is a logout-everywhere, not a permanent ban.
|
|
17
|
+
*
|
|
18
|
+
* Backend is an on-disk JSONL file (one entry per line, mode 0600). When
|
|
19
|
+
* no path is configured the store is in-memory only (lost on restart);
|
|
20
|
+
* set OMCP_AUTH_REVOCATION_FILE so revocations survive a restart.
|
|
21
|
+
*
|
|
22
|
+
* Multi-replica caveat: the file is read once at startup and the writing
|
|
23
|
+
* replica updates its own in-memory index immediately, but there is no
|
|
24
|
+
* live cross-replica propagation — a revocation issued on replica A is
|
|
25
|
+
* not seen by replica B until B restarts. A shared-store backend (Redis,
|
|
26
|
+
* mirroring the SCIM / transport stores) is the planned path to
|
|
27
|
+
* fleet-wide live propagation; see docs/access-control.md.
|
|
28
|
+
*/
|
|
29
|
+
import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
30
|
+
import { dirname } from "node:path";
|
|
31
|
+
function defaultNow() {
|
|
32
|
+
return Math.floor(Date.now() / 1000);
|
|
33
|
+
}
|
|
34
|
+
export class RevocationStore {
|
|
35
|
+
/** Revoked individual session ids. */
|
|
36
|
+
sids = new Set();
|
|
37
|
+
/** sub → latest subject-revocation cutoff (seconds since epoch). */
|
|
38
|
+
subjectCutoffs = new Map();
|
|
39
|
+
/** Full ordered history, kept so list() can mirror the file. */
|
|
40
|
+
entries = [];
|
|
41
|
+
path;
|
|
42
|
+
now;
|
|
43
|
+
/** Serialises appends so two concurrent revokes can't interleave a line. */
|
|
44
|
+
writeChain = Promise.resolve();
|
|
45
|
+
constructor(cfg) {
|
|
46
|
+
this.path = cfg.path;
|
|
47
|
+
this.now = cfg.now ?? defaultNow;
|
|
48
|
+
}
|
|
49
|
+
/** Build a store, loading any existing on-disk blocklist. */
|
|
50
|
+
static async create(cfg = {}) {
|
|
51
|
+
const store = new RevocationStore(cfg);
|
|
52
|
+
await store.load();
|
|
53
|
+
return store;
|
|
54
|
+
}
|
|
55
|
+
/** True when this store persists to disk. */
|
|
56
|
+
get persistent() {
|
|
57
|
+
return !!this.path;
|
|
58
|
+
}
|
|
59
|
+
get filePath() {
|
|
60
|
+
return this.path;
|
|
61
|
+
}
|
|
62
|
+
/** Number of revocation entries currently held. */
|
|
63
|
+
get size() {
|
|
64
|
+
return this.entries.length;
|
|
65
|
+
}
|
|
66
|
+
async load() {
|
|
67
|
+
if (!this.path)
|
|
68
|
+
return;
|
|
69
|
+
let raw;
|
|
70
|
+
try {
|
|
71
|
+
raw = await readFile(this.path, "utf8");
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
// ENOENT is expected on first boot — nothing to load.
|
|
75
|
+
if (err.code === "ENOENT")
|
|
76
|
+
return;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
for (const line of raw.split("\n")) {
|
|
80
|
+
const trimmed = line.trim();
|
|
81
|
+
if (!trimmed)
|
|
82
|
+
continue;
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(trimmed);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// A torn / hand-edited line shouldn't take the whole store down.
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const entry = coerceEntry(parsed);
|
|
92
|
+
if (entry)
|
|
93
|
+
this.index(entry);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Apply an entry to the in-memory indices + history (no write). */
|
|
97
|
+
index(entry) {
|
|
98
|
+
this.entries.push(entry);
|
|
99
|
+
if (entry.kind === "session") {
|
|
100
|
+
this.sids.add(entry.value);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const prev = this.subjectCutoffs.get(entry.value);
|
|
104
|
+
// Keep the latest cutoff so re-revoking a subject only widens the window.
|
|
105
|
+
if (prev === undefined || entry.revokedAt > prev) {
|
|
106
|
+
this.subjectCutoffs.set(entry.value, entry.revokedAt);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async persist(entry) {
|
|
111
|
+
if (!this.path)
|
|
112
|
+
return;
|
|
113
|
+
const path = this.path;
|
|
114
|
+
const line = JSON.stringify(entry) + "\n";
|
|
115
|
+
// Chain appends; ensure the parent dir + 0600 mode on the first write.
|
|
116
|
+
this.writeChain = this.writeChain.then(async () => {
|
|
117
|
+
try {
|
|
118
|
+
await appendFile(path, line, { mode: 0o600 });
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err.code === "ENOENT") {
|
|
122
|
+
await mkdir(dirname(path), { recursive: true });
|
|
123
|
+
await writeFile(path, line, { mode: 0o600 });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
throw err;
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
await this.writeChain;
|
|
130
|
+
}
|
|
131
|
+
/** Revoke one session by its sid. Idempotent. */
|
|
132
|
+
async revokeSession(sid, opts = {}) {
|
|
133
|
+
const entry = {
|
|
134
|
+
kind: "session",
|
|
135
|
+
value: sid,
|
|
136
|
+
revokedAt: this.now(),
|
|
137
|
+
...(opts.reason ? { reason: opts.reason } : {}),
|
|
138
|
+
...(opts.by ? { by: opts.by } : {}),
|
|
139
|
+
};
|
|
140
|
+
this.index(entry);
|
|
141
|
+
await this.persist(entry);
|
|
142
|
+
return entry;
|
|
143
|
+
}
|
|
144
|
+
/** Revoke every session for a subject issued at or before now. */
|
|
145
|
+
async revokeSubject(sub, opts = {}) {
|
|
146
|
+
const entry = {
|
|
147
|
+
kind: "subject",
|
|
148
|
+
value: sub,
|
|
149
|
+
revokedAt: this.now(),
|
|
150
|
+
...(opts.reason ? { reason: opts.reason } : {}),
|
|
151
|
+
...(opts.by ? { by: opts.by } : {}),
|
|
152
|
+
};
|
|
153
|
+
this.index(entry);
|
|
154
|
+
await this.persist(entry);
|
|
155
|
+
return entry;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Decide whether a session is revoked. Pure + synchronous so it can run
|
|
159
|
+
* on the hot path of every request without an await.
|
|
160
|
+
*/
|
|
161
|
+
isRevoked(session) {
|
|
162
|
+
if (session.sid && this.sids.has(session.sid))
|
|
163
|
+
return true;
|
|
164
|
+
const cutoff = this.subjectCutoffs.get(session.sub);
|
|
165
|
+
// `<=` so a session issued in the same second as the revocation is
|
|
166
|
+
// also caught — the operator's intent is "kill what exists now".
|
|
167
|
+
if (cutoff !== undefined && session.iat <= cutoff)
|
|
168
|
+
return true;
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
/** Snapshot of all entries, newest last (file order). */
|
|
172
|
+
list() {
|
|
173
|
+
return this.entries.map((e) => ({ ...e }));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Validate + normalise an untrusted parsed JSON line into an entry. */
|
|
177
|
+
function coerceEntry(v) {
|
|
178
|
+
if (!v || typeof v !== "object")
|
|
179
|
+
return null;
|
|
180
|
+
const o = v;
|
|
181
|
+
if (o.kind !== "session" && o.kind !== "subject")
|
|
182
|
+
return null;
|
|
183
|
+
if (typeof o.value !== "string" || !o.value)
|
|
184
|
+
return null;
|
|
185
|
+
if (typeof o.revokedAt !== "number" || !Number.isFinite(o.revokedAt))
|
|
186
|
+
return null;
|
|
187
|
+
const entry = { kind: o.kind, value: o.value, revokedAt: o.revokedAt };
|
|
188
|
+
if (typeof o.reason === "string")
|
|
189
|
+
entry.reason = o.reason;
|
|
190
|
+
if (typeof o.by === "string")
|
|
191
|
+
entry.by = o.by;
|
|
192
|
+
return entry;
|
|
193
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|