@openparachute/hub 0.6.3-rc.2 → 0.6.3-rc.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__/api-modules-ops.test.ts +121 -0
- package/src/__tests__/api-modules.test.ts +67 -0
- package/src/__tests__/host-admin-token-validation.test.ts +218 -0
- package/src/__tests__/managed-unit.test.ts +23 -3
- package/src/__tests__/migrate-cutover.test.ts +60 -1
- package/src/__tests__/migrate.test.ts +16 -0
- package/src/__tests__/operator-token.test.ts +277 -0
- package/src/__tests__/stale-module-units.test.ts +286 -0
- package/src/api-modules-ops.ts +28 -2
- package/src/api-modules.ts +25 -2
- package/src/cloudflare/connector-service.ts +13 -2
- package/src/commands/migrate-cutover.ts +48 -0
- package/src/host-admin-token-validation.ts +96 -0
- package/src/hub-server.ts +19 -3
- package/src/managed-unit.ts +24 -4
- package/src/operator-token.ts +96 -5
- package/src/origin-check.ts +10 -0
- package/src/stale-module-units.ts +374 -0
package/package.json
CHANGED
|
@@ -70,6 +70,36 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
|
|
|
70
70
|
return signed.token;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Mint a host-admin bearer at a chosen `iss` (hub#516). The CLI presents the
|
|
75
|
+
* operator token on loopback; after `expose` its `iss` is the public origin
|
|
76
|
+
* while the loopback request resolves the loopback issuer.
|
|
77
|
+
*/
|
|
78
|
+
async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
|
|
79
|
+
const signed = await signAccessToken(h.db, {
|
|
80
|
+
sub: h.userId,
|
|
81
|
+
scopes,
|
|
82
|
+
audience: "operator",
|
|
83
|
+
clientId: "parachute-hub",
|
|
84
|
+
issuer: iss,
|
|
85
|
+
ttlSeconds: 3600,
|
|
86
|
+
});
|
|
87
|
+
recordTokenMint(h.db, {
|
|
88
|
+
jti: signed.jti,
|
|
89
|
+
createdVia: "operator_mint",
|
|
90
|
+
subject: "operator",
|
|
91
|
+
clientId: "parachute-hub",
|
|
92
|
+
scopes,
|
|
93
|
+
expiresAt: signed.expiresAt,
|
|
94
|
+
});
|
|
95
|
+
return signed.token;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The hub's public origin after `expose` — what the operator token's `iss` becomes. */
|
|
99
|
+
const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
|
|
100
|
+
/** A foreign origin the hub never answers on. */
|
|
101
|
+
const FOREIGN_ORIGIN = "https://evil.example.com";
|
|
102
|
+
|
|
73
103
|
function postReq(path: string, headers: Record<string, string>): Request {
|
|
74
104
|
return new Request(`http://localhost${path}`, { method: "POST", headers });
|
|
75
105
|
}
|
|
@@ -967,6 +997,97 @@ describe("POST /api/modules/:short/stop", () => {
|
|
|
967
997
|
});
|
|
968
998
|
});
|
|
969
999
|
|
|
1000
|
+
/**
|
|
1001
|
+
* hub#516 — the module-ops `authorize` accepts the operator token's `iss`
|
|
1002
|
+
* against the SET of origins the hub answers on (knownIssuers), not just the
|
|
1003
|
+
* single per-request issuer. Exercised through `handleStop` (the simplest sync
|
|
1004
|
+
* op that goes through `authorize`). The per-request `issuer` here is loopback
|
|
1005
|
+
* (mirroring a loopback CLI request); `knownIssuers` carries loopback + the
|
|
1006
|
+
* public expose-state origin.
|
|
1007
|
+
*/
|
|
1008
|
+
describe("operator-token iss validation against knownIssuers (hub#516)", () => {
|
|
1009
|
+
let h: Harness;
|
|
1010
|
+
beforeEach(async () => {
|
|
1011
|
+
h = await makeHarness();
|
|
1012
|
+
_resetOperationsRegistryForTests();
|
|
1013
|
+
});
|
|
1014
|
+
afterEach(() => h.cleanup());
|
|
1015
|
+
|
|
1016
|
+
// The live repro: operator token's iss = PUBLIC origin, loopback request
|
|
1017
|
+
// (per-request issuer = loopback), knownIssuers includes the public origin
|
|
1018
|
+
// → ACCEPTED. Was rejected (`unexpected "iss" claim value`) pre-fix.
|
|
1019
|
+
test("live repro: public-iss operator token on a loopback request → ACCEPTED", async () => {
|
|
1020
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1021
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
1022
|
+
const res = await handleStop(
|
|
1023
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1024
|
+
"vault",
|
|
1025
|
+
{
|
|
1026
|
+
db: h.db,
|
|
1027
|
+
issuer: ISSUER, // loopback per-request issuer
|
|
1028
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
1029
|
+
manifestPath: h.manifestPath,
|
|
1030
|
+
configDir: h.dir,
|
|
1031
|
+
supervisor,
|
|
1032
|
+
},
|
|
1033
|
+
);
|
|
1034
|
+
// Not a 401 — authorize passed. (stopped:false because nothing's running.)
|
|
1035
|
+
expect(res.status).toBe(200);
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
test("loopback-iss operator token, loopback knownIssuers → accepted (unchanged)", async () => {
|
|
1039
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1040
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], ISSUER);
|
|
1041
|
+
const res = await handleStop(
|
|
1042
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1043
|
+
"vault",
|
|
1044
|
+
{
|
|
1045
|
+
db: h.db,
|
|
1046
|
+
issuer: ISSUER,
|
|
1047
|
+
knownIssuers: [ISSUER, "http://localhost:1939"],
|
|
1048
|
+
manifestPath: h.manifestPath,
|
|
1049
|
+
configDir: h.dir,
|
|
1050
|
+
supervisor,
|
|
1051
|
+
},
|
|
1052
|
+
);
|
|
1053
|
+
expect(res.status).toBe(200);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
test("FOREIGN-iss operator token → 401 (no widening to arbitrary issuers)", async () => {
|
|
1057
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1058
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], FOREIGN_ORIGIN);
|
|
1059
|
+
const res = await handleStop(
|
|
1060
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1061
|
+
"vault",
|
|
1062
|
+
{
|
|
1063
|
+
db: h.db,
|
|
1064
|
+
issuer: ISSUER,
|
|
1065
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
1066
|
+
manifestPath: h.manifestPath,
|
|
1067
|
+
configDir: h.dir,
|
|
1068
|
+
supervisor,
|
|
1069
|
+
},
|
|
1070
|
+
);
|
|
1071
|
+
expect(res.status).toBe(401);
|
|
1072
|
+
const body = (await res.json()) as { error_description: string };
|
|
1073
|
+
expect(body.error_description).toMatch(/unexpected "iss" claim value/);
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
test("knownIssuers absent → falls back to strict per-request issuer (back-compat)", async () => {
|
|
1077
|
+
const { supervisor } = makeIdleSupervisor();
|
|
1078
|
+
// Public-iss token, loopback per-request issuer, NO knownIssuers wired →
|
|
1079
|
+
// the strict single-issuer fallback rejects (the pre-fix behavior the
|
|
1080
|
+
// non-HTTP install path relies on).
|
|
1081
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_OPS_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
1082
|
+
const res = await handleStop(
|
|
1083
|
+
postReq("/api/modules/vault/stop", { authorization: `Bearer ${bearer}` }),
|
|
1084
|
+
"vault",
|
|
1085
|
+
{ db: h.db, issuer: ISSUER, manifestPath: h.manifestPath, configDir: h.dir, supervisor },
|
|
1086
|
+
);
|
|
1087
|
+
expect(res.status).toBe(401);
|
|
1088
|
+
});
|
|
1089
|
+
});
|
|
1090
|
+
|
|
970
1091
|
describe("POST /api/modules/:short/restart", () => {
|
|
971
1092
|
let h: Harness;
|
|
972
1093
|
beforeEach(async () => {
|
|
@@ -63,6 +63,32 @@ async function mintBearer(h: Harness, scopes: string[]): Promise<string> {
|
|
|
63
63
|
return signed.token;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** The hub's public origin after `expose` — what the operator token's `iss` becomes (hub#516). */
|
|
67
|
+
const PUBLIC_ORIGIN = "https://parachute.taildf9ce2.ts.net";
|
|
68
|
+
/** A foreign origin the hub never answers on (hub#516). */
|
|
69
|
+
const FOREIGN_ORIGIN = "https://evil.example.com";
|
|
70
|
+
|
|
71
|
+
/** Mint a host-admin (operator-shaped) bearer at a chosen `iss` (hub#516). */
|
|
72
|
+
async function mintBearerAtIssuer(h: Harness, scopes: string[], iss: string): Promise<string> {
|
|
73
|
+
const signed = await signAccessToken(h.db, {
|
|
74
|
+
sub: h.userId,
|
|
75
|
+
scopes,
|
|
76
|
+
audience: "operator",
|
|
77
|
+
clientId: "parachute-hub",
|
|
78
|
+
issuer: iss,
|
|
79
|
+
ttlSeconds: 3600,
|
|
80
|
+
});
|
|
81
|
+
recordTokenMint(h.db, {
|
|
82
|
+
jti: signed.jti,
|
|
83
|
+
createdVia: "operator_mint",
|
|
84
|
+
subject: "operator",
|
|
85
|
+
clientId: "parachute-hub",
|
|
86
|
+
scopes,
|
|
87
|
+
expiresAt: signed.expiresAt,
|
|
88
|
+
});
|
|
89
|
+
return signed.token;
|
|
90
|
+
}
|
|
91
|
+
|
|
66
92
|
function writeManifest(path: string, services: unknown[]): void {
|
|
67
93
|
writeFileSync(path, JSON.stringify({ services }));
|
|
68
94
|
}
|
|
@@ -147,6 +173,47 @@ describe("GET /api/modules", () => {
|
|
|
147
173
|
expect(body.error).toBe("insufficient_scope");
|
|
148
174
|
});
|
|
149
175
|
|
|
176
|
+
// hub#516: `parachute status` reads /api/modules on loopback presenting the
|
|
177
|
+
// operator token, whose `iss` is the PUBLIC origin after `expose`. The
|
|
178
|
+
// host-admin bearer's iss is validated against `knownIssuers` (loopback ∪
|
|
179
|
+
// expose-state public ∪ env), not the single per-request loopback issuer.
|
|
180
|
+
test("live repro: public-iss operator token on a loopback request → 200 (hub#516)", async () => {
|
|
181
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
182
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
183
|
+
db: h.db,
|
|
184
|
+
issuer: ISSUER, // loopback per-request issuer
|
|
185
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
186
|
+
manifestPath: h.manifestPath,
|
|
187
|
+
fetchLatestVersion: async () => null,
|
|
188
|
+
});
|
|
189
|
+
expect(res.status).toBe(200);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("FOREIGN-iss operator token → 401 (no widening) (hub#516)", async () => {
|
|
193
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], FOREIGN_ORIGIN);
|
|
194
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
195
|
+
db: h.db,
|
|
196
|
+
issuer: ISSUER,
|
|
197
|
+
knownIssuers: [ISSUER, "http://localhost:1939", PUBLIC_ORIGIN],
|
|
198
|
+
manifestPath: h.manifestPath,
|
|
199
|
+
fetchLatestVersion: async () => null,
|
|
200
|
+
});
|
|
201
|
+
expect(res.status).toBe(401);
|
|
202
|
+
const body = (await res.json()) as { error_description: string };
|
|
203
|
+
expect(body.error_description).toMatch(/unexpected "iss" claim value/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("knownIssuers absent → strict per-request issuer fallback rejects public-iss (hub#516)", async () => {
|
|
207
|
+
const bearer = await mintBearerAtIssuer(h, [API_MODULES_REQUIRED_SCOPE], PUBLIC_ORIGIN);
|
|
208
|
+
const res = await handleApiModules(getReq({ authorization: `Bearer ${bearer}` }), {
|
|
209
|
+
db: h.db,
|
|
210
|
+
issuer: ISSUER, // no knownIssuers → falls back to [issuer]
|
|
211
|
+
manifestPath: h.manifestPath,
|
|
212
|
+
fetchLatestVersion: async () => null,
|
|
213
|
+
});
|
|
214
|
+
expect(res.status).toBe(401);
|
|
215
|
+
});
|
|
216
|
+
|
|
150
217
|
test("200 + curated list on fresh container (empty services.json)", async () => {
|
|
151
218
|
// The v0.6 hot path: brand-new Render container, no services.json
|
|
152
219
|
// yet. UI must render "install vault / scribe" cards even though
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hub#516 — `validateHostAdminToken` accepts the operator / SPA host-admin
|
|
3
|
+
* token's `iss` against the SET of origins the hub answers on (loopback ∪
|
|
4
|
+
* expose-state public ∪ env/platform), not the single per-request issuer, so
|
|
5
|
+
* the loopback CLI works on an exposed box. OAuth-token validation
|
|
6
|
+
* (`validateAccessToken` with a pinned `expectedIssuer`) is NOT touched — see
|
|
7
|
+
* the strict-iss regression at the bottom of this file.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { validateHostAdminToken } from "../host-admin-token-validation.ts";
|
|
14
|
+
import { hubDbPath, openHubDb } from "../hub-db.ts";
|
|
15
|
+
import { recordTokenMint, signAccessToken, validateAccessToken } from "../jwt-sign.ts";
|
|
16
|
+
import { rotateSigningKey } from "../signing-keys.ts";
|
|
17
|
+
import { createUser } from "../users.ts";
|
|
18
|
+
|
|
19
|
+
const LOOPBACK = "http://127.0.0.1:1939";
|
|
20
|
+
const PUBLIC_TS = "https://parachute.taildf9ce2.ts.net";
|
|
21
|
+
const FOREIGN = "https://evil.example.com";
|
|
22
|
+
|
|
23
|
+
interface H {
|
|
24
|
+
db: ReturnType<typeof openHubDb>;
|
|
25
|
+
userId: string;
|
|
26
|
+
cleanup: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function makeH(): Promise<H> {
|
|
30
|
+
const dir = mkdtempSync(join(tmpdir(), "phub-host-admin-tok-"));
|
|
31
|
+
const db = openHubDb(hubDbPath(dir));
|
|
32
|
+
rotateSigningKey(db);
|
|
33
|
+
const user = await createUser(db, "owner", "pw");
|
|
34
|
+
return {
|
|
35
|
+
db,
|
|
36
|
+
userId: user.id,
|
|
37
|
+
cleanup: () => {
|
|
38
|
+
db.close();
|
|
39
|
+
rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Mint an operator-shaped token (aud: "operator", host:admin) at `iss`. */
|
|
45
|
+
async function mintOperatorAt(h: H, iss: string): Promise<string> {
|
|
46
|
+
const signed = await signAccessToken(h.db, {
|
|
47
|
+
sub: h.userId,
|
|
48
|
+
scopes: ["parachute:host:admin", "parachute:host:auth"],
|
|
49
|
+
audience: "operator",
|
|
50
|
+
clientId: "parachute-hub",
|
|
51
|
+
issuer: iss,
|
|
52
|
+
ttlSeconds: 3600,
|
|
53
|
+
});
|
|
54
|
+
recordTokenMint(h.db, {
|
|
55
|
+
jti: signed.jti,
|
|
56
|
+
createdVia: "operator_mint",
|
|
57
|
+
subject: "operator",
|
|
58
|
+
clientId: "parachute-hub",
|
|
59
|
+
scopes: ["parachute:host:admin", "parachute:host:auth"],
|
|
60
|
+
expiresAt: signed.expiresAt,
|
|
61
|
+
});
|
|
62
|
+
return signed.token;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe("validateHostAdminToken (hub#516)", () => {
|
|
66
|
+
// The live repro: operator token minted with the PUBLIC origin as `iss`,
|
|
67
|
+
// presented on a LOOPBACK request (known-origins set built from the loopback
|
|
68
|
+
// per-request issuer + the expose-state public origin) → ACCEPTED.
|
|
69
|
+
test("live repro: public-iss operator token accepted when public origin is in the known set", async () => {
|
|
70
|
+
const h = await makeH();
|
|
71
|
+
try {
|
|
72
|
+
const token = await mintOperatorAt(h, PUBLIC_TS);
|
|
73
|
+
const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
|
|
74
|
+
const { payload } = await validateHostAdminToken(h.db, token, knownIssuers);
|
|
75
|
+
expect(payload.iss).toBe(PUBLIC_TS);
|
|
76
|
+
expect(payload.sub).toBe(h.userId);
|
|
77
|
+
} finally {
|
|
78
|
+
h.cleanup();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("loopback-iss operator token, loopback known set → accepted (unchanged)", async () => {
|
|
83
|
+
const h = await makeH();
|
|
84
|
+
try {
|
|
85
|
+
const token = await mintOperatorAt(h, LOOPBACK);
|
|
86
|
+
const { payload } = await validateHostAdminToken(h.db, token, [
|
|
87
|
+
LOOPBACK,
|
|
88
|
+
"http://localhost:1939",
|
|
89
|
+
]);
|
|
90
|
+
expect(payload.iss).toBe(LOOPBACK);
|
|
91
|
+
} finally {
|
|
92
|
+
h.cleanup();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("FOREIGN iss → REJECTED (no widening to arbitrary issuers)", async () => {
|
|
97
|
+
const h = await makeH();
|
|
98
|
+
try {
|
|
99
|
+
// A token the hub itself signed but whose iss is NOT one of its known
|
|
100
|
+
// origins. The signature verifies, but the belt-and-suspenders iss ∈
|
|
101
|
+
// known-origins check must still reject it.
|
|
102
|
+
const token = await mintOperatorAt(h, FOREIGN);
|
|
103
|
+
const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
|
|
104
|
+
await expect(validateHostAdminToken(h.db, token, knownIssuers)).rejects.toThrow(
|
|
105
|
+
/unexpected "iss" claim value/,
|
|
106
|
+
);
|
|
107
|
+
} finally {
|
|
108
|
+
h.cleanup();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("empty known-origins set rejects every token (fails closed)", async () => {
|
|
113
|
+
const h = await makeH();
|
|
114
|
+
try {
|
|
115
|
+
const token = await mintOperatorAt(h, LOOPBACK);
|
|
116
|
+
await expect(validateHostAdminToken(h.db, token, [])).rejects.toThrow(
|
|
117
|
+
/unexpected "iss" claim value/,
|
|
118
|
+
);
|
|
119
|
+
} finally {
|
|
120
|
+
h.cleanup();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("expose-state absent (loopback-only set): public-iss token rejected; loopback-iss accepted", async () => {
|
|
125
|
+
const h = await makeH();
|
|
126
|
+
try {
|
|
127
|
+
// Before `expose`, the known set is loopback-only — a public-iss token
|
|
128
|
+
// shouldn't exist yet, and if presented, it's not in the set → reject.
|
|
129
|
+
const publicTok = await mintOperatorAt(h, PUBLIC_TS);
|
|
130
|
+
const loopbackOnly = [LOOPBACK, "http://localhost:1939"];
|
|
131
|
+
await expect(validateHostAdminToken(h.db, publicTok, loopbackOnly)).rejects.toThrow(
|
|
132
|
+
/unexpected "iss" claim value/,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const loopbackTok = await mintOperatorAt(h, LOOPBACK);
|
|
136
|
+
const { payload } = await validateHostAdminToken(h.db, loopbackTok, loopbackOnly);
|
|
137
|
+
expect(payload.iss).toBe(LOOPBACK);
|
|
138
|
+
} finally {
|
|
139
|
+
h.cleanup();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("signature still enforced: a token signed by an unknown key is rejected", async () => {
|
|
144
|
+
const h = await makeH();
|
|
145
|
+
try {
|
|
146
|
+
// Mint against a SECOND hub's key, then try to validate against the
|
|
147
|
+
// first hub's JWKS. The known-origins set includes the iss, but the
|
|
148
|
+
// signature check (step 1) must reject it before iss is even considered.
|
|
149
|
+
const other = await makeH();
|
|
150
|
+
try {
|
|
151
|
+
const foreignSigned = await mintOperatorAt(other, PUBLIC_TS);
|
|
152
|
+
await expect(validateHostAdminToken(h.db, foreignSigned, [PUBLIC_TS])).rejects.toThrow();
|
|
153
|
+
} finally {
|
|
154
|
+
other.cleanup();
|
|
155
|
+
}
|
|
156
|
+
} finally {
|
|
157
|
+
h.cleanup();
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Host-injection defense, made explicit. Simulate an attacker who got their
|
|
162
|
+
// own issuer into the known-issuers set via a forged Host header (the set is
|
|
163
|
+
// built from the per-request Host-derived issuer, so `iss ∈ knownIssuers`
|
|
164
|
+
// holds). They present a token signed by a DIFFERENT hub's key whose `iss`
|
|
165
|
+
// is exactly that injected origin. It is STILL REJECTED — the signature /
|
|
166
|
+
// JWKS check (step 1) fails before the `iss ∈ knownIssuers` check (step 2)
|
|
167
|
+
// is ever reached. This pins that known-issuers acceptance can never bypass
|
|
168
|
+
// the signature gate: membership in the set is necessary but not sufficient.
|
|
169
|
+
test("Host-injection: foreign-signed token with iss IN knownIssuers is STILL rejected (signature gate is first)", async () => {
|
|
170
|
+
const h = await makeH();
|
|
171
|
+
try {
|
|
172
|
+
const other = await makeH();
|
|
173
|
+
try {
|
|
174
|
+
// Token minted + signed by the OTHER hub at PUBLIC_TS.
|
|
175
|
+
const foreignSigned = await mintOperatorAt(other, PUBLIC_TS);
|
|
176
|
+
// The attacker's iss IS in our known set (e.g. injected via Host), yet
|
|
177
|
+
// validation against OUR JWKS must reject it on the signature check.
|
|
178
|
+
const knownIssuers = [LOOPBACK, "http://localhost:1939", PUBLIC_TS];
|
|
179
|
+
expect(knownIssuers).toContain(PUBLIC_TS); // precondition: iss ∈ knownIssuers
|
|
180
|
+
await expect(validateHostAdminToken(h.db, foreignSigned, knownIssuers)).rejects.toThrow();
|
|
181
|
+
} finally {
|
|
182
|
+
other.cleanup();
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
h.cleanup();
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Pin the invariant: OAuth/access-token validation stays STRICT per-request
|
|
190
|
+
// issuer. The relaxation lives only in validateHostAdminToken; the core
|
|
191
|
+
// primitive validateAccessToken(db, token, expectedIssuer) still rejects a
|
|
192
|
+
// mismatched iss. (Mirrors jwt-sign.test.ts's defense-in-depth test; kept
|
|
193
|
+
// here so the hub#516 guard travels with the relaxation.)
|
|
194
|
+
test("OAuth-token validation UNCHANGED: validateAccessToken pins iss strictly", async () => {
|
|
195
|
+
const h = await makeH();
|
|
196
|
+
try {
|
|
197
|
+
// A vault-aud OAuth-shaped token minted at the public origin.
|
|
198
|
+
const signed = await signAccessToken(h.db, {
|
|
199
|
+
sub: h.userId,
|
|
200
|
+
scopes: ["vault:read"],
|
|
201
|
+
audience: "vault.default",
|
|
202
|
+
clientId: "some-mcp-client",
|
|
203
|
+
issuer: PUBLIC_TS,
|
|
204
|
+
ttlSeconds: 3600,
|
|
205
|
+
});
|
|
206
|
+
// Strict per-request validation against a DIFFERENT (loopback) issuer
|
|
207
|
+
// still rejects — the relaxation must NOT leak to this path.
|
|
208
|
+
await expect(validateAccessToken(h.db, signed.token, LOOPBACK)).rejects.toThrow(
|
|
209
|
+
/unexpected "iss" claim value/,
|
|
210
|
+
);
|
|
211
|
+
// And it accepts when the issuer matches (sanity).
|
|
212
|
+
const { payload } = await validateAccessToken(h.db, signed.token, PUBLIC_TS);
|
|
213
|
+
expect(payload.aud).toBe("vault.default");
|
|
214
|
+
} finally {
|
|
215
|
+
h.cleanup();
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -429,10 +429,13 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
|
|
|
429
429
|
expect(() => hubUnit(f.deps)).toThrow(/'bun' not found on PATH/);
|
|
430
430
|
});
|
|
431
431
|
|
|
432
|
-
test("env carries the
|
|
432
|
+
test("env carries the 5 vars and INTENTIONALLY OMITS PARACHUTE_HUB_ORIGIN", () => {
|
|
433
433
|
const f = fakeDeps({ platform: "linux" });
|
|
434
434
|
const unit = hubUnit(f.deps);
|
|
435
435
|
expect(unit.env).toEqual({
|
|
436
|
+
// Forced loopback (security): a self-hosted supervised hub must NOT inherit
|
|
437
|
+
// serve.ts's container-first 0.0.0.0 default and bare-serve all-interfaces.
|
|
438
|
+
PARACHUTE_BIND_HOST: "127.0.0.1",
|
|
436
439
|
PARACHUTE_HOME: "/home/op/.parachute",
|
|
437
440
|
PORT: "1939",
|
|
438
441
|
PATH: "/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin",
|
|
@@ -441,6 +444,17 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
|
|
|
441
444
|
expect(unit.env.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
442
445
|
});
|
|
443
446
|
|
|
447
|
+
test("env forces PARACHUTE_BIND_HOST=127.0.0.1 (loopback trust model — never 0.0.0.0)", () => {
|
|
448
|
+
const f = fakeDeps({ platform: "linux" });
|
|
449
|
+
const unit = hubUnit(f.deps);
|
|
450
|
+
// The whole point of the fix: the supervised hub unit binds loopback, NOT
|
|
451
|
+
// the serve.ts container-first 0.0.0.0 default. Covers both init + migrate
|
|
452
|
+
// (both route through buildHubManagedUnit) and both platforms (the env is
|
|
453
|
+
// platform-agnostic; systemd/launchd render shapes are asserted below).
|
|
454
|
+
expect(unit.env.PARACHUTE_BIND_HOST).toBe("127.0.0.1");
|
|
455
|
+
expect(unit.env.PARACHUTE_BIND_HOST).not.toBe("0.0.0.0");
|
|
456
|
+
});
|
|
457
|
+
|
|
444
458
|
test("PARACHUTE_HOME is the captured param, NOT the default (§4.2)", () => {
|
|
445
459
|
const f = fakeDeps({ platform: "linux" });
|
|
446
460
|
const unit = buildHubManagedUnit({
|
|
@@ -468,11 +482,14 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
|
|
|
468
482
|
expect(unit.env.PORT).toBe("2939");
|
|
469
483
|
});
|
|
470
484
|
|
|
471
|
-
test("rendered systemd SYSTEM unit:
|
|
485
|
+
test("rendered systemd SYSTEM unit: 5 Environment= vars, User= present, StartLimit present", () => {
|
|
472
486
|
const f = fakeDeps({ platform: "linux", getuid: () => 0, userName: () => "op" });
|
|
473
487
|
const unit = renderManagedSystemdUnit(hubUnit(f.deps), { root: true, userName: "op" });
|
|
474
488
|
expect(unit).toContain("Description=Parachute hub (serve + supervisor)");
|
|
475
489
|
expect(unit).toContain("User=op");
|
|
490
|
+
// Forced loopback bind (security): the supervised hub must bind 127.0.0.1.
|
|
491
|
+
expect(unit).toContain("Environment=PARACHUTE_BIND_HOST=127.0.0.1");
|
|
492
|
+
expect(unit).not.toContain("PARACHUTE_BIND_HOST=0.0.0.0");
|
|
476
493
|
expect(unit).toContain("Environment=PARACHUTE_HOME=/home/op/.parachute");
|
|
477
494
|
expect(unit).toContain("Environment=PORT=1939");
|
|
478
495
|
expect(unit).toContain("Environment=PATH=/home/op/.bun/bin:/usr/local/bin:/usr/bin:/bin");
|
|
@@ -494,7 +511,7 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
|
|
|
494
511
|
expect(unit).toContain("WantedBy=default.target");
|
|
495
512
|
});
|
|
496
513
|
|
|
497
|
-
test("rendered launchd plist: EnvironmentVariables dict (
|
|
514
|
+
test("rendered launchd plist: EnvironmentVariables dict (5 vars) + ThrottleInterval + abs ProgramArguments", () => {
|
|
498
515
|
const f = fakeDeps({ platform: "darwin" });
|
|
499
516
|
const plist = renderManagedLaunchdPlist(hubUnit(f.deps));
|
|
500
517
|
expect(plist).toContain("<key>Label</key>\n <string>computer.parachute.hub</string>");
|
|
@@ -502,6 +519,9 @@ describe("buildHubManagedUnit — §4.1 hub-unit shape", () => {
|
|
|
502
519
|
expect(plist).toContain("<string>/home/op/parachute-hub/src/cli.ts</string>");
|
|
503
520
|
expect(plist).toContain("<string>serve</string>");
|
|
504
521
|
expect(plist).toContain("<key>EnvironmentVariables</key>");
|
|
522
|
+
// Forced loopback bind (security): the supervised hub must bind 127.0.0.1.
|
|
523
|
+
expect(plist).toContain("<key>PARACHUTE_BIND_HOST</key>\n <string>127.0.0.1</string>");
|
|
524
|
+
expect(plist).not.toContain("<string>0.0.0.0</string>");
|
|
505
525
|
expect(plist).toContain("<key>PARACHUTE_HOME</key>\n <string>/home/op/.parachute</string>");
|
|
506
526
|
expect(plist).toContain("<key>PORT</key>\n <string>1939</string>");
|
|
507
527
|
expect(plist).toContain("<key>BUN_INSTALL</key>\n <string>/home/op/.bun</string>");
|
|
@@ -122,6 +122,12 @@ function makeFakeCutover(over: Partial<CutoverDeps> = {}): FakeCutover {
|
|
|
122
122
|
messages: ["started unit"],
|
|
123
123
|
};
|
|
124
124
|
},
|
|
125
|
+
// Hermetic default: the stale-unit disable is a no-op (no real
|
|
126
|
+
// systemctl/launchctl). Tests that exercise #522 override this to trace + act.
|
|
127
|
+
disableStaleModuleUnits: () => {
|
|
128
|
+
trace.push("disableStaleUnits");
|
|
129
|
+
return { actions: [] };
|
|
130
|
+
},
|
|
125
131
|
...over,
|
|
126
132
|
};
|
|
127
133
|
// Expose the world via closure for tests that want to manipulate it.
|
|
@@ -184,6 +190,43 @@ describe("cutoverToSupervised — happy path (§7.1)", () => {
|
|
|
184
190
|
}
|
|
185
191
|
});
|
|
186
192
|
|
|
193
|
+
test("#522: stale-unit disable runs in the STOP phase — after detached stop, before unit start", async () => {
|
|
194
|
+
const h = makeHarness();
|
|
195
|
+
try {
|
|
196
|
+
seedManifest(h.manifestPath, [{ name: "vault", port: 1940 }]);
|
|
197
|
+
const fc = makeFakeCutover();
|
|
198
|
+
const w = getWorld(fc.deps);
|
|
199
|
+
w.listening.add(1939);
|
|
200
|
+
w.listening.add(1940);
|
|
201
|
+
w.alivePids.add(5555);
|
|
202
|
+
writePid("vault", 5555, h.configDir);
|
|
203
|
+
const baseKill = fc.deps.kill;
|
|
204
|
+
fc.deps.kill = (pid, signal) => {
|
|
205
|
+
baseKill?.(pid, signal);
|
|
206
|
+
if (pid === 5555) getWorld(fc.deps).listening.delete(1940);
|
|
207
|
+
};
|
|
208
|
+
const result = await cutoverToSupervised({
|
|
209
|
+
configDir: h.configDir,
|
|
210
|
+
manifestPath: h.manifestPath,
|
|
211
|
+
deps: fc.deps,
|
|
212
|
+
log: () => {},
|
|
213
|
+
pollMs: 0,
|
|
214
|
+
});
|
|
215
|
+
expect(result.outcome).toBe("migrated");
|
|
216
|
+
const stopIdx = fc.trace.indexOf("stopHub");
|
|
217
|
+
const disableIdx = fc.trace.indexOf("disableStaleUnits");
|
|
218
|
+
const startIdx = fc.trace.indexOf("startUnit");
|
|
219
|
+
// The disable runs AFTER the detached stop and BEFORE the unit start, so a
|
|
220
|
+
// KeepAlive/Restart=always unit can't re-grab the port between freeing it
|
|
221
|
+
// and the supervised module binding it.
|
|
222
|
+
expect(disableIdx).toBeGreaterThanOrEqual(0);
|
|
223
|
+
expect(stopIdx).toBeLessThan(disableIdx);
|
|
224
|
+
expect(disableIdx).toBeLessThan(startIdx);
|
|
225
|
+
} finally {
|
|
226
|
+
h.cleanup();
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
187
230
|
test("verify-ports-free runs before start (start never races a held port)", async () => {
|
|
188
231
|
const h = makeHarness();
|
|
189
232
|
try {
|
|
@@ -443,6 +486,7 @@ describe("cutoverToSupervised — fail-safe recovery states", () => {
|
|
|
443
486
|
describe("teardownHubUnit (§7.4)", () => {
|
|
444
487
|
test("removes the hub unit (idempotent success path)", () => {
|
|
445
488
|
let removeArgs: { launchdLabel: string; systemdUnitName: string } | undefined;
|
|
489
|
+
let staleCalled = false;
|
|
446
490
|
const log: string[] = [];
|
|
447
491
|
const res = teardownHubUnit({
|
|
448
492
|
log: (l) => log.push(l),
|
|
@@ -450,21 +494,36 @@ describe("teardownHubUnit (§7.4)", () => {
|
|
|
450
494
|
removeArgs = { launchdLabel: opts.launchdLabel, systemdUnitName: opts.systemdUnitName };
|
|
451
495
|
return { removed: true, messages: [opts.removedSystemdMessage(opts.systemdUnitName)] };
|
|
452
496
|
},
|
|
497
|
+
// Hermetic stub — no real systemctl/launchctl.
|
|
498
|
+
disableStaleModuleUnits: () => {
|
|
499
|
+
staleCalled = true;
|
|
500
|
+
return { actions: [] };
|
|
501
|
+
},
|
|
453
502
|
});
|
|
454
503
|
expect(res.removed).toBe(true);
|
|
455
504
|
expect(removeArgs?.launchdLabel).toBe("computer.parachute.hub");
|
|
456
505
|
expect(removeArgs?.systemdUnitName).toBe("parachute-hub.service");
|
|
506
|
+
// #522: teardown also runs the stale-per-module-autostart disable.
|
|
507
|
+
expect(staleCalled).toBe(true);
|
|
457
508
|
// Surfaces the fallback hint.
|
|
458
509
|
expect(log.join("\n")).toContain("parachute serve");
|
|
459
510
|
});
|
|
460
511
|
|
|
461
|
-
test("no unit installed → no-op, friendly message", () => {
|
|
512
|
+
test("no unit installed → no-op, friendly message (still runs the stale-unit disable)", () => {
|
|
513
|
+
let staleCalled = false;
|
|
462
514
|
const log: string[] = [];
|
|
463
515
|
const res = teardownHubUnit({
|
|
464
516
|
log: (l) => log.push(l),
|
|
465
517
|
remove: (): ManagedUnitRemoveResult => ({ removed: false, messages: [] }),
|
|
518
|
+
disableStaleModuleUnits: () => {
|
|
519
|
+
staleCalled = true;
|
|
520
|
+
return { actions: [] };
|
|
521
|
+
},
|
|
466
522
|
});
|
|
467
523
|
expect(res.removed).toBe(false);
|
|
524
|
+
// #522: a leftover module autostart must be cleaned even when the hub unit was
|
|
525
|
+
// never installed (a partial / never-migrated box rolling back).
|
|
526
|
+
expect(staleCalled).toBe(true);
|
|
468
527
|
expect(log.join("\n")).toContain("nothing to tear down");
|
|
469
528
|
});
|
|
470
529
|
});
|