@openparachute/hub 0.5.2 → 0.5.7

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.5.2",
3
+ "version": "0.5.7",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -13,6 +13,7 @@ import {
13
13
  import { CSRF_COOKIE_NAME, CSRF_FIELD_NAME } from "../csrf.ts";
14
14
  import { hubDbPath, openHubDb } from "../hub-db.ts";
15
15
  import type { ConfigSchema, ModuleManifest } from "../module-manifest.ts";
16
+ import { __resetForTests as resetRateLimit } from "../rate-limit.ts";
16
17
  import type { ServicesManifest } from "../services-manifest.ts";
17
18
  import { SESSION_TTL_MS, buildSessionCookie, createSession, findSession } from "../sessions.ts";
18
19
  import { createUser } from "../users.ts";
@@ -106,6 +107,10 @@ function fakeReadManifest(installDir: string): Promise<ModuleManifest | null> {
106
107
  let harness: Harness;
107
108
  beforeEach(() => {
108
109
  harness = makeHarness();
110
+ // Per-test rate-limit state — login tests share the UNKNOWN_IP sentinel
111
+ // bucket since they don't set CF-Connecting-IP, so without a reset the
112
+ // 6th test in this file would 429 spuriously.
113
+ resetRateLimit();
109
114
  });
110
115
  afterEach(() => {
111
116
  harness.cleanup();
@@ -222,6 +227,93 @@ describe("handleAdminLoginPost", () => {
222
227
  expect(res.status).toBe(302);
223
228
  expect(res.headers.get("location")).toBe("/admin/config");
224
229
  });
230
+
231
+ // hub#185 — per-IP rate-limit (5 attempts / 15 min) on POST /admin/login.
232
+ test("6 rapid POSTs from same IP get 200/401×4/429 and the 429 carries Retry-After", async () => {
233
+ await createUser(harness.db, "admin", "correct-pw");
234
+ const buildReq = (password: string) => {
235
+ const { body, headers } = formBody({
236
+ [CSRF_FIELD_NAME]: TEST_CSRF,
237
+ username: "admin",
238
+ password,
239
+ next: "/admin/config",
240
+ });
241
+ return new Request("http://hub.test/admin/login", {
242
+ method: "POST",
243
+ headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": "203.0.113.42" },
244
+ body,
245
+ });
246
+ };
247
+ // First attempt: correct password → 302. Counts as attempt #1.
248
+ const first = await handleAdminLoginPost(harness.db, buildReq("correct-pw"));
249
+ expect(first.status).toBe(302);
250
+ // Attempts 2–5: wrong password → 401 each.
251
+ for (let i = 2; i <= 5; i++) {
252
+ const r = await handleAdminLoginPost(harness.db, buildReq("wrong"));
253
+ expect(r.status).toBe(401);
254
+ }
255
+ // Attempt 6: rate-limit fires before credential check → 429 + Retry-After.
256
+ const denied = await handleAdminLoginPost(harness.db, buildReq("wrong"));
257
+ expect(denied.status).toBe(429);
258
+ const retryAfter = denied.headers.get("retry-after");
259
+ expect(retryAfter).not.toBeNull();
260
+ const seconds = Number(retryAfter);
261
+ expect(seconds).toBeGreaterThan(0);
262
+ // Window is 15 min = 900s, so retry-after sits in (0, 900].
263
+ expect(seconds).toBeLessThanOrEqual(900);
264
+ });
265
+
266
+ test("rate-limit is per-IP: a different IP can still log in after another's bucket fills", async () => {
267
+ await createUser(harness.db, "admin", "pw");
268
+ const buildReq = (ip: string, password: string) => {
269
+ const { body, headers } = formBody({
270
+ [CSRF_FIELD_NAME]: TEST_CSRF,
271
+ username: "admin",
272
+ password,
273
+ next: "/admin/config",
274
+ });
275
+ return new Request("http://hub.test/admin/login", {
276
+ method: "POST",
277
+ headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": ip },
278
+ body,
279
+ });
280
+ };
281
+ // Exhaust ip-a's bucket with 5 wrong-password attempts, then confirm 429.
282
+ for (let i = 0; i < 5; i++) {
283
+ await handleAdminLoginPost(harness.db, buildReq("203.0.113.7", "wrong"));
284
+ }
285
+ const aDenied = await handleAdminLoginPost(harness.db, buildReq("203.0.113.7", "wrong"));
286
+ expect(aDenied.status).toBe(429);
287
+ // Different IP: fresh bucket, correct credentials → 302.
288
+ const bOk = await handleAdminLoginPost(harness.db, buildReq("198.51.100.99", "pw"));
289
+ expect(bOk.status).toBe(302);
290
+ });
291
+
292
+ test("rate-limit fires before credential check (denied request never touches DB)", async () => {
293
+ // No user exists in the harness DB. First 5 attempts should be 401
294
+ // ("Invalid credentials" — no such user). 6th should be 429 with the
295
+ // rate-limit body, NOT a credential-failure body.
296
+ const buildReq = () => {
297
+ const { body, headers } = formBody({
298
+ [CSRF_FIELD_NAME]: TEST_CSRF,
299
+ username: "ghost",
300
+ password: "x",
301
+ next: "/admin/config",
302
+ });
303
+ return new Request("http://hub.test/admin/login", {
304
+ method: "POST",
305
+ headers: { ...headers, cookie: CSRF_COOKIE, "cf-connecting-ip": "203.0.113.99" },
306
+ body,
307
+ });
308
+ };
309
+ for (let i = 0; i < 5; i++) {
310
+ const r = await handleAdminLoginPost(harness.db, buildReq());
311
+ expect(r.status).toBe(401);
312
+ }
313
+ const denied = await handleAdminLoginPost(harness.db, buildReq());
314
+ expect(denied.status).toBe(429);
315
+ expect(await denied.text()).toContain("Too many login attempts");
316
+ });
225
317
  });
226
318
 
227
319
  describe("handleAdminLogoutPost (#113)", () => {
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { is2FAEnrolled, printPublic2FAWarning } from "../commands/expose-2fa-warning.ts";
6
+ import type { VaultAuthStatus } from "../vault/auth-status.ts";
7
+
8
+ function status(partial: Partial<VaultAuthStatus> = {}): VaultAuthStatus {
9
+ return {
10
+ hasOwnerPassword: false,
11
+ hasTotp: false,
12
+ tokenCount: 0,
13
+ vaultNames: [],
14
+ ...partial,
15
+ };
16
+ }
17
+
18
+ describe("is2FAEnrolled", () => {
19
+ test("returns true when status carries hasTotp: true", () => {
20
+ expect(is2FAEnrolled({ status: status({ hasTotp: true }) })).toBe(true);
21
+ });
22
+
23
+ test("returns false when status carries hasTotp: false", () => {
24
+ expect(is2FAEnrolled({ status: status({ hasTotp: false }) })).toBe(false);
25
+ });
26
+
27
+ test("reads totp_secret from vaultHome's config.yaml when status not supplied", () => {
28
+ const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
29
+ try {
30
+ writeFileSync(join(dir, "config.yaml"), 'totp_secret: "JBSWY3DPEHPK3PXP"\n');
31
+ expect(is2FAEnrolled({ vaultHome: dir })).toBe(true);
32
+ } finally {
33
+ rmSync(dir, { recursive: true, force: true });
34
+ }
35
+ });
36
+
37
+ test("missing config.yaml → not enrolled (false)", () => {
38
+ const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
39
+ try {
40
+ // No config.yaml written.
41
+ expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
42
+ } finally {
43
+ rmSync(dir, { recursive: true, force: true });
44
+ }
45
+ });
46
+
47
+ test("empty totp_secret value → not enrolled (matches vault's readGlobalConfig)", () => {
48
+ const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
49
+ try {
50
+ writeFileSync(join(dir, "config.yaml"), 'totp_secret: ""\n');
51
+ expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
52
+ } finally {
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test("malformed config.yaml → not enrolled (safer fail mode, fires warning)", () => {
58
+ // The probe parses config.yaml with a line-anchored regex (no YAML
59
+ // dependency), so junk content simply doesn't match `totp_secret: "..."`
60
+ // and resolves to `hasTotp: false` — which fires the public-exposure
61
+ // warning rather than silently suppressing it. Pin that contract so a
62
+ // future refactor of auth-status.ts can't quietly invert it.
63
+ const dir = mkdtempSync(join(tmpdir(), "pcli-2fa-warn-"));
64
+ try {
65
+ writeFileSync(join(dir, "config.yaml"), "totp_secret: [unbalanced\n ::: not yaml\n");
66
+ expect(is2FAEnrolled({ vaultHome: dir })).toBe(false);
67
+ } finally {
68
+ rmSync(dir, { recursive: true, force: true });
69
+ }
70
+ });
71
+ });
72
+
73
+ describe("printPublic2FAWarning", () => {
74
+ test("not enrolled → fires warning, returns true", () => {
75
+ const logs: string[] = [];
76
+ const fired = printPublic2FAWarning({
77
+ status: status({ hasTotp: false }),
78
+ log: (l) => logs.push(l),
79
+ publicUrl: "https://vault.example.com",
80
+ });
81
+ expect(fired).toBe(true);
82
+ const joined = logs.join("\n");
83
+ expect(joined).toContain("2FA is not enrolled");
84
+ expect(joined).toContain("https://vault.example.com/admin/login");
85
+ expect(joined).toContain("parachute auth 2fa enroll");
86
+ });
87
+
88
+ test("enrolled → suppressed, returns false, logs nothing", () => {
89
+ const logs: string[] = [];
90
+ const fired = printPublic2FAWarning({
91
+ status: status({ hasTotp: true }),
92
+ log: (l) => logs.push(l),
93
+ publicUrl: "https://vault.example.com",
94
+ });
95
+ expect(fired).toBe(false);
96
+ expect(logs).toEqual([]);
97
+ });
98
+
99
+ test("password-also-missing case still fires (warning is layer-independent of password state)", () => {
100
+ // The wide-open state (no password, no 2FA) hits this branch too — the
101
+ // hub's own `printAuthGuidance` (cloudflare) and `runAuthPreflight`
102
+ // (interactive wizard) cover the password remediation; this warning is
103
+ // strictly about 2FA.
104
+ const logs: string[] = [];
105
+ const fired = printPublic2FAWarning({
106
+ status: status({ hasOwnerPassword: false, hasTotp: false }),
107
+ log: (l) => logs.push(l),
108
+ publicUrl: "https://vault.example.com",
109
+ });
110
+ expect(fired).toBe(true);
111
+ expect(logs.some((l) => l.includes("2FA is not enrolled"))).toBe(true);
112
+ });
113
+
114
+ test("embeds the supplied publicUrl into the /admin/login pointer", () => {
115
+ const logs: string[] = [];
116
+ printPublic2FAWarning({
117
+ status: status({ hasTotp: false }),
118
+ log: (l) => logs.push(l),
119
+ publicUrl: "https://parachute.taildf9ce2.ts.net",
120
+ });
121
+ expect(logs.some((l) => l.includes("https://parachute.taildf9ce2.ts.net/admin/login"))).toBe(
122
+ true,
123
+ );
124
+ });
125
+ });
@@ -511,6 +511,107 @@ describe("exposeCloudflareUp", () => {
511
511
  env.cleanup();
512
512
  }
513
513
  });
514
+
515
+ // 2FA-enrollment warning (#186). The cloudflare path is always public —
516
+ // every successful bringup makes /admin/login reachable on the open
517
+ // internet, where 2FA is the primary defense beyond #188's rate-limit floor.
518
+ describe("2FA-enrollment warning", () => {
519
+ test("not enrolled → warning fires after the success block", async () => {
520
+ const env = makeEnv();
521
+ try {
522
+ const uuid = "cccccccc-0000-0000-0000-000000000003";
523
+ const { runner } = queueRunner([
524
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
525
+ { code: 0, stdout: "[]", stderr: "" },
526
+ {
527
+ code: 0,
528
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
529
+ stderr: "",
530
+ },
531
+ { code: 0, stdout: "", stderr: "" },
532
+ ]);
533
+ const { spawner } = fakeSpawner(42100);
534
+ const logs: string[] = [];
535
+
536
+ const code = await exposeCloudflareUp("vault.example.com", {
537
+ runner,
538
+ spawner,
539
+ alive: () => false,
540
+ kill: () => {},
541
+ log: (l) => logs.push(l),
542
+ manifestPath: env.manifestPath,
543
+ statePath: env.statePath,
544
+ configPath: env.configPath,
545
+ logPath: env.logPath,
546
+ cloudflaredHome: env.cloudflaredHome,
547
+ // No password, no 2FA — fully wide open. The warning should still
548
+ // fire; password-recovery copy already lives in `printAuthGuidance`.
549
+ vaultAuthStatus: {
550
+ hasOwnerPassword: false,
551
+ hasTotp: false,
552
+ tokenCount: 0,
553
+ vaultNames: [],
554
+ },
555
+ });
556
+
557
+ expect(code).toBe(0);
558
+ const joined = logs.join("\n");
559
+ expect(joined).toContain("2FA is not enrolled");
560
+ expect(joined).toContain("https://vault.example.com/admin/login");
561
+ expect(joined).toContain("parachute auth 2fa enroll");
562
+ } finally {
563
+ env.cleanup();
564
+ }
565
+ });
566
+
567
+ test("enrolled → warning suppressed (no '2FA is not enrolled' line)", async () => {
568
+ const env = makeEnv();
569
+ try {
570
+ const uuid = "dddddddd-0000-0000-0000-000000000004";
571
+ const { runner } = queueRunner([
572
+ { code: 0, stdout: "cloudflared 2024.1.0\n", stderr: "" },
573
+ { code: 0, stdout: "[]", stderr: "" },
574
+ {
575
+ code: 0,
576
+ stdout: `Tunnel credentials written to ${env.cloudflaredHome}/${uuid}.json.\nCreated tunnel parachute with id ${uuid}\n`,
577
+ stderr: "",
578
+ },
579
+ { code: 0, stdout: "", stderr: "" },
580
+ ]);
581
+ const { spawner } = fakeSpawner(42101);
582
+ const logs: string[] = [];
583
+
584
+ const code = await exposeCloudflareUp("vault.example.com", {
585
+ runner,
586
+ spawner,
587
+ alive: () => false,
588
+ kill: () => {},
589
+ log: (l) => logs.push(l),
590
+ manifestPath: env.manifestPath,
591
+ statePath: env.statePath,
592
+ configPath: env.configPath,
593
+ logPath: env.logPath,
594
+ cloudflaredHome: env.cloudflaredHome,
595
+ vaultAuthStatus: {
596
+ hasOwnerPassword: true,
597
+ hasTotp: true,
598
+ tokenCount: 1,
599
+ vaultNames: ["default"],
600
+ },
601
+ });
602
+
603
+ expect(code).toBe(0);
604
+ const joined = logs.join("\n");
605
+ expect(joined).not.toContain("2FA is not enrolled");
606
+ // The existing `printAuthGuidance` 2FA-recommend bullet is unrelated
607
+ // to the new contextual warning and stays in place — assert it on a
608
+ // shape that doesn't collide with the warning text.
609
+ expect(joined).toContain("(recommended) TOTP + backup codes");
610
+ } finally {
611
+ env.cleanup();
612
+ }
613
+ });
614
+ });
514
615
  });
515
616
 
516
617
  describe("exposeCloudflareOff", () => {