@openparachute/hub 0.7.4-rc.8 → 0.7.4
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/__tests__/admin-auth.test.ts +128 -0
- package/src/__tests__/admin-clients.test.ts +103 -1
- package/src/__tests__/admin-handlers.test.ts +28 -0
- package/src/__tests__/admin-host-admin-token.test.ts +58 -1
- package/src/__tests__/admin-lock.test.ts +33 -1
- package/src/__tests__/admin-vaults.test.ts +52 -9
- package/src/__tests__/api-account-2fa.test.ts +453 -0
- package/src/__tests__/api-mint-token.test.ts +75 -0
- package/src/__tests__/api-modules.test.ts +143 -0
- package/src/__tests__/api-settings-root-redirect.test.ts +302 -0
- package/src/__tests__/auth.test.ts +336 -0
- package/src/__tests__/clients.test.ts +298 -0
- package/src/__tests__/cors.test.ts +138 -1
- package/src/__tests__/doctor.test.ts +755 -0
- package/src/__tests__/hub-command.test.ts +69 -2
- package/src/__tests__/hub-settings.test.ts +188 -0
- package/src/__tests__/jwt-sign.test.ts +27 -0
- package/src/__tests__/oauth-handlers.test.ts +276 -21
- package/src/__tests__/oauth-ui.test.ts +52 -0
- package/src/__tests__/scope-explanations.test.ts +20 -9
- package/src/__tests__/sessions.test.ts +80 -0
- package/src/__tests__/setup-gate.test.ts +111 -3
- package/src/__tests__/vault-remove.test.ts +40 -19
- package/src/__tests__/well-known.test.ts +37 -2
- package/src/account-setup.ts +2 -0
- package/src/admin-agent-grants.ts +16 -1
- package/src/admin-auth.ts +13 -4
- package/src/admin-clients.ts +66 -5
- package/src/admin-grants.ts +11 -2
- package/src/admin-handlers.ts +2 -0
- package/src/admin-host-admin-token.ts +24 -1
- package/src/admin-lock.ts +16 -0
- package/src/admin-vaults.ts +70 -15
- package/src/api-account-2fa.ts +395 -0
- package/src/api-admin-lock.ts +7 -0
- package/src/api-hub-upgrade.ts +14 -1
- package/src/api-hub.ts +10 -1
- package/src/api-invites.ts +18 -3
- package/src/api-me.ts +11 -2
- package/src/api-mint-token.ts +16 -1
- package/src/api-modules.ts +119 -1
- package/src/api-revoke-token.ts +14 -1
- package/src/api-settings-hub-origin.ts +14 -1
- package/src/api-settings-root-redirect.ts +201 -0
- package/src/api-tokens.ts +14 -1
- package/src/api-users.ts +15 -6
- package/src/api-vault-caps.ts +11 -2
- package/src/cli.ts +29 -0
- package/src/clients.ts +164 -0
- package/src/commands/auth.ts +263 -1
- package/src/commands/doctor.ts +1250 -0
- package/src/commands/hub.ts +102 -1
- package/src/commands/vault-remove.ts +16 -24
- package/src/cors.ts +7 -3
- package/src/help.ts +53 -0
- package/src/hub-db.ts +14 -0
- package/src/hub-server.ts +123 -19
- package/src/hub-settings.ts +163 -1
- package/src/jwt-sign.ts +25 -6
- package/src/oauth-handlers.ts +25 -5
- package/src/oauth-ui.ts +51 -0
- package/src/rate-limit.ts +28 -0
- package/src/scope-explanations.ts +23 -9
- package/src/sessions.ts +43 -2
- package/src/setup-wizard.ts +2 -0
- package/src/well-known.ts +10 -1
- package/web/ui/dist/assets/{index--728BX3j.css → index-BcC4U5gM.css} +1 -1
- package/web/ui/dist/assets/index-CVqK1cV5.js +61 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DZzX_Enf.js +0 -61
|
@@ -12,9 +12,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
12
12
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { join } from "node:path";
|
|
15
|
-
import { hub, hubSetOrigin, rewriteCaddyfileHost } from "../commands/hub.ts";
|
|
15
|
+
import { hub, hubSetOrigin, hubSetRootRedirect, rewriteCaddyfileHost } from "../commands/hub.ts";
|
|
16
16
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
17
|
-
import { getHubOrigin } from "../hub-settings.ts";
|
|
17
|
+
import { getHubOrigin, getRootRedirect } from "../hub-settings.ts";
|
|
18
18
|
import type { CommandResult } from "../tailscale/run.ts";
|
|
19
19
|
|
|
20
20
|
describe("parachute hub set-origin", () => {
|
|
@@ -431,3 +431,70 @@ describe("parachute hub set-origin — Caddy automation", () => {
|
|
|
431
431
|
expect(log.join("\n")).toContain("already points at old.example.com");
|
|
432
432
|
});
|
|
433
433
|
});
|
|
434
|
+
|
|
435
|
+
describe("parachute hub set-root-redirect", () => {
|
|
436
|
+
let dir: string;
|
|
437
|
+
let log: string[];
|
|
438
|
+
const collect = (line: string) => log.push(line);
|
|
439
|
+
|
|
440
|
+
beforeEach(() => {
|
|
441
|
+
dir = mkdtempSync(join(tmpdir(), "hub-set-root-redirect-"));
|
|
442
|
+
log = [];
|
|
443
|
+
});
|
|
444
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
445
|
+
|
|
446
|
+
/** Open the configDir's hub.db and read the persisted root_redirect. */
|
|
447
|
+
function persisted(): string | null {
|
|
448
|
+
const db = openHubDb(hubDbPath(dir));
|
|
449
|
+
try {
|
|
450
|
+
return getRootRedirect(db);
|
|
451
|
+
} finally {
|
|
452
|
+
db.close();
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
test("persists a safe same-origin path to hub_settings.root_redirect", async () => {
|
|
457
|
+
const code = await hubSetRootRedirect(["/surface/reading-room"], {
|
|
458
|
+
configDir: dir,
|
|
459
|
+
log: collect,
|
|
460
|
+
});
|
|
461
|
+
expect(code).toBe(0);
|
|
462
|
+
expect(persisted()).toBe("/surface/reading-room");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
test("--clear deletes the row", async () => {
|
|
466
|
+
await hubSetRootRedirect(["/surface/x"], { configDir: dir, log: collect });
|
|
467
|
+
const code = await hubSetRootRedirect(["--clear"], { configDir: dir, log: collect });
|
|
468
|
+
expect(code).toBe(0);
|
|
469
|
+
expect(persisted()).toBeNull();
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("rejects an open-redirect path without writing", async () => {
|
|
473
|
+
for (const bad of ["//evil.com", "https://evil.com", "/\\evil.com", "/"]) {
|
|
474
|
+
const code = await hubSetRootRedirect([bad], { configDir: dir, log: collect });
|
|
475
|
+
expect(code).toBe(1);
|
|
476
|
+
expect(persisted()).toBeNull();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("rejects a path with no leading slash without writing", async () => {
|
|
481
|
+
const code = await hubSetRootRedirect(["surface/x"], { configDir: dir, log: collect });
|
|
482
|
+
expect(code).toBe(1);
|
|
483
|
+
expect(persisted()).toBeNull();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("usage error (exit 1) when no path + no --clear", async () => {
|
|
487
|
+
const code = await hubSetRootRedirect([], { configDir: dir, log: collect });
|
|
488
|
+
expect(code).toBe(1);
|
|
489
|
+
expect(persisted()).toBeNull();
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("routed through the `hub` dispatcher", async () => {
|
|
493
|
+
const code = await hub(["set-root-redirect", "/surface/via-dispatcher"], {
|
|
494
|
+
configDir: dir,
|
|
495
|
+
log: collect,
|
|
496
|
+
});
|
|
497
|
+
expect(code).toBe(0);
|
|
498
|
+
expect(persisted()).toBe("/surface/via-dispatcher");
|
|
499
|
+
});
|
|
500
|
+
});
|
|
@@ -12,8 +12,10 @@ import { join } from "node:path";
|
|
|
12
12
|
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
13
13
|
import {
|
|
14
14
|
DEFAULT_MODULE_INSTALL_CHANNEL,
|
|
15
|
+
DEFAULT_ROOT_REDIRECT,
|
|
15
16
|
FIRST_CLIENT_AUTO_APPROVE_WINDOW_MS,
|
|
16
17
|
MODULE_INSTALL_CHANNELS,
|
|
18
|
+
PARACHUTE_HUB_ROOT_REDIRECT_ENV,
|
|
17
19
|
PARACHUTE_INSTALL_CHANNEL_ENV,
|
|
18
20
|
PARACHUTE_MODULE_CHANNEL_ENV,
|
|
19
21
|
SETUP_EXPOSE_MODES,
|
|
@@ -21,15 +23,20 @@ import {
|
|
|
21
23
|
deleteSetting,
|
|
22
24
|
getHubOrigin,
|
|
23
25
|
getModuleInstallChannel,
|
|
26
|
+
getRootRedirect,
|
|
24
27
|
getSetting,
|
|
25
28
|
isFirstClientAutoApproveWindowOpen,
|
|
26
29
|
isModuleInstallChannel,
|
|
27
30
|
isNotesRedirectDisabled,
|
|
31
|
+
isSafeRedirectPath,
|
|
28
32
|
isSetupExposeMode,
|
|
29
33
|
openFirstClientAutoApproveWindow,
|
|
34
|
+
resolveRootRedirect,
|
|
35
|
+
resolveRootRedirectDetailed,
|
|
30
36
|
setHubOrigin,
|
|
31
37
|
setModuleInstallChannel,
|
|
32
38
|
setNotesRedirectDisabled,
|
|
39
|
+
setRootRedirect,
|
|
33
40
|
setSetting,
|
|
34
41
|
} from "../hub-settings.ts";
|
|
35
42
|
|
|
@@ -613,3 +620,184 @@ describe("hub-settings — notes_redirect_disabled (parachute-app §16)", () =>
|
|
|
613
620
|
}
|
|
614
621
|
});
|
|
615
622
|
});
|
|
623
|
+
|
|
624
|
+
describe("hub-settings — isSafeRedirectPath (open-redirect guard)", () => {
|
|
625
|
+
test("accepts plain same-origin relative paths", () => {
|
|
626
|
+
expect(isSafeRedirectPath("/admin")).toBe(true);
|
|
627
|
+
expect(isSafeRedirectPath("/surface/reading-room")).toBe(true);
|
|
628
|
+
expect(isSafeRedirectPath("/vault/default/")).toBe(true);
|
|
629
|
+
// Query + fragment stay same-origin → allowed.
|
|
630
|
+
expect(isSafeRedirectPath("/surface/x?view=reading#top")).toBe(true);
|
|
631
|
+
// Deep paths with hyphens/dots/underscores (regression: a botched
|
|
632
|
+
// whitespace regex once rejected `-`).
|
|
633
|
+
expect(isSafeRedirectPath("/a-b_c.d/e")).toBe(true);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("rejects protocol-relative + backslash authority tricks", () => {
|
|
637
|
+
expect(isSafeRedirectPath("//evil.com")).toBe(false);
|
|
638
|
+
expect(isSafeRedirectPath("//evil.com/path")).toBe(false);
|
|
639
|
+
expect(isSafeRedirectPath("/\\evil.com")).toBe(false);
|
|
640
|
+
expect(isSafeRedirectPath("/\\\\evil.com")).toBe(false);
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
test("rejects absolute URLs + scheme payloads", () => {
|
|
644
|
+
expect(isSafeRedirectPath("https://evil.com")).toBe(false);
|
|
645
|
+
expect(isSafeRedirectPath("http://evil.com/x")).toBe(false);
|
|
646
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing the runtime guard with a hostile string
|
|
647
|
+
expect(isSafeRedirectPath("javascript:alert(1)" as any)).toBe(false);
|
|
648
|
+
expect(isSafeRedirectPath("data:text/html,<script>1</script>")).toBe(false);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("rejects values missing a leading slash", () => {
|
|
652
|
+
expect(isSafeRedirectPath("admin")).toBe(false);
|
|
653
|
+
expect(isSafeRedirectPath("evil.com")).toBe(false);
|
|
654
|
+
expect(isSafeRedirectPath("")).toBe(false);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("rejects pathname-`/` targets (would 302-loop the bare-`/` route)", () => {
|
|
658
|
+
expect(isSafeRedirectPath("/")).toBe(false);
|
|
659
|
+
expect(isSafeRedirectPath("/?next=x")).toBe(false);
|
|
660
|
+
expect(isSafeRedirectPath("/#frag")).toBe(false);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("rejects whitespace + control chars (header-injection / normalization)", () => {
|
|
664
|
+
expect(isSafeRedirectPath("/admin\r\nSet-Cookie: x=1")).toBe(false);
|
|
665
|
+
expect(isSafeRedirectPath("/ad min")).toBe(false);
|
|
666
|
+
expect(isSafeRedirectPath("/admin\t")).toBe(false);
|
|
667
|
+
expect(isSafeRedirectPath("/\tadmin")).toBe(false);
|
|
668
|
+
expect(isSafeRedirectPath("/admin ")).toBe(false);
|
|
669
|
+
// U+2028 line separator (stripped by some parsers) — built via charCode so
|
|
670
|
+
// the source file carries no irregular-whitespace literal.
|
|
671
|
+
expect(isSafeRedirectPath(`/admin${String.fromCharCode(0x2028)}x`)).toBe(false);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("rejects non-string inputs", () => {
|
|
675
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
676
|
+
expect(isSafeRedirectPath(null as any)).toBe(false);
|
|
677
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
678
|
+
expect(isSafeRedirectPath(undefined as any)).toBe(false);
|
|
679
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
680
|
+
expect(isSafeRedirectPath(42 as any)).toBe(false);
|
|
681
|
+
// biome-ignore lint/suspicious/noExplicitAny: exercising the runtime type guard
|
|
682
|
+
expect(isSafeRedirectPath({} as any)).toBe(false);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
describe("hub-settings — root_redirect storage + resolution", () => {
|
|
687
|
+
let dir: string;
|
|
688
|
+
beforeEach(() => {
|
|
689
|
+
dir = mkdtempSync(join(tmpdir(), "hub-settings-root-redirect-"));
|
|
690
|
+
});
|
|
691
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
692
|
+
|
|
693
|
+
// An empty env so the resolver's env layer is deterministic (the host's real
|
|
694
|
+
// PARACHUTE_HUB_ROOT_REDIRECT, if any, must not leak in).
|
|
695
|
+
const noEnv: NodeJS.ProcessEnv = {};
|
|
696
|
+
const silent = () => {};
|
|
697
|
+
|
|
698
|
+
test("getRootRedirect round-trips via setRootRedirect", () => {
|
|
699
|
+
const db = openHubDb(hubDbPath(dir));
|
|
700
|
+
try {
|
|
701
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
702
|
+
setRootRedirect(db, "/surface/reading-room");
|
|
703
|
+
expect(getRootRedirect(db)).toBe("/surface/reading-room");
|
|
704
|
+
} finally {
|
|
705
|
+
db.close();
|
|
706
|
+
}
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("setRootRedirect(null) / empty clears the row", () => {
|
|
710
|
+
const db = openHubDb(hubDbPath(dir));
|
|
711
|
+
try {
|
|
712
|
+
setRootRedirect(db, "/surface/x");
|
|
713
|
+
setRootRedirect(db, null);
|
|
714
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
715
|
+
setRootRedirect(db, "/surface/x");
|
|
716
|
+
setRootRedirect(db, "");
|
|
717
|
+
expect(getRootRedirect(db)).toBeNull();
|
|
718
|
+
} finally {
|
|
719
|
+
db.close();
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
test("resolves to /admin default when neither DB nor env is set", () => {
|
|
724
|
+
const db = openHubDb(hubDbPath(dir));
|
|
725
|
+
try {
|
|
726
|
+
expect(resolveRootRedirect(db, { env: noEnv })).toBe(DEFAULT_ROOT_REDIRECT);
|
|
727
|
+
expect(resolveRootRedirectDetailed(db, { env: noEnv })).toEqual({
|
|
728
|
+
value: "/admin",
|
|
729
|
+
source: "default",
|
|
730
|
+
});
|
|
731
|
+
} finally {
|
|
732
|
+
db.close();
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
test("env override applies when no DB row", () => {
|
|
737
|
+
const db = openHubDb(hubDbPath(dir));
|
|
738
|
+
try {
|
|
739
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
740
|
+
expect(resolveRootRedirectDetailed(db, { env })).toEqual({
|
|
741
|
+
value: "/surface/from-env",
|
|
742
|
+
source: "env",
|
|
743
|
+
});
|
|
744
|
+
} finally {
|
|
745
|
+
db.close();
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test("DB row overrides env (DB is tier-1)", () => {
|
|
750
|
+
const db = openHubDb(hubDbPath(dir));
|
|
751
|
+
try {
|
|
752
|
+
setRootRedirect(db, "/surface/from-db");
|
|
753
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
754
|
+
expect(resolveRootRedirectDetailed(db, { env })).toEqual({
|
|
755
|
+
value: "/surface/from-db",
|
|
756
|
+
source: "db",
|
|
757
|
+
});
|
|
758
|
+
} finally {
|
|
759
|
+
db.close();
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("an unsafe DB row is ignored → falls through to env", () => {
|
|
764
|
+
const db = openHubDb(hubDbPath(dir));
|
|
765
|
+
try {
|
|
766
|
+
// Simulate a hand-edited sqlite row that bypassed write-side validation.
|
|
767
|
+
setSetting(db, "root_redirect", "//evil.com");
|
|
768
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
769
|
+
expect(resolveRootRedirect(db, { env, warn: silent })).toBe("/surface/from-env");
|
|
770
|
+
} finally {
|
|
771
|
+
db.close();
|
|
772
|
+
}
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
test("an unsafe DB row with no env → falls all the way back to /admin", () => {
|
|
776
|
+
const db = openHubDb(hubDbPath(dir));
|
|
777
|
+
try {
|
|
778
|
+
setSetting(db, "root_redirect", "https://evil.com");
|
|
779
|
+
expect(resolveRootRedirect(db, { env: noEnv, warn: silent })).toBe("/admin");
|
|
780
|
+
} finally {
|
|
781
|
+
db.close();
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
test("an unsafe env value is ignored → falls back to /admin", () => {
|
|
786
|
+
const db = openHubDb(hubDbPath(dir));
|
|
787
|
+
try {
|
|
788
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "//evil.com" };
|
|
789
|
+
expect(resolveRootRedirectDetailed(db, { env, warn: silent })).toEqual({
|
|
790
|
+
value: "/admin",
|
|
791
|
+
source: "default",
|
|
792
|
+
});
|
|
793
|
+
} finally {
|
|
794
|
+
db.close();
|
|
795
|
+
}
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("a null db (no state) resolves from env / default only", () => {
|
|
799
|
+
expect(resolveRootRedirect(null, { env: noEnv })).toBe("/admin");
|
|
800
|
+
const env = { [PARACHUTE_HUB_ROOT_REDIRECT_ENV]: "/surface/from-env" };
|
|
801
|
+
expect(resolveRootRedirect(null, { env })).toBe("/surface/from-env");
|
|
802
|
+
});
|
|
803
|
+
});
|
|
@@ -370,6 +370,33 @@ describe("validateAccessToken", () => {
|
|
|
370
370
|
}
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
+
test("expectedIssuer accepts a SET — iss in the set validates, outside rejects", async () => {
|
|
374
|
+
const { db, cleanup } = makeDb();
|
|
375
|
+
try {
|
|
376
|
+
const { token } = await signAccessToken(db, {
|
|
377
|
+
sub: "u",
|
|
378
|
+
scopes: ["s"],
|
|
379
|
+
audience: "operator",
|
|
380
|
+
clientId: "c",
|
|
381
|
+
issuer: "https://tunnel.example",
|
|
382
|
+
});
|
|
383
|
+
// In-set (≠ first element) validates — additive membership on iss.
|
|
384
|
+
const { payload } = await validateAccessToken(db, token, [
|
|
385
|
+
"http://127.0.0.1:1939",
|
|
386
|
+
"https://tunnel.example",
|
|
387
|
+
]);
|
|
388
|
+
expect(payload.iss).toBe("https://tunnel.example");
|
|
389
|
+
// Outside the set rejects.
|
|
390
|
+
await expect(
|
|
391
|
+
validateAccessToken(db, token, ["http://127.0.0.1:1939", "https://other.example"]),
|
|
392
|
+
).rejects.toThrow();
|
|
393
|
+
// A single-element set that doesn't match also rejects (no widening).
|
|
394
|
+
await expect(validateAccessToken(db, token, ["http://127.0.0.1:1939"])).rejects.toThrow();
|
|
395
|
+
} finally {
|
|
396
|
+
cleanup();
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
373
400
|
test("verifies a token signed by a recently-retired key (rotation tolerance)", async () => {
|
|
374
401
|
const { db, cleanup } = makeDb();
|
|
375
402
|
try {
|