@sleep2agi/commhub-server 0.9.0-preview.107 → 0.9.0-preview.108
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/package.json +1 -1
- package/src/auth-weak-password-login.test.ts +176 -0
- package/src/auth.ts +15 -1
- package/src/create-node-validate.ts +1 -1
- package/src/tools.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.108",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// #649: a successful login with a weak password flags the account.
|
|
2
|
+
//
|
|
3
|
+
// Accounts created before #261 (2026-06-28) added `must_change_password` sit at 0 even when they
|
|
4
|
+
// still use the bootstrap default. Login is the one place the plaintext is in hand, so a login that
|
|
5
|
+
// succeeds with a password failing validatePasswordStrength (too short / in WEAK_PASSWORDS) sets the
|
|
6
|
+
// flag and returns `must_change_password: true`. Changing the password clears it.
|
|
7
|
+
//
|
|
8
|
+
// Every case goes through the real HTTP route (bootServer + fetch POST /api/auth/login), not the
|
|
9
|
+
// module function, so the response shape is the one clients see. The aggregate runner gives this
|
|
10
|
+
// file its own COMMHUB_DB / HOME / TMPDIR; the hub listens on port 0.
|
|
11
|
+
// Mutation: drop the new block in login() and the weak cases go red (flag stays 0, field absent).
|
|
12
|
+
|
|
13
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
14
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { tmpdir } from "os";
|
|
17
|
+
import { createHash } from "crypto";
|
|
18
|
+
import { register } from "./auth.js";
|
|
19
|
+
import { db, hashPassword } from "./db.js";
|
|
20
|
+
import { WEAK_PASSWORDS } from "./password-dict.js";
|
|
21
|
+
|
|
22
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-weakpw-"));
|
|
23
|
+
let BASE = "";
|
|
24
|
+
let hub: any = null;
|
|
25
|
+
const STRONG = "Str0ng-Fixture-Passw0rd!";
|
|
26
|
+
// Fixture plaintexts only (not anyone's real password).
|
|
27
|
+
const TOO_SHORT = "abc1234"; // 7 chars: fails the length rule (same shape as an old bootstrap default)
|
|
28
|
+
const IN_LIST = "password1"; // ≥ 8 chars but in WEAK_PASSWORDS
|
|
29
|
+
|
|
30
|
+
async function post(path: string, body: unknown, token = "") {
|
|
31
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
34
|
+
body: JSON.stringify(body),
|
|
35
|
+
});
|
|
36
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
37
|
+
}
|
|
38
|
+
const login = (username: string, password: string) => post("/api/auth/login", { username, password });
|
|
39
|
+
const flag = (userId: string) => db.get<any>("SELECT must_change_password AS f FROM users WHERE user_id = ?1", userId)?.f;
|
|
40
|
+
|
|
41
|
+
let seq = 0;
|
|
42
|
+
/** A user created the normal way (strong password), then rewound to an "old account" state:
|
|
43
|
+
* its stored hash replaced by `password` and the flag forced to 0. */
|
|
44
|
+
function oldAccount(password: string, opts: { legacySha256?: boolean } = {}) {
|
|
45
|
+
const username = `weakpw_${Date.now()}_${seq++}`;
|
|
46
|
+
const reg = register(username, STRONG, undefined, undefined, { issueTokens: false });
|
|
47
|
+
expect(reg.ok).toBe(true);
|
|
48
|
+
const userId = reg.user!.user_id;
|
|
49
|
+
const hash = opts.legacySha256 ? createHash("sha256").update(`anet:${password}`).digest("hex") : hashPassword(password); // legacy = pre-A1 `anet:`-prefixed sha256 (auth-kdf-migration.test.ts)
|
|
50
|
+
db.run("UPDATE users SET password_hash = ?1, must_change_password = 0 WHERE user_id = ?2", [hash, userId]);
|
|
51
|
+
expect(flag(userId)).toBe(0);
|
|
52
|
+
return { username, userId };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeAll(async () => {
|
|
56
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
57
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
58
|
+
process.env.HOST = "127.0.0.1";
|
|
59
|
+
process.env.COMMHUB_LOGIN_IP_MAX = "1000";
|
|
60
|
+
const mod: any = await import("./server.js");
|
|
61
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
62
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
63
|
+
}, 30_000);
|
|
64
|
+
|
|
65
|
+
afterAll(() => {
|
|
66
|
+
try { hub?.stop?.(true); } catch {}
|
|
67
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("fixtures are what they claim", () => {
|
|
71
|
+
test("TOO_SHORT is < 8 chars and IN_LIST is >= 8 chars and in WEAK_PASSWORDS; STRONG is neither", () => {
|
|
72
|
+
expect(TOO_SHORT.length).toBeLessThan(8);
|
|
73
|
+
expect(IN_LIST.length).toBeGreaterThanOrEqual(8);
|
|
74
|
+
expect(WEAK_PASSWORDS.has(IN_LIST)).toBe(true);
|
|
75
|
+
expect(STRONG.length).toBeGreaterThanOrEqual(8);
|
|
76
|
+
expect(WEAK_PASSWORDS.has(STRONG.toLowerCase())).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("successful login with a weak password sets must_change_password", () => {
|
|
81
|
+
test("too-short password, flag forced to 0 → login → flag 1 and response says must_change_password: true", async () => {
|
|
82
|
+
const u = oldAccount(TOO_SHORT);
|
|
83
|
+
const r = await login(u.username, TOO_SHORT);
|
|
84
|
+
expect(r.status).toBe(200);
|
|
85
|
+
expect(r.body.ok).toBe(true);
|
|
86
|
+
expect(r.body.must_change_password).toBe(true);
|
|
87
|
+
expect(flag(u.userId)).toBe(1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("password in WEAK_PASSWORDS (length OK) → flagged", async () => {
|
|
91
|
+
const u = oldAccount(IN_LIST);
|
|
92
|
+
const r = await login(u.username, IN_LIST);
|
|
93
|
+
expect(r.status).toBe(200);
|
|
94
|
+
expect(r.body.must_change_password).toBe(true);
|
|
95
|
+
expect(flag(u.userId)).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("weak-list match is case-insensitive like validatePasswordStrength", async () => {
|
|
99
|
+
const upper = IN_LIST.toUpperCase();
|
|
100
|
+
const u = oldAccount(upper);
|
|
101
|
+
const r = await login(u.username, upper);
|
|
102
|
+
expect(r.status).toBe(200);
|
|
103
|
+
expect(r.body.must_change_password).toBe(true);
|
|
104
|
+
expect(flag(u.userId)).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("legacy sha256 hash + weak password → login succeeds, hash upgraded, flag set", async () => {
|
|
108
|
+
const u = oldAccount(TOO_SHORT, { legacySha256: true });
|
|
109
|
+
const r = await login(u.username, TOO_SHORT);
|
|
110
|
+
expect(r.status).toBe(200);
|
|
111
|
+
expect(r.body.must_change_password).toBe(true);
|
|
112
|
+
expect(flag(u.userId)).toBe(1);
|
|
113
|
+
const stored = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", u.userId).password_hash;
|
|
114
|
+
expect(stored.startsWith("scrypt$")).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("no flag where it does not belong", () => {
|
|
119
|
+
test("strong password → flag stays 0 and the field is absent", async () => {
|
|
120
|
+
const u = oldAccount(STRONG);
|
|
121
|
+
const r = await login(u.username, STRONG);
|
|
122
|
+
expect(r.status).toBe(200);
|
|
123
|
+
expect(r.body.ok).toBe(true);
|
|
124
|
+
expect("must_change_password" in r.body).toBe(false);
|
|
125
|
+
expect(flag(u.userId)).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("failed login against a weak-password account does not touch the flag", async () => {
|
|
129
|
+
const u = oldAccount(TOO_SHORT);
|
|
130
|
+
const r = await login(u.username, "wrong-password-xyz");
|
|
131
|
+
expect(r.status).toBe(401);
|
|
132
|
+
expect(r.body.must_change_password).toBeUndefined();
|
|
133
|
+
expect(flag(u.userId)).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("an already-flagged account with a strong password still reports the flag (#261 behaviour kept)", async () => {
|
|
137
|
+
const u = oldAccount(STRONG);
|
|
138
|
+
db.run("UPDATE users SET must_change_password = 1 WHERE user_id = ?1", [u.userId]);
|
|
139
|
+
const r = await login(u.username, STRONG);
|
|
140
|
+
expect(r.body.must_change_password).toBe(true);
|
|
141
|
+
expect(flag(u.userId)).toBe(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("changing the password clears the flag", () => {
|
|
146
|
+
test("weak login flags → POST /api/auth/password to a strong one → flag 0 → next login has no field", async () => {
|
|
147
|
+
const u = oldAccount(TOO_SHORT);
|
|
148
|
+
const first = await login(u.username, TOO_SHORT);
|
|
149
|
+
expect(first.body.must_change_password).toBe(true);
|
|
150
|
+
const changed = await post("/api/auth/password", { old_password: TOO_SHORT, new_password: STRONG }, first.body.token);
|
|
151
|
+
expect(changed.status).toBe(200);
|
|
152
|
+
expect(changed.body.ok).toBe(true);
|
|
153
|
+
expect(flag(u.userId)).toBe(0);
|
|
154
|
+
const again = await login(u.username, STRONG);
|
|
155
|
+
expect(again.status).toBe(200);
|
|
156
|
+
expect("must_change_password" in again.body).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("the plaintext is never logged", () => {
|
|
161
|
+
test("console output during a weak-password login does not contain the password", async () => {
|
|
162
|
+
const u = oldAccount(IN_LIST);
|
|
163
|
+
const seen: string[] = [];
|
|
164
|
+
const orig = { log: console.log, warn: console.warn, error: console.error, info: console.info };
|
|
165
|
+
for (const k of Object.keys(orig) as (keyof typeof orig)[]) {
|
|
166
|
+
(console as any)[k] = (...a: unknown[]) => { seen.push(a.map(String).join(" ")); };
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
const r = await login(u.username, IN_LIST);
|
|
170
|
+
expect(r.body.must_change_password).toBe(true);
|
|
171
|
+
} finally {
|
|
172
|
+
Object.assign(console, orig);
|
|
173
|
+
}
|
|
174
|
+
expect(seen.some(l => l.includes(IN_LIST))).toBe(false);
|
|
175
|
+
});
|
|
176
|
+
});
|
package/src/auth.ts
CHANGED
|
@@ -196,6 +196,20 @@ export function login(username: string, password: string, client: SessionClientI
|
|
|
196
196
|
}
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
// #649: a successful login whose plaintext fails the same strength rule as
|
|
200
|
+
// register/changePassword (too short, or in WEAK_PASSWORDS) flags the account.
|
|
201
|
+
// Catches accounts created before #261 added must_change_password (they sit at
|
|
202
|
+
// 0 with the bootstrap default). changePassword clears it. Never log the password.
|
|
203
|
+
let mustChangePassword = user.must_change_password === 1;
|
|
204
|
+
if (!mustChangePassword && validatePasswordStrength(password) !== null) {
|
|
205
|
+
mustChangePassword = true;
|
|
206
|
+
try {
|
|
207
|
+
db.run("UPDATE users SET must_change_password = 1 WHERE user_id = ?1", [user.user_id]);
|
|
208
|
+
} catch (e: any) {
|
|
209
|
+
console.log(`[commhub auth] weak-password flag write failed for user ${user.user_id}: ${e?.message ?? e}`);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
199
213
|
// Issue a NEW user token — do NOT rotate/invalidate existing ones. Each
|
|
200
214
|
// login (cli, dashboard, second machine) gets its own row so they don't
|
|
201
215
|
// kick each other out of session. Tokens can be revoked via /api/auth/tokens.
|
|
@@ -223,7 +237,7 @@ export function login(username: string, password: string, client: SessionClientI
|
|
|
223
237
|
network_id: networkId,
|
|
224
238
|
// #261 P0-2 — only include field when truthy (back-compat; old clients
|
|
225
239
|
// don't see this field at all unless their account is flagged).
|
|
226
|
-
...(
|
|
240
|
+
...(mustChangePassword ? { must_change_password: true } : {}),
|
|
227
241
|
};
|
|
228
242
|
}
|
|
229
243
|
|
|
@@ -231,7 +231,7 @@ export function validateEnvRefs(
|
|
|
231
231
|
return out;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
-
// §4.4.7 — safe serializer for .env.
|
|
234
|
+
// §4.4.7 — safe serializer for the node secrets.env file. Dotenv-quoted with escape
|
|
235
235
|
// for backslash / double-quote / newline / carriage-return so that a
|
|
236
236
|
// value containing `\n"evil=KEY2"` cannot pollute the next line.
|
|
237
237
|
export function serializeEnvLocal(env: Record<string, string>): string {
|
package/src/tools.ts
CHANGED
|
@@ -5889,7 +5889,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
|
|
|
5889
5889
|
vendor: blob.vendor,
|
|
5890
5890
|
base_url: blob.base_url,
|
|
5891
5891
|
model_name: blob.model_name,
|
|
5892
|
-
api_key: blob.api_key, // ephemeral; daemon writes to .env
|
|
5892
|
+
api_key: blob.api_key, // ephemeral; daemon writes to secrets.env or in-memory only
|
|
5893
5893
|
}) }] };
|
|
5894
5894
|
},
|
|
5895
5895
|
);
|