@openparachute/hub 0.5.2 → 0.5.9-rc.6

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.
Files changed (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -1,19 +1,24 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync, statSync } from "node:fs";
2
+ import { chmodSync, mkdtempSync, rmSync, statSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { hubDbPath, openHubDb } from "../hub-db.ts";
7
- import { validateAccessToken } from "../jwt-sign.ts";
7
+ import { signAccessToken, validateAccessToken } from "../jwt-sign.ts";
8
8
  import {
9
9
  OPERATOR_TOKEN_AUDIENCE,
10
+ OPERATOR_TOKEN_AUTO_ROTATE_THRESHOLD_SECONDS,
11
+ OPERATOR_TOKEN_CLIENT_ID,
10
12
  OPERATOR_TOKEN_FILENAME,
11
13
  OPERATOR_TOKEN_SCOPES,
14
+ OPERATOR_TOKEN_SCOPE_SETS,
15
+ OPERATOR_TOKEN_SCOPE_SET_CLAIM,
12
16
  OPERATOR_TOKEN_TTL_SECONDS,
13
17
  issueOperatorToken,
14
18
  mintOperatorToken,
15
19
  operatorTokenPath,
16
20
  readOperatorTokenFile,
21
+ useOperatorTokenWithAutoRotate,
17
22
  writeOperatorTokenFile,
18
23
  } from "../operator-token.ts";
19
24
  import { rotateSigningKey } from "../signing-keys.ts";
@@ -58,10 +63,19 @@ describe("mintOperatorToken", () => {
58
63
  }
59
64
  });
60
65
 
61
- test("scopes include hub:admin + parachute:host:admin + vault:admin + scribe:admin + channel:send", () => {
66
+ test("admin scope-set includes hub:admin + parachute:host:* + vault/scribe/channel admins (#213)", () => {
67
+ // OPERATOR_TOKEN_SCOPES === OPERATOR_TOKEN_SCOPE_SETS.admin (back-compat
68
+ // alias). The pre-#213 set was 5 scopes; #213 added the fine-grained
69
+ // parachute:host:install/start/expose/auth/vault scopes to the admin
70
+ // superset (admin is "everything", per the scope-set vocabulary).
62
71
  expect(OPERATOR_TOKEN_SCOPES).toEqual([
63
72
  "hub:admin",
64
73
  "parachute:host:admin",
74
+ "parachute:host:install",
75
+ "parachute:host:start",
76
+ "parachute:host:expose",
77
+ "parachute:host:auth",
78
+ "parachute:host:vault",
65
79
  "vault:admin",
66
80
  "scribe:admin",
67
81
  "channel:send",
@@ -138,3 +152,365 @@ describe("issueOperatorToken", () => {
138
152
  }
139
153
  });
140
154
  });
155
+
156
+ describe("operator token defaults (#213)", () => {
157
+ test("default lifetime is 90d (was 365d through 0.5.7)", () => {
158
+ expect(OPERATOR_TOKEN_TTL_SECONDS).toBe(90 * 24 * 60 * 60);
159
+ });
160
+
161
+ test("auto-rotate threshold is 7d", () => {
162
+ expect(OPERATOR_TOKEN_AUTO_ROTATE_THRESHOLD_SECONDS).toBe(7 * 24 * 60 * 60);
163
+ });
164
+ });
165
+
166
+ describe("mintOperatorToken scope-sets (#213)", () => {
167
+ test("default scope-set is admin and embeds the pa_scope_set claim", async () => {
168
+ const h = makeHarness();
169
+ try {
170
+ const db = openHubDb(hubDbPath(h.dir));
171
+ try {
172
+ rotateSigningKey(db);
173
+ const minted = await mintOperatorToken(db, "user-abc", { issuer: TEST_ISSUER });
174
+ expect(minted.scopeSet).toBe("admin");
175
+ const validated = await validateAccessToken(db, minted.token, TEST_ISSUER);
176
+ expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("admin");
177
+ expect(validated.payload.scope).toBe(OPERATOR_TOKEN_SCOPE_SETS.admin.join(" "));
178
+ } finally {
179
+ db.close();
180
+ }
181
+ } finally {
182
+ h.cleanup();
183
+ }
184
+ });
185
+
186
+ test("--scope-set=start mints with parachute:host:start only", async () => {
187
+ const h = makeHarness();
188
+ try {
189
+ const db = openHubDb(hubDbPath(h.dir));
190
+ try {
191
+ rotateSigningKey(db);
192
+ const minted = await mintOperatorToken(db, "user-abc", {
193
+ issuer: TEST_ISSUER,
194
+ scopeSet: "start",
195
+ });
196
+ expect(minted.scopeSet).toBe("start");
197
+ const validated = await validateAccessToken(db, minted.token, TEST_ISSUER);
198
+ expect(validated.payload.scope).toBe("parachute:host:start");
199
+ expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
200
+ } finally {
201
+ db.close();
202
+ }
203
+ } finally {
204
+ h.cleanup();
205
+ }
206
+ });
207
+
208
+ test("install scope-set carries vault:read for new-vault discovery", async () => {
209
+ const h = makeHarness();
210
+ try {
211
+ const db = openHubDb(hubDbPath(h.dir));
212
+ try {
213
+ rotateSigningKey(db);
214
+ const minted = await mintOperatorToken(db, "u", {
215
+ issuer: TEST_ISSUER,
216
+ scopeSet: "install",
217
+ });
218
+ const validated = await validateAccessToken(db, minted.token, TEST_ISSUER);
219
+ const scopes = String(validated.payload.scope ?? "").split(" ");
220
+ expect(scopes).toContain("parachute:host:install");
221
+ expect(scopes).toContain("vault:read");
222
+ } finally {
223
+ db.close();
224
+ }
225
+ } finally {
226
+ h.cleanup();
227
+ }
228
+ });
229
+
230
+ test("admin set is the superset of all narrow sets", () => {
231
+ const admin = new Set(OPERATOR_TOKEN_SCOPE_SETS.admin);
232
+ for (const setName of ["install", "start", "expose", "auth", "vault"] as const) {
233
+ for (const scope of OPERATOR_TOKEN_SCOPE_SETS[setName]) {
234
+ // vault:read is in `install` but not (directly) in admin — admin
235
+ // carries vault:admin which subsumes :read at the resource server.
236
+ if (scope === "vault:read") continue;
237
+ expect(admin.has(scope)).toBe(true);
238
+ }
239
+ }
240
+ });
241
+ });
242
+
243
+ describe("readOperatorTokenFile permission warning (#213)", () => {
244
+ test("does not warn when file is mode 0600", async () => {
245
+ const h = makeHarness();
246
+ const origErr = console.error;
247
+ let stderr = "";
248
+ console.error = (...a: unknown[]) => {
249
+ stderr += `${a.map(String).join(" ")}\n`;
250
+ };
251
+ try {
252
+ await writeOperatorTokenFile("token-abc", h.dir);
253
+ await readOperatorTokenFile(h.dir);
254
+ expect(stderr).toBe("");
255
+ } finally {
256
+ console.error = origErr;
257
+ h.cleanup();
258
+ }
259
+ });
260
+
261
+ test("warns (without failing) when file is world-readable", async () => {
262
+ const h = makeHarness();
263
+ const origErr = console.error;
264
+ let stderr = "";
265
+ console.error = (...a: unknown[]) => {
266
+ stderr += `${a.map(String).join(" ")}\n`;
267
+ };
268
+ try {
269
+ const path = await writeOperatorTokenFile("token-abc", h.dir);
270
+ chmodSync(path, 0o644);
271
+ const round = await readOperatorTokenFile(h.dir);
272
+ expect(round).toBe("token-abc");
273
+ expect(stderr).toContain("operator token file");
274
+ expect(stderr).toContain("0644");
275
+ expect(stderr).toContain("chmod 0600");
276
+ } finally {
277
+ console.error = origErr;
278
+ h.cleanup();
279
+ }
280
+ });
281
+ });
282
+
283
+ describe("useOperatorTokenWithAutoRotate (#213)", () => {
284
+ test("returns the token unchanged when remaining lifetime > threshold", async () => {
285
+ const h = makeHarness();
286
+ try {
287
+ const db = openHubDb(hubDbPath(h.dir));
288
+ try {
289
+ rotateSigningKey(db);
290
+ const issued = await issueOperatorToken(db, "user-abc", {
291
+ dir: h.dir,
292
+ issuer: TEST_ISSUER,
293
+ // Default 90d, fresh — well above threshold.
294
+ });
295
+ const used = await useOperatorTokenWithAutoRotate(db, {
296
+ configDir: h.dir,
297
+ issuer: TEST_ISSUER,
298
+ });
299
+ expect(used).not.toBeNull();
300
+ expect(used?.refreshed).toBe(false);
301
+ expect(used?.rotated).toBeUndefined();
302
+ expect(used?.token).toBe(issued.token);
303
+ } finally {
304
+ db.close();
305
+ }
306
+ } finally {
307
+ h.cleanup();
308
+ }
309
+ });
310
+
311
+ test("auto-rotates when within 7d of expiry, preserving scope-set", async () => {
312
+ const h = makeHarness();
313
+ try {
314
+ const db = openHubDb(hubDbPath(h.dir));
315
+ try {
316
+ rotateSigningKey(db);
317
+ // Mint with a 1-day TTL — well below the 7d threshold.
318
+ const original = await issueOperatorToken(db, "user-abc", {
319
+ dir: h.dir,
320
+ issuer: TEST_ISSUER,
321
+ scopeSet: "start",
322
+ ttlSeconds: 24 * 60 * 60,
323
+ });
324
+ expect(original.scopeSet).toBe("start");
325
+
326
+ const used = await useOperatorTokenWithAutoRotate(db, {
327
+ configDir: h.dir,
328
+ issuer: TEST_ISSUER,
329
+ });
330
+ expect(used).not.toBeNull();
331
+ expect(used?.refreshed).toBe(true);
332
+ expect(used?.rotated?.scopeSet).toBe("start");
333
+ // The on-disk token is now the rotated one.
334
+ const onDisk = await readOperatorTokenFile(h.dir);
335
+ expect(onDisk).toBe(used!.token);
336
+ expect(onDisk).not.toBe(original.token);
337
+ // The rotated token is still scope-set "start".
338
+ const validated = await validateAccessToken(db, used!.token, TEST_ISSUER);
339
+ expect(validated.payload[OPERATOR_TOKEN_SCOPE_SET_CLAIM]).toBe("start");
340
+ expect(validated.payload.scope).toBe("parachute:host:start");
341
+ } finally {
342
+ db.close();
343
+ }
344
+ } finally {
345
+ h.cleanup();
346
+ }
347
+ });
348
+
349
+ test("does NOT auto-rotate a non-operator-audience JWT stashed at the path (privilege guard)", async () => {
350
+ const h = makeHarness();
351
+ try {
352
+ const db = openHubDb(hubDbPath(h.dir));
353
+ try {
354
+ rotateSigningKey(db);
355
+ // Hand-sign a narrow JWT with aud=scribe (not "operator") and a
356
+ // 1-hour TTL. Even though it's within the rotation window, the
357
+ // helper must not silently upgrade it to a full operator token.
358
+ const signed = await signAccessToken(db, {
359
+ sub: "user-abc",
360
+ scopes: ["scribe:transcribe"],
361
+ audience: "scribe",
362
+ clientId: OPERATOR_TOKEN_CLIENT_ID,
363
+ issuer: TEST_ISSUER,
364
+ ttlSeconds: 3600,
365
+ });
366
+ await writeOperatorTokenFile(signed.token, h.dir);
367
+
368
+ const used = await useOperatorTokenWithAutoRotate(db, {
369
+ configDir: h.dir,
370
+ issuer: TEST_ISSUER,
371
+ });
372
+ expect(used).not.toBeNull();
373
+ expect(used?.refreshed).toBe(false);
374
+ expect(used?.rotated).toBeUndefined();
375
+ expect(used?.token).toBe(signed.token);
376
+ // On-disk file unchanged.
377
+ const onDisk = await readOperatorTokenFile(h.dir);
378
+ expect(onDisk).toBe(signed.token);
379
+ } finally {
380
+ db.close();
381
+ }
382
+ } finally {
383
+ h.cleanup();
384
+ }
385
+ });
386
+
387
+ test("returns null when no operator token file exists", async () => {
388
+ const h = makeHarness();
389
+ try {
390
+ const db = openHubDb(hubDbPath(h.dir));
391
+ try {
392
+ rotateSigningKey(db);
393
+ const used = await useOperatorTokenWithAutoRotate(db, {
394
+ configDir: h.dir,
395
+ issuer: TEST_ISSUER,
396
+ });
397
+ expect(used).toBeNull();
398
+ } finally {
399
+ db.close();
400
+ }
401
+ } finally {
402
+ h.cleanup();
403
+ }
404
+ });
405
+
406
+ test("validateAccessToken rejects a fully-expired token (jose enforces exp)", async () => {
407
+ const h = makeHarness();
408
+ try {
409
+ const db = openHubDb(hubDbPath(h.dir));
410
+ try {
411
+ rotateSigningKey(db);
412
+ // Mint a token that's already expired.
413
+ const expiredAt = new Date("2026-01-01T00:00:00Z");
414
+ const issued = await issueOperatorToken(db, "user-abc", {
415
+ dir: h.dir,
416
+ issuer: TEST_ISSUER,
417
+ ttlSeconds: 60,
418
+ now: () => expiredAt,
419
+ });
420
+ expect(issued.token.length).toBeGreaterThan(0);
421
+ await expect(
422
+ useOperatorTokenWithAutoRotate(db, { configDir: h.dir, issuer: TEST_ISSUER }),
423
+ ).rejects.toThrow();
424
+ } finally {
425
+ db.close();
426
+ }
427
+ } finally {
428
+ h.cleanup();
429
+ }
430
+ });
431
+ });
432
+
433
+ // closes #212 Phase 1 — operator-mint paths write to the unified token
434
+ // registry so they show up in the revocation list and admin UI alongside
435
+ // OAuth refresh tokens and CLI mints.
436
+ describe("mintOperatorToken registry write (#212)", () => {
437
+ test("writes a tokens row with created_via='operator_mint', subject='operator', user_id NULL", async () => {
438
+ const h = makeHarness();
439
+ try {
440
+ const db = openHubDb(hubDbPath(h.dir));
441
+ try {
442
+ rotateSigningKey(db);
443
+ const minted = await mintOperatorToken(db, "user-abc", {
444
+ issuer: TEST_ISSUER,
445
+ scopeSet: "start",
446
+ });
447
+ const row = db
448
+ .query<
449
+ {
450
+ jti: string;
451
+ user_id: string | null;
452
+ subject: string | null;
453
+ created_via: string;
454
+ scopes: string;
455
+ expires_at: string;
456
+ },
457
+ [string]
458
+ >(
459
+ "SELECT jti, user_id, subject, created_via, scopes, expires_at FROM tokens WHERE jti = ?",
460
+ )
461
+ .get(minted.jti);
462
+ expect(row).not.toBeNull();
463
+ expect(row?.user_id).toBeNull();
464
+ expect(row?.subject).toBe("operator");
465
+ expect(row?.created_via).toBe("operator_mint");
466
+ expect(row?.scopes).toBe("parachute:host:start");
467
+ expect(row?.expires_at).toBe(minted.expiresAt);
468
+ } finally {
469
+ db.close();
470
+ }
471
+ } finally {
472
+ h.cleanup();
473
+ }
474
+ });
475
+
476
+ test("auto-rotation writes a fresh registry row for the rotated token", async () => {
477
+ const h = makeHarness();
478
+ try {
479
+ const db = openHubDb(hubDbPath(h.dir));
480
+ try {
481
+ rotateSigningKey(db);
482
+ const original = await issueOperatorToken(db, "user-abc", {
483
+ dir: h.dir,
484
+ issuer: TEST_ISSUER,
485
+ ttlSeconds: 24 * 60 * 60, // within rotation window
486
+ });
487
+ const used = await useOperatorTokenWithAutoRotate(db, {
488
+ configDir: h.dir,
489
+ issuer: TEST_ISSUER,
490
+ });
491
+ expect(used?.refreshed).toBe(true);
492
+ // The rotated token has a new jti.
493
+ const newJti = used!.payload.jti as string;
494
+ expect(newJti).not.toBe(original.jti);
495
+ const row = db
496
+ .query<{ jti: string; created_via: string }, [string]>(
497
+ "SELECT jti, created_via FROM tokens WHERE jti = ?",
498
+ )
499
+ .get(newJti);
500
+ expect(row).not.toBeNull();
501
+ expect(row?.created_via).toBe("operator_mint");
502
+ // Both the original and the rotated row exist (the original isn't
503
+ // auto-revoked — it stays valid until its own exp). Phase 2 may add
504
+ // a "revoke prior on rotation" toggle; for now we keep both.
505
+ const origRow = db
506
+ .query<{ jti: string }, [string]>("SELECT jti FROM tokens WHERE jti = ?")
507
+ .get(original.jti);
508
+ expect(origRow).not.toBeNull();
509
+ } finally {
510
+ db.close();
511
+ }
512
+ } finally {
513
+ h.cleanup();
514
+ }
515
+ });
516
+ });
@@ -0,0 +1,220 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildHubBoundOrigins, isSameOriginRequest } from "../origin-check.ts";
3
+
4
+ const ISSUER = "https://parachute.taildf9ce2.ts.net";
5
+ const PORT = 1939;
6
+
7
+ function reqWithHeaders(headers: Record<string, string>): Request {
8
+ // Bun's Request constructor lower-cases header keys; tests pass whatever
9
+ // case is conventional in the wild. The URL is irrelevant — only the
10
+ // headers are inspected by isSameOriginRequest.
11
+ return new Request("http://placeholder/", { method: "POST", headers });
12
+ }
13
+
14
+ describe("buildHubBoundOrigins", () => {
15
+ test("issuer only — single-origin hub", () => {
16
+ expect(buildHubBoundOrigins({ issuer: ISSUER })).toEqual([ISSUER]);
17
+ });
18
+
19
+ test("issuer + loopback port adds localhost + 127.0.0.1 aliases", () => {
20
+ const origins = buildHubBoundOrigins({ issuer: ISSUER, loopbackPort: PORT });
21
+ expect(origins).toContain(ISSUER);
22
+ expect(origins).toContain(`http://localhost:${PORT}`);
23
+ expect(origins).toContain(`http://127.0.0.1:${PORT}`);
24
+ expect(origins.length).toBe(3);
25
+ });
26
+
27
+ test("exposeHubOrigin adds a tailnet/funnel origin when distinct from issuer", () => {
28
+ // Scenario: hub was started with --issuer http://localhost:1939 (dev),
29
+ // then `parachute expose tailnet` brought up the tailnet hostname.
30
+ // exposeHubOrigin captures the post-expose hostname.
31
+ const origins = buildHubBoundOrigins({
32
+ issuer: "http://localhost:1939",
33
+ loopbackPort: PORT,
34
+ exposeHubOrigin: ISSUER,
35
+ });
36
+ expect(origins).toContain("http://localhost:1939");
37
+ expect(origins).toContain(ISSUER);
38
+ });
39
+
40
+ test("dedups when exposeHubOrigin matches issuer", () => {
41
+ // Normal case: `parachute expose` set the issuer AND wrote the same
42
+ // hubOrigin to expose-state.json. The set should still be one entry
43
+ // for that origin, not two.
44
+ const origins = buildHubBoundOrigins({
45
+ issuer: ISSUER,
46
+ exposeHubOrigin: ISSUER,
47
+ });
48
+ expect(origins.filter((o) => o === ISSUER).length).toBe(1);
49
+ });
50
+
51
+ test("malformed inputs are silently dropped", () => {
52
+ // No URL parser crash — return whatever could be parsed. The caller
53
+ // (resolveBoundOrigins) keeps the issuer as a baseline anyway.
54
+ const origins = buildHubBoundOrigins({
55
+ issuer: ISSUER,
56
+ exposeHubOrigin: "not a url",
57
+ });
58
+ expect(origins).toContain(ISSUER);
59
+ expect(origins.length).toBe(1);
60
+ });
61
+
62
+ test("normalizes via URL.origin — trailing slash on issuer is stripped", () => {
63
+ const origins = buildHubBoundOrigins({ issuer: `${ISSUER}/` });
64
+ expect(origins).toEqual([ISSUER]); // URL.origin drops trailing slash
65
+ });
66
+
67
+ test("non-integer loopbackPort is ignored", () => {
68
+ // Belt for callers passing through a stringly-typed env var. We don't
69
+ // want to emit `http://localhost:NaN`.
70
+ const origins = buildHubBoundOrigins({
71
+ issuer: ISSUER,
72
+ loopbackPort: Number.NaN,
73
+ });
74
+ expect(origins.every((o) => !o.includes("NaN"))).toBe(true);
75
+ expect(origins).toContain(ISSUER);
76
+ });
77
+ });
78
+
79
+ describe("isSameOriginRequest", () => {
80
+ const BOUND = buildHubBoundOrigins({ issuer: ISSUER, loopbackPort: PORT });
81
+
82
+ describe("Origin header (primary)", () => {
83
+ test("accepts a request whose Origin matches the issuer", () => {
84
+ const req = reqWithHeaders({ origin: ISSUER });
85
+ expect(isSameOriginRequest(req, BOUND)).toBe(true);
86
+ });
87
+
88
+ test("accepts a request whose Origin matches loopback (localhost)", () => {
89
+ // Closes #245 Case A: operator on http://localhost:1939/login
90
+ // submitting the approve form — previously rejected because Origin
91
+ // (localhost) didn't match the configured issuer (tailnet).
92
+ const req = reqWithHeaders({ origin: `http://localhost:${PORT}` });
93
+ expect(isSameOriginRequest(req, BOUND)).toBe(true);
94
+ });
95
+
96
+ test("accepts a request whose Origin matches loopback (127.0.0.1)", () => {
97
+ const req = reqWithHeaders({ origin: `http://127.0.0.1:${PORT}` });
98
+ expect(isSameOriginRequest(req, BOUND)).toBe(true);
99
+ });
100
+
101
+ test("rejects a real third-party origin", () => {
102
+ const req = reqWithHeaders({ origin: "https://attacker.example" });
103
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
104
+ });
105
+
106
+ test("rejects a port-only mismatch", () => {
107
+ // A request from `http://localhost:1940` (different port) is NOT
108
+ // the hub — could be a different service on the same box. The
109
+ // bound set only includes the hub's own port.
110
+ const req = reqWithHeaders({ origin: "http://localhost:1940" });
111
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
112
+ });
113
+
114
+ test("rejects scheme mismatch (https://localhost vs http://localhost)", () => {
115
+ // The bound set has http://localhost:<port>; an https://localhost
116
+ // request shouldn't match. Less likely in practice (loopback is
117
+ // typically http) but the URL.origin comparison catches it.
118
+ const req = reqWithHeaders({ origin: `https://localhost:${PORT}` });
119
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
120
+ });
121
+
122
+ test("malformed Origin string returns false (does not throw)", () => {
123
+ const req = reqWithHeaders({ origin: "not a valid url" });
124
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
125
+ });
126
+ });
127
+
128
+ describe("Referer header (fallback when Origin is absent)", () => {
129
+ test("accepts when Referer matches a bound origin", () => {
130
+ const req = reqWithHeaders({ referer: `${ISSUER}/login` });
131
+ expect(isSameOriginRequest(req, BOUND)).toBe(true);
132
+ });
133
+
134
+ test("rejects when Referer is third-party", () => {
135
+ const req = reqWithHeaders({ referer: "https://attacker.example/page" });
136
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
137
+ });
138
+
139
+ test("Origin takes priority over Referer when both present", () => {
140
+ // If Origin says cross-origin, even a same-origin Referer doesn't
141
+ // rescue. Important: an attacker can sometimes spoof Referer (via
142
+ // a redirect chain) but cannot spoof Origin from a browser.
143
+ const req = reqWithHeaders({
144
+ origin: "https://attacker.example",
145
+ referer: ISSUER,
146
+ });
147
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
148
+ });
149
+ });
150
+
151
+ describe("Host header (last-resort fallback when Origin + Referer both stripped)", () => {
152
+ // Closes #245 Case B: Tailscale Serve stripped Origin/Referer from a
153
+ // legitimate same-origin POST, so neither primary nor secondary
154
+ // signal was available. Host header reflected the tailnet hostname
155
+ // the browser thought it was talking to.
156
+
157
+ test("accepts when Host matches a bound origin's host:port", () => {
158
+ const req = reqWithHeaders({
159
+ host: new URL(ISSUER).host,
160
+ });
161
+ expect(isSameOriginRequest(req, BOUND)).toBe(true);
162
+ });
163
+
164
+ test("accepts loopback Host match", () => {
165
+ const req = reqWithHeaders({ host: `localhost:${PORT}` });
166
+ expect(isSameOriginRequest(req, BOUND)).toBe(true);
167
+ });
168
+
169
+ test("rejects a third-party Host", () => {
170
+ const req = reqWithHeaders({ host: "attacker.example" });
171
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
172
+ });
173
+
174
+ test("Origin takes priority over Host — Host-only check fires only when Origin+Referer absent", () => {
175
+ // Same belt-and-suspenders order: Origin says no → reject, even if
176
+ // Host happens to match. Otherwise an attacker who could induce a
177
+ // cross-origin POST without browser Origin (rare but theoretical)
178
+ // could pass with a manipulated Host. Origin remains the primary.
179
+ const req = reqWithHeaders({
180
+ origin: "https://attacker.example",
181
+ host: new URL(ISSUER).host,
182
+ });
183
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
184
+ });
185
+
186
+ test("Referer takes priority over Host", () => {
187
+ const req = reqWithHeaders({
188
+ referer: "https://attacker.example/page",
189
+ host: new URL(ISSUER).host,
190
+ });
191
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
192
+ });
193
+ });
194
+
195
+ describe("no headers at all", () => {
196
+ test("rejects when Origin, Referer, AND Host are all absent", () => {
197
+ // Bun synthesizes a Host header from the URL, so we use new Headers()
198
+ // directly to clear it. The function's contract: with no signal, reject.
199
+ const req = new Request("http://placeholder/", {
200
+ method: "POST",
201
+ headers: new Headers(),
202
+ });
203
+ // Bun will still inject Host from the URL, so simulate the stripped
204
+ // case by passing an explicit empty Host. If Bun adds the URL host,
205
+ // the check returns true for matching placeholder — but our bound
206
+ // origins don't include placeholder, so we still return false.
207
+ expect(isSameOriginRequest(req, BOUND)).toBe(false);
208
+ });
209
+ });
210
+
211
+ describe("empty bound-origin set (defense fails closed)", () => {
212
+ test("returns false regardless of headers when no origins are bound", () => {
213
+ // Mis-wired hub (no issuer, no exposeState, no port) — the function
214
+ // should reject everything rather than accept everything. Fail-closed
215
+ // is the right default for a CSRF defense.
216
+ const req = reqWithHeaders({ origin: ISSUER });
217
+ expect(isSameOriginRequest(req, [])).toBe(false);
218
+ });
219
+ });
220
+ });