@openparachute/hub 0.7.3-rc.8 → 0.7.3-rc.9
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__/hub-origins-env-set.test.ts +199 -0
- package/src/__tests__/serve-boot.test.ts +45 -0
- package/src/__tests__/vault-hub-origin-env.test.ts +38 -4
- package/src/api-modules-ops.ts +12 -0
- package/src/commands/serve-boot.ts +16 -2
- package/src/hub-origin.ts +64 -0
- package/src/jwt-sign.ts +14 -3
- package/src/vault-hub-origin-env.ts +109 -16
package/package.json
CHANGED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-origin iss-set (onboarding-streamline 2026-06-25) — hub side.
|
|
3
|
+
*
|
|
4
|
+
* The hub publishes the SET of origins it legitimately answers on to its
|
|
5
|
+
* supervised resource servers via `PARACHUTE_HUB_ORIGINS` (comma-separated),
|
|
6
|
+
* alongside the single canonical `PARACHUTE_HUB_ORIGIN`. A resource server on
|
|
7
|
+
* scope-guard ≥0.5.0 widens its accepted-`iss` check to this set so a token
|
|
8
|
+
* minted under one URL of a multi-URL box validates via another URL of the
|
|
9
|
+
* SAME box.
|
|
10
|
+
*
|
|
11
|
+
* These tests pin two things:
|
|
12
|
+
* 1. The serialize/parse round-trip + the assembly from hub-controlled
|
|
13
|
+
* inputs (issuer ∪ loopback aliases ∪ expose-state ∪ platform).
|
|
14
|
+
* 2. The SECURITY INVARIANT: the set is built ONLY from operator/hub config
|
|
15
|
+
* and on-disk state — never from an unvalidated request `Host` /
|
|
16
|
+
* `X-Forwarded-Host`. We feed an attacker-controlled "Host" through every
|
|
17
|
+
* input channel a request could plausibly reach and assert it never lands
|
|
18
|
+
* in the published set.
|
|
19
|
+
*/
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
21
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { parseHubOrigins, serializeHubOrigins } from "../hub-origin.ts";
|
|
25
|
+
import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
|
|
26
|
+
|
|
27
|
+
describe("serializeHubOrigins / parseHubOrigins round-trip", () => {
|
|
28
|
+
test("serialize dedupes, drops empties, strips trailing slashes", () => {
|
|
29
|
+
const v = serializeHubOrigins([
|
|
30
|
+
"https://a.example/",
|
|
31
|
+
"https://a.example",
|
|
32
|
+
"",
|
|
33
|
+
"https://b.example",
|
|
34
|
+
]);
|
|
35
|
+
expect(v).toBe("https://a.example,https://b.example");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("serialize returns undefined when nothing survives", () => {
|
|
39
|
+
expect(serializeHubOrigins([])).toBeUndefined();
|
|
40
|
+
expect(serializeHubOrigins(["", " "])).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("parse is the inverse — tolerant of whitespace + trailing slashes + empties", () => {
|
|
44
|
+
expect(parseHubOrigins("https://a.example, https://b.example/ ,,")).toEqual([
|
|
45
|
+
"https://a.example",
|
|
46
|
+
"https://b.example",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("parse returns [] for absent/empty/garbage", () => {
|
|
51
|
+
expect(parseHubOrigins(undefined)).toEqual([]);
|
|
52
|
+
expect(parseHubOrigins("")).toEqual([]);
|
|
53
|
+
expect(parseHubOrigins(" , , ")).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("round-trips a real set", () => {
|
|
57
|
+
const origins = ["https://example.com", "http://127.0.0.1:1939", "http://localhost:1939"];
|
|
58
|
+
const wire = serializeHubOrigins(origins)!;
|
|
59
|
+
expect(parseHubOrigins(wire)).toEqual(origins);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("buildHubOriginsEnvValue — assembles the hub's legitimate-origin set", () => {
|
|
64
|
+
let dir: string;
|
|
65
|
+
const EXPOSE = () => join(dir, "expose-state.json");
|
|
66
|
+
|
|
67
|
+
/** Write a schema-valid expose-state.json carrying the given hubOrigin. */
|
|
68
|
+
function writeExposeState(hubOrigin: string): void {
|
|
69
|
+
writeFileSync(
|
|
70
|
+
EXPOSE(),
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
version: 1,
|
|
73
|
+
layer: "public",
|
|
74
|
+
mode: "path",
|
|
75
|
+
canonicalFqdn: new URL(hubOrigin).host,
|
|
76
|
+
port: 1939,
|
|
77
|
+
funnel: true,
|
|
78
|
+
entries: [],
|
|
79
|
+
hubOrigin,
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
dir = mkdtempSync(join(tmpdir(), "hub-origins-set-"));
|
|
86
|
+
// No hub.port file in this fresh configDir → readHubPort falls back to
|
|
87
|
+
// HUB_UNIT_DEFAULT_PORT (1939), the deterministic value these cases assert.
|
|
88
|
+
});
|
|
89
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
90
|
+
|
|
91
|
+
test("issuer ∪ loopback aliases when no expose / platform origin", () => {
|
|
92
|
+
const v = buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE());
|
|
93
|
+
const set = parseHubOrigins(v);
|
|
94
|
+
expect(set).toContain("https://example.com");
|
|
95
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
96
|
+
expect(set).toContain("http://localhost:1939");
|
|
97
|
+
expect(set).toHaveLength(3);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("loopback aliases are ALWAYS present (the always-present invariant)", () => {
|
|
101
|
+
// Even with a public issuer, loopback must be in the set so the co-located
|
|
102
|
+
// CLI / loopback-proxied request path validates.
|
|
103
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
104
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
105
|
+
expect(set).toContain("http://localhost:1939");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("folds in the expose-state public origin", () => {
|
|
109
|
+
writeExposeState("https://box.sslip.io");
|
|
110
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
111
|
+
expect(set).toContain("https://example.com");
|
|
112
|
+
expect(set).toContain("https://box.sslip.io");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("folds in the platform origin (RENDER_EXTERNAL_URL)", () => {
|
|
116
|
+
const set = parseHubOrigins(
|
|
117
|
+
buildHubOriginsEnvValue(
|
|
118
|
+
dir,
|
|
119
|
+
"https://example.com",
|
|
120
|
+
{ RENDER_EXTERNAL_URL: "https://app.onrender.com" },
|
|
121
|
+
EXPOSE(),
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
expect(set).toContain("https://app.onrender.com");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("folds in the composed Fly default origin", () => {
|
|
128
|
+
const set = parseHubOrigins(
|
|
129
|
+
buildHubOriginsEnvValue(dir, "https://example.com", { FLY_APP_NAME: "myapp" }, EXPOSE()),
|
|
130
|
+
);
|
|
131
|
+
expect(set).toContain("https://myapp.fly.dev");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("absent issuer → loopback-only set (still useful, never empty on a normal box)", () => {
|
|
135
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, undefined, {}, EXPOSE()));
|
|
136
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
137
|
+
expect(set).toContain("http://localhost:1939");
|
|
138
|
+
// The empty issuer "" is dropped by buildHubBoundOrigins' URL parse.
|
|
139
|
+
expect(set).not.toContain("");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("a malformed expose-state.json never throws — falls back to issuer + loopback", () => {
|
|
143
|
+
writeFileSync(EXPOSE(), "{ not valid json");
|
|
144
|
+
const set = parseHubOrigins(buildHubOriginsEnvValue(dir, "https://example.com", {}, EXPOSE()));
|
|
145
|
+
expect(set).toContain("https://example.com");
|
|
146
|
+
expect(set).toContain("http://127.0.0.1:1939");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("SECURITY INVARIANT — request Host never enters the set", () => {
|
|
150
|
+
const ATTACKER = "https://attacker.evil";
|
|
151
|
+
|
|
152
|
+
test("an attacker Host smuggled via expose-state IS honored ONLY because it's operator-written on-disk state — but a Host passed nowhere never appears", () => {
|
|
153
|
+
// There is NO request input to buildHubOriginsEnvValue at all — it takes
|
|
154
|
+
// configDir + issuer + env + expose-state-path. None of those is a
|
|
155
|
+
// request header. We assert the function's surface offers no channel for
|
|
156
|
+
// a request Host: feeding the attacker value to the only inputs an
|
|
157
|
+
// attacker might influence (a stray env var, a header-shaped string)
|
|
158
|
+
// never reaches the set unless it's a legitimate operator-config var.
|
|
159
|
+
const set = parseHubOrigins(
|
|
160
|
+
buildHubOriginsEnvValue(
|
|
161
|
+
dir,
|
|
162
|
+
"https://example.com",
|
|
163
|
+
{
|
|
164
|
+
// Header-shaped env vars an attacker might hope are read — none are
|
|
165
|
+
// consulted by the assembler (only RENDER_EXTERNAL_URL / FLY_APP_NAME).
|
|
166
|
+
HTTP_HOST: ATTACKER,
|
|
167
|
+
HTTP_X_FORWARDED_HOST: ATTACKER,
|
|
168
|
+
X_FORWARDED_HOST: ATTACKER,
|
|
169
|
+
HOST: ATTACKER,
|
|
170
|
+
} as NodeJS.ProcessEnv,
|
|
171
|
+
EXPOSE(),
|
|
172
|
+
),
|
|
173
|
+
);
|
|
174
|
+
expect(set).not.toContain(ATTACKER);
|
|
175
|
+
expect(set.some((o) => o.includes("attacker"))).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("the set is exactly issuer ∪ loopback ∪ expose ∪ platform — no other source", () => {
|
|
179
|
+
writeExposeState("https://box.sslip.io");
|
|
180
|
+
const set = parseHubOrigins(
|
|
181
|
+
buildHubOriginsEnvValue(
|
|
182
|
+
dir,
|
|
183
|
+
"https://example.com",
|
|
184
|
+
{ RENDER_EXTERNAL_URL: "https://app.onrender.com" },
|
|
185
|
+
EXPOSE(),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
// Every member is one of the four sanctioned sources; nothing else.
|
|
189
|
+
const sanctioned = new Set([
|
|
190
|
+
"https://example.com",
|
|
191
|
+
"http://127.0.0.1:1939",
|
|
192
|
+
"http://localhost:1939",
|
|
193
|
+
"https://box.sslip.io",
|
|
194
|
+
"https://app.onrender.com",
|
|
195
|
+
]);
|
|
196
|
+
for (const o of set) expect(sanctioned.has(o)).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
});
|
|
@@ -125,6 +125,51 @@ describe("bootSupervisedModules", () => {
|
|
|
125
125
|
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
|
|
126
126
|
});
|
|
127
127
|
|
|
128
|
+
test("forwards PARACHUTE_HUB_ORIGINS (the multi-origin iss-set) alongside the single origin", async () => {
|
|
129
|
+
// Multi-origin iss-set (onboarding-streamline 2026-06-25): a resource
|
|
130
|
+
// server on scope-guard ≥0.5.0 widens its accepted-`iss` check to this set
|
|
131
|
+
// so a token minted under one URL of a multi-URL box validates via another.
|
|
132
|
+
// The set always carries the issuer + loopback aliases (the deterministic
|
|
133
|
+
// members); expose-state / platform members vary by box.
|
|
134
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
135
|
+
const recorder = makeRecorder();
|
|
136
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
137
|
+
|
|
138
|
+
await bootSupervisedModules(sup, {
|
|
139
|
+
manifestPath: h.manifestPath,
|
|
140
|
+
configDir: h.dir,
|
|
141
|
+
hubOrigin: "https://hub.example",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const origins = recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGINS;
|
|
145
|
+
expect(origins).toBeDefined();
|
|
146
|
+
const set = (origins ?? "").split(",");
|
|
147
|
+
expect(set).toContain("https://hub.example");
|
|
148
|
+
// SECURITY: the set is hub-controlled (issuer + loopback + expose +
|
|
149
|
+
// platform) — never a request Host. Loopback aliases are always present.
|
|
150
|
+
expect(set.some((o) => o.startsWith("http://127.0.0.1:"))).toBe(true);
|
|
151
|
+
expect(set.some((o) => o.startsWith("http://localhost:"))).toBe(true);
|
|
152
|
+
// And the single canonical origin is still written for back-compat.
|
|
153
|
+
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("no PARACHUTE_HUB_ORIGINS when hubOrigin is unset (no widening, single-origin only)", async () => {
|
|
157
|
+
// Back-compat: a boot with no hubOrigin neither sets PARACHUTE_HUB_ORIGIN
|
|
158
|
+
// nor the set — the child keeps its own loopback default + scope-guard's
|
|
159
|
+
// single-origin behavior.
|
|
160
|
+
writeManifest({ services: [VAULT_ENTRY] }, h.manifestPath);
|
|
161
|
+
const recorder = makeRecorder();
|
|
162
|
+
const sup = new Supervisor({ spawnFn: recorder.spawn });
|
|
163
|
+
|
|
164
|
+
await bootSupervisedModules(sup, {
|
|
165
|
+
manifestPath: h.manifestPath,
|
|
166
|
+
configDir: h.dir,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
170
|
+
expect(recorder.calls[0]?.env?.PARACHUTE_HUB_ORIGINS).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
128
173
|
test("sets PORT in child env from services.json entry (hub#357)", async () => {
|
|
129
174
|
// Container deploys (Render etc.) set PORT in hub's process.env via
|
|
130
175
|
// Dockerfile / platform injection. The supervisor's defaultSpawnFn
|
|
@@ -76,13 +76,29 @@ describe("persistVaultHubOrigin", () => {
|
|
|
76
76
|
expect(existsSync(vaultEnv())).toBe(false);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
-
test("is idempotent — no rewrite when the
|
|
79
|
+
test("is idempotent — no rewrite when both the origin and the set are current", () => {
|
|
80
80
|
const log: string[] = [];
|
|
81
81
|
expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(true);
|
|
82
|
+
// First call wrote BOTH the single origin and the multi-origin set.
|
|
83
|
+
expect(
|
|
84
|
+
log.some((l) => /persisted PARACHUTE_HUB_ORIGIN=https:\/\/hub\.example\.com/.test(l)),
|
|
85
|
+
).toBe(true);
|
|
86
|
+
expect(log.some((l) => /persisted PARACHUTE_HUB_ORIGINS=/.test(l))).toBe(true);
|
|
87
|
+
const writesAfterFirst = log.length;
|
|
88
|
+
// Second call is a no-op: both values already current → false, no new logs.
|
|
82
89
|
expect(persistVaultHubOrigin(dir, "https://hub.example.com", (l) => log.push(l))).toBe(false);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
90
|
+
expect(log).toHaveLength(writesAfterFirst);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("writes PARACHUTE_HUB_ORIGINS (multi-origin iss-set) alongside the single origin", () => {
|
|
94
|
+
expect(persistVaultHubOrigin(dir, "https://hub.example.com", () => {})).toBe(true);
|
|
95
|
+
const values = readEnvFileValues(vaultEnv());
|
|
96
|
+
expect(values.PARACHUTE_HUB_ORIGIN).toBe("https://hub.example.com");
|
|
97
|
+
// The set carries the issuer + loopback aliases (always present).
|
|
98
|
+
const set = (values.PARACHUTE_HUB_ORIGINS ?? "").split(",");
|
|
99
|
+
expect(set).toContain("https://hub.example.com");
|
|
100
|
+
expect(set.some((o) => o.startsWith("http://127.0.0.1:"))).toBe(true);
|
|
101
|
+
expect(set.some((o) => o.startsWith("http://localhost:"))).toBe(true);
|
|
86
102
|
});
|
|
87
103
|
|
|
88
104
|
test("updates a stale origin in-place and preserves sibling keys", () => {
|
|
@@ -113,6 +129,24 @@ describe("clearVaultHubOrigin", () => {
|
|
|
113
129
|
expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
|
|
114
130
|
});
|
|
115
131
|
|
|
132
|
+
test("removes BOTH the single origin and the multi-origin set, leaving siblings", () => {
|
|
133
|
+
writeFileSync(
|
|
134
|
+
mkVaultDir(),
|
|
135
|
+
"SCRIBE_AUTH_TOKEN=secret\nPARACHUTE_HUB_ORIGIN=https://hub.example.com\nPARACHUTE_HUB_ORIGINS=https://hub.example.com,http://127.0.0.1:1939\n",
|
|
136
|
+
);
|
|
137
|
+
expect(clearVaultHubOrigin(dir, () => {})).toBe(true);
|
|
138
|
+
const values = readEnvFileValues(vaultEnv());
|
|
139
|
+
expect(values.PARACHUTE_HUB_ORIGIN).toBeUndefined();
|
|
140
|
+
expect(values.PARACHUTE_HUB_ORIGINS).toBeUndefined();
|
|
141
|
+
expect(values.SCRIBE_AUTH_TOKEN).toBe("secret");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("clears a lone PARACHUTE_HUB_ORIGINS even if the single origin is absent", () => {
|
|
145
|
+
writeFileSync(mkVaultDir(), "PARACHUTE_HUB_ORIGINS=https://hub.example.com\n");
|
|
146
|
+
expect(clearVaultHubOrigin(dir, () => {})).toBe(true);
|
|
147
|
+
expect(readEnvFileValues(vaultEnv()).PARACHUTE_HUB_ORIGINS).toBeUndefined();
|
|
148
|
+
});
|
|
149
|
+
|
|
116
150
|
test("no-op when no origin is present", () => {
|
|
117
151
|
writeFileSync(mkVaultDir(), "SCRIBE_AUTH_TOKEN=secret\n");
|
|
118
152
|
expect(clearVaultHubOrigin(dir, () => {})).toBe(false);
|
package/src/api-modules-ops.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
import { findService, readManifestLenient, removeService } from "./services-manifest.ts";
|
|
56
56
|
import { enrichedPath } from "./spawn-path.ts";
|
|
57
57
|
import type { ModuleState, SpawnRequest, Supervisor } from "./supervisor.ts";
|
|
58
|
+
import { buildHubOriginsEnvValue } from "./vault-hub-origin-env.ts";
|
|
58
59
|
import { WELL_KNOWN_PATH, type regenerateWellKnown } from "./well-known.ts";
|
|
59
60
|
|
|
60
61
|
/**
|
|
@@ -609,10 +610,21 @@ async function spawnSupervised(
|
|
|
609
610
|
// `process.env.PATH` may ALREADY be enriched by serve startup (serve.ts);
|
|
610
611
|
// re-enriching here is a harmless no-op — `enrichedPath` is idempotent
|
|
611
612
|
// (dedupe + append-only), so double-enrichment can't duplicate or reorder.
|
|
613
|
+
// PARACHUTE_HUB_ORIGINS (multi-origin iss-set, onboarding-streamline
|
|
614
|
+
// 2026-06-25): alongside the single canonical PARACHUTE_HUB_ORIGIN, inject
|
|
615
|
+
// the SET of origins the hub legitimately answers on (issuer ∪ loopback ∪
|
|
616
|
+
// expose-state ∪ platform). A resource server on scope-guard ≥0.5.0 widens
|
|
617
|
+
// its accepted-`iss` check to this set so a token minted under one URL of a
|
|
618
|
+
// multi-URL box validates via another URL of the SAME box. Mirrors
|
|
619
|
+
// buildModuleSpawnRequest (serve-boot.ts) — keep the two in sync. SECURITY:
|
|
620
|
+
// the set is hub-controlled config/disk state ONLY, never a request Host
|
|
621
|
+
// (see buildHubOriginsEnvValue). `deps.spawnEnv` still wins.
|
|
622
|
+
const hubOrigins = deps.issuer ? buildHubOriginsEnvValue(deps.configDir, deps.issuer) : undefined;
|
|
612
623
|
const childEnv: Record<string, string> = {
|
|
613
624
|
PATH: enrichedPath(),
|
|
614
625
|
PORT: String(entry.port),
|
|
615
626
|
...(deps.issuer ? { PARACHUTE_HUB_ORIGIN: deps.issuer } : {}),
|
|
627
|
+
...(hubOrigins ? { PARACHUTE_HUB_ORIGINS: hubOrigins } : {}),
|
|
616
628
|
...(deps.spawnEnv ?? {}),
|
|
617
629
|
};
|
|
618
630
|
const req: SpawnRequest = {
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { join } from "node:path";
|
|
20
20
|
import { readEnvFileValues } from "../env-file.ts";
|
|
21
|
-
import { HUB_ORIGIN_ENV } from "../hub-origin.ts";
|
|
21
|
+
import { HUB_ORIGINS_ENV, HUB_ORIGIN_ENV } from "../hub-origin.ts";
|
|
22
22
|
import { ModuleManifestError } from "../module-manifest.ts";
|
|
23
23
|
import {
|
|
24
24
|
type ServiceSpec,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
import { type ServiceEntry, readManifestLenient, upsertService } from "../services-manifest.ts";
|
|
31
31
|
import { enrichedPath } from "../spawn-path.ts";
|
|
32
32
|
import type { Supervisor } from "../supervisor.ts";
|
|
33
|
+
import { buildHubOriginsEnvValue } from "../vault-hub-origin-env.ts";
|
|
33
34
|
|
|
34
35
|
export interface BootOpts {
|
|
35
36
|
/** Path to services.json. */
|
|
@@ -197,7 +198,20 @@ export function buildModuleSpawnRequest(
|
|
|
197
198
|
PORT: String(entry.port),
|
|
198
199
|
...fileEnvSansPort,
|
|
199
200
|
};
|
|
200
|
-
if (opts.hubOrigin)
|
|
201
|
+
if (opts.hubOrigin) {
|
|
202
|
+
env[HUB_ORIGIN_ENV] = opts.hubOrigin;
|
|
203
|
+
// Multi-origin iss-set (onboarding-streamline 2026-06-25): alongside the
|
|
204
|
+
// single canonical origin, inject the SET of origins this hub legitimately
|
|
205
|
+
// answers on (issuer ∪ loopback aliases ∪ expose-state ∪ platform). A
|
|
206
|
+
// resource server on scope-guard ≥0.5.0 widens its accepted-`iss` check to
|
|
207
|
+
// this set, so a token minted under one URL of a multi-URL box validates
|
|
208
|
+
// when the resource is reached via another URL of the SAME box. SECURITY:
|
|
209
|
+
// the set is hub-controlled config/disk state only, NEVER a request Host —
|
|
210
|
+
// see `buildHubOriginsEnvValue`. The single `PARACHUTE_HUB_ORIGIN` above
|
|
211
|
+
// stays for back-compat with older scope-guard.
|
|
212
|
+
const originsValue = buildHubOriginsEnvValue(opts.configDir, opts.hubOrigin);
|
|
213
|
+
if (originsValue) env[HUB_ORIGINS_ENV] = originsValue;
|
|
214
|
+
}
|
|
201
215
|
if (opts.extraEnv) Object.assign(env, opts.extraEnv);
|
|
202
216
|
|
|
203
217
|
const req: SpawnReqShape = { short, cmd };
|
package/src/hub-origin.ts
CHANGED
|
@@ -14,6 +14,70 @@
|
|
|
14
14
|
|
|
15
15
|
export const HUB_ORIGIN_ENV = "PARACHUTE_HUB_ORIGIN";
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* The env var carrying the SET of origins the hub legitimately answers on,
|
|
19
|
+
* comma-separated (multi-origin iss-set, onboarding-streamline 2026-06-25).
|
|
20
|
+
*
|
|
21
|
+
* Supervised resource servers (vault, scribe) read this in addition to the
|
|
22
|
+
* single canonical `PARACHUTE_HUB_ORIGIN` and widen their accepted-`iss` check
|
|
23
|
+
* from one string to this set — so a token minted under one URL of a
|
|
24
|
+
* multi-URL box (loopback ∪ `<ip>.sslip.io` ∪ a custom domain behind Caddy)
|
|
25
|
+
* validates when the resource is reached via another URL of the SAME box. The
|
|
26
|
+
* signing key is stable + origin-independent, so only the `iss` string varies.
|
|
27
|
+
*
|
|
28
|
+
* SECURITY INVARIANT: the value MUST be the hub's `buildHubBoundOrigins`
|
|
29
|
+
* output — configured issuer ∪ loopback aliases ∪ expose-state public origin ∪
|
|
30
|
+
* platform origin — published BY THE HUB. It must NEVER contain an unvalidated
|
|
31
|
+
* request `Host` / `X-Forwarded-Host`. Accepting `iss ∈ this-set` is safe
|
|
32
|
+
* ONLY because the resource server's JWKS signature verify runs first and
|
|
33
|
+
* proves the hub minted the token.
|
|
34
|
+
*
|
|
35
|
+
* `PARACHUTE_HUB_ORIGIN` (the single canonical origin) is ALWAYS written
|
|
36
|
+
* alongside this var for back-compat: a resource server on an older
|
|
37
|
+
* scope-guard that doesn't read `PARACHUTE_HUB_ORIGINS` keeps its existing
|
|
38
|
+
* single-origin behavior.
|
|
39
|
+
*/
|
|
40
|
+
export const HUB_ORIGINS_ENV = "PARACHUTE_HUB_ORIGINS";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Serialize an origin SET into the comma-separated `PARACHUTE_HUB_ORIGINS`
|
|
44
|
+
* wire form. Dedupes, drops empties, preserves first-seen order. Returns
|
|
45
|
+
* `undefined` when nothing survives (caller skips setting the env var so a
|
|
46
|
+
* resource server falls back to single-origin behavior). Pair with
|
|
47
|
+
* `buildHubBoundOrigins` to produce `origins`.
|
|
48
|
+
*/
|
|
49
|
+
export function serializeHubOrigins(origins: readonly string[]): string | undefined {
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
for (const raw of origins) {
|
|
52
|
+
if (typeof raw !== "string") continue;
|
|
53
|
+
const trimmed = raw.trim().replace(/\/+$/, "");
|
|
54
|
+
if (trimmed.length > 0) seen.add(trimmed);
|
|
55
|
+
}
|
|
56
|
+
if (seen.size === 0) return undefined;
|
|
57
|
+
return Array.from(seen).join(",");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Parse the comma-separated `PARACHUTE_HUB_ORIGINS` wire form back into an
|
|
62
|
+
* origin array. Tolerant of surrounding whitespace + trailing slashes + empty
|
|
63
|
+
* segments. The resource-server inverse of `serializeHubOrigins`; lives here
|
|
64
|
+
* (rather than only in the consumer adapters) so the hub's own injection tests
|
|
65
|
+
* can round-trip against the canonical parser. Returns `[]` for an
|
|
66
|
+
* absent/empty/garbage value.
|
|
67
|
+
*/
|
|
68
|
+
export function parseHubOrigins(raw: string | undefined): string[] {
|
|
69
|
+
if (!raw) return [];
|
|
70
|
+
const out: string[] = [];
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
for (const seg of raw.split(",")) {
|
|
73
|
+
const trimmed = seg.trim().replace(/\/+$/, "");
|
|
74
|
+
if (trimmed.length === 0 || seen.has(trimmed)) continue;
|
|
75
|
+
seen.add(trimmed);
|
|
76
|
+
out.push(trimmed);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
17
81
|
export interface DeriveHubOriginOpts {
|
|
18
82
|
/** Explicit user override (e.g., `--hub-origin`). Wins over everything else. */
|
|
19
83
|
override?: string;
|
package/src/jwt-sign.ts
CHANGED
|
@@ -55,9 +55,20 @@ export interface SignAccessTokenOpts {
|
|
|
55
55
|
clientId: string;
|
|
56
56
|
/**
|
|
57
57
|
* Hub origin — sets the `iss` claim. Required: every consumer (vault,
|
|
58
|
-
* scribe,
|
|
59
|
-
*
|
|
60
|
-
*
|
|
58
|
+
* scribe, agent) validates `iss`, and a missing claim is rejected. Callers
|
|
59
|
+
* derive this via `deriveHubOrigin()` or thread it from `OAuthDeps.issuer`
|
|
60
|
+
* (the per-request origin from `resolveIssuer`).
|
|
61
|
+
*
|
|
62
|
+
* Multi-origin iss-set (onboarding-streamline 2026-06-25): consumers no
|
|
63
|
+
* longer pin `iss` to a SINGLE `PARACHUTE_HUB_ORIGIN`. The hub publishes the
|
|
64
|
+
* SET of origins it legitimately answers on via `PARACHUTE_HUB_ORIGINS`
|
|
65
|
+
* (`buildHubBoundOrigins` — issuer ∪ loopback ∪ expose-state ∪ platform),
|
|
66
|
+
* and a scope-guard ≥0.5.0 consumer accepts `iss` ∈ that set. So a token
|
|
67
|
+
* minted here with one URL of a multi-URL box validates when the resource is
|
|
68
|
+
* reached via another URL of the SAME box. Mint stays per-request as-is: the
|
|
69
|
+
* canonical configured origin (the intended Caddy/zero-SSH deploy) is minted
|
|
70
|
+
* on every request AND is a member of the published set, so mint and validate
|
|
71
|
+
* already align — no clamp needed here.
|
|
61
72
|
*/
|
|
62
73
|
issuer: string;
|
|
63
74
|
/**
|
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
import { join } from "node:path";
|
|
30
30
|
import { parseEnvFile, removeEnvLine, upsertEnvLine, writeEnvFile } from "./env-file.ts";
|
|
31
31
|
import { EXPOSE_STATE_PATH, readExposeState } from "./expose-state.ts";
|
|
32
|
-
import {
|
|
32
|
+
import { readHubPort } from "./hub-control.ts";
|
|
33
|
+
import { HUB_ORIGINS_ENV, HUB_ORIGIN_ENV, serializeHubOrigins } from "./hub-origin.ts";
|
|
34
|
+
import { HUB_UNIT_DEFAULT_PORT } from "./hub-unit.ts";
|
|
35
|
+
import { buildHubBoundOrigins } from "./origin-check.ts";
|
|
33
36
|
|
|
34
37
|
/**
|
|
35
38
|
* Loopback origins (`http://127.0.0.1:<port>`, `localhost`, `[::1]`) are the
|
|
@@ -91,11 +94,80 @@ function vaultEnvPath(configDir: string): string {
|
|
|
91
94
|
return join(configDir, "vault", ".env");
|
|
92
95
|
}
|
|
93
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Compose `https://${FLY_APP_NAME}.fly.dev` when FLY_APP_NAME is a plausible
|
|
99
|
+
* Fly app slug (no slashes — Fly slugs don't contain them). Mirrors the
|
|
100
|
+
* private helper in operator-token.ts / hub-server.ts; kept local so the
|
|
101
|
+
* origin-SET assembly here doesn't reach across modules for a 3-line guard.
|
|
102
|
+
*/
|
|
103
|
+
function flyDefaultOriginFromEnv(env: NodeJS.ProcessEnv): string | undefined {
|
|
104
|
+
const app = env.FLY_APP_NAME;
|
|
105
|
+
if (typeof app !== "string" || app.length === 0 || app.includes("/")) return undefined;
|
|
106
|
+
return `https://${app}.fly.dev`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Assemble the comma-separated `PARACHUTE_HUB_ORIGINS` value the hub injects
|
|
111
|
+
* into supervised resource servers (multi-origin iss-set). The SET is the
|
|
112
|
+
* hub's own legitimate origins — the env-injection sibling of the operator
|
|
113
|
+
* token's `buildKnownIssuersForOperatorToken` and the per-request
|
|
114
|
+
* `buildHubBoundOrigins` call in hub-server.ts. Inputs:
|
|
115
|
+
*
|
|
116
|
+
* - `issuer` — the canonical hub origin the child also receives as the
|
|
117
|
+
* single `PARACHUTE_HUB_ORIGIN` (the seed; always included).
|
|
118
|
+
* - loopback aliases — `http://127.0.0.1:<port>` ∪ `http://localhost:<port>`
|
|
119
|
+
* for the hub's port (`readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT`).
|
|
120
|
+
* - the expose-state public origin — `expose-state.json`'s `hubOrigin`.
|
|
121
|
+
* - the platform/env public origin — `RENDER_EXTERNAL_URL` ∪ the composed
|
|
122
|
+
* Fly default (container deploys where the public origin comes from the
|
|
123
|
+
* platform, not expose-state).
|
|
124
|
+
*
|
|
125
|
+
* SECURITY INVARIANT: every input is hub/operator-controlled config or
|
|
126
|
+
* on-disk state — NEVER an unvalidated request `Host` / `X-Forwarded-Host`.
|
|
127
|
+
* The accepted-`iss` widening this enables is safe only because the resource
|
|
128
|
+
* server verifies the JWKS signature first; this set is the belt-and-
|
|
129
|
+
* suspenders allowlist layered on top.
|
|
130
|
+
*
|
|
131
|
+
* Returns `undefined` when the seed issuer is absent AND nothing else resolves
|
|
132
|
+
* (caller skips the env var so the child keeps single-origin behavior).
|
|
133
|
+
*/
|
|
134
|
+
export function buildHubOriginsEnvValue(
|
|
135
|
+
configDir: string,
|
|
136
|
+
issuer: string | undefined,
|
|
137
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
138
|
+
exposeStatePath: string = EXPOSE_STATE_PATH,
|
|
139
|
+
): string | undefined {
|
|
140
|
+
const loopbackPort = readHubPort(configDir) ?? HUB_UNIT_DEFAULT_PORT;
|
|
141
|
+
let exposeHubOrigin: string | undefined;
|
|
142
|
+
try {
|
|
143
|
+
exposeHubOrigin = readExposeState(exposeStatePath)?.hubOrigin;
|
|
144
|
+
} catch {
|
|
145
|
+
// A malformed expose-state.json must never block a module spawn — the seed
|
|
146
|
+
// issuer + loopback aliases already cover legitimate access.
|
|
147
|
+
exposeHubOrigin = undefined;
|
|
148
|
+
}
|
|
149
|
+
const platformOrigin = env.RENDER_EXTERNAL_URL ?? flyDefaultOriginFromEnv(env);
|
|
150
|
+
const origins = buildHubBoundOrigins({
|
|
151
|
+
// buildHubBoundOrigins requires `issuer`; pass "" when absent (it's dropped
|
|
152
|
+
// by the URL parse) so the loopback aliases still seed the set.
|
|
153
|
+
issuer: issuer ?? "",
|
|
154
|
+
loopbackPort,
|
|
155
|
+
...(exposeHubOrigin !== undefined ? { exposeHubOrigin } : {}),
|
|
156
|
+
...(platformOrigin !== undefined ? { platformOrigin } : {}),
|
|
157
|
+
});
|
|
158
|
+
return serializeHubOrigins(origins);
|
|
159
|
+
}
|
|
160
|
+
|
|
94
161
|
/**
|
|
95
162
|
* Upsert `PARACHUTE_HUB_ORIGIN=<origin>` into `vault/.env` when `origin` is a
|
|
96
|
-
* non-loopback public origin
|
|
97
|
-
*
|
|
98
|
-
*
|
|
163
|
+
* non-loopback public origin, AND the `PARACHUTE_HUB_ORIGINS` set (the
|
|
164
|
+
* multi-origin iss-set: origin ∪ loopback aliases ∪ expose-state ∪ platform)
|
|
165
|
+
* so the daemon-boot path validates `iss` against every URL the box answers on
|
|
166
|
+
* (a Caddy-fronted box reached via loopback + sslip.io + a custom domain at
|
|
167
|
+
* once). The set is assembled from hub-controlled inputs only (see
|
|
168
|
+
* `buildHubOriginsEnvValue`'s security invariant). Idempotent — skips the
|
|
169
|
+
* write (and the log) when BOTH values are already current so repeated
|
|
170
|
+
* `start`s don't churn the file. Returns true iff the file was written.
|
|
99
171
|
*/
|
|
100
172
|
export function persistVaultHubOrigin(
|
|
101
173
|
configDir: string,
|
|
@@ -105,26 +177,47 @@ export function persistVaultHubOrigin(
|
|
|
105
177
|
if (isLoopbackOrigin(origin)) return false;
|
|
106
178
|
const path = vaultEnvPath(configDir);
|
|
107
179
|
const parsed = parseEnvFile(path);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
180
|
+
const originsValue = buildHubOriginsEnvValue(configDir, origin);
|
|
181
|
+
const originCurrent = parsed.values[HUB_ORIGIN_ENV] === origin;
|
|
182
|
+
const originsCurrent =
|
|
183
|
+
originsValue === undefined || parsed.values[HUB_ORIGINS_ENV] === originsValue;
|
|
184
|
+
if (originCurrent && originsCurrent) return false;
|
|
185
|
+
let lines = parsed.lines;
|
|
186
|
+
if (!originCurrent) lines = upsertEnvLine(lines, HUB_ORIGIN_ENV, origin);
|
|
187
|
+
if (!originsCurrent && originsValue !== undefined) {
|
|
188
|
+
lines = upsertEnvLine(lines, HUB_ORIGINS_ENV, originsValue);
|
|
189
|
+
}
|
|
190
|
+
writeEnvFile(path, lines);
|
|
191
|
+
if (!originCurrent) {
|
|
192
|
+
log(` persisted ${HUB_ORIGIN_ENV}=${origin} to ${path} (survives daemon restart)`);
|
|
193
|
+
}
|
|
194
|
+
if (!originsCurrent && originsValue !== undefined) {
|
|
195
|
+
log(` persisted ${HUB_ORIGINS_ENV}=${originsValue} to ${path} (multi-origin iss-set)`);
|
|
196
|
+
}
|
|
111
197
|
return true;
|
|
112
198
|
}
|
|
113
199
|
|
|
114
200
|
/**
|
|
115
|
-
* Drop a previously-persisted `PARACHUTE_HUB_ORIGIN`
|
|
116
|
-
* on `expose … off`: once exposure is torn down, a
|
|
117
|
-
* with a loopback `iss`, so a stale public origin
|
|
118
|
-
* cause the mismatch. Removing the
|
|
119
|
-
* (`getHubOrigin`), which matches what the local
|
|
120
|
-
* false) when
|
|
201
|
+
* Drop a previously-persisted `PARACHUTE_HUB_ORIGIN` (and `PARACHUTE_HUB_ORIGINS`)
|
|
202
|
+
* from `vault/.env`. Called on `expose … off`: once exposure is torn down, a
|
|
203
|
+
* local-only hub mints tokens with a loopback `iss`, so a stale public origin
|
|
204
|
+
* left in `.env` would itself cause the mismatch. Removing the lines reverts
|
|
205
|
+
* vault to its loopback default (`getHubOrigin`), which matches what the local
|
|
206
|
+
* hub now stamps. No-op (returns false) when neither key is present. Returns
|
|
207
|
+
* true iff the file was rewritten.
|
|
121
208
|
*/
|
|
122
209
|
export function clearVaultHubOrigin(configDir: string, log: (line: string) => void): boolean {
|
|
123
210
|
const path = vaultEnvPath(configDir);
|
|
124
211
|
const parsed = parseEnvFile(path);
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
212
|
+
const hadOrigin = parsed.values[HUB_ORIGIN_ENV] !== undefined;
|
|
213
|
+
const hadOrigins = parsed.values[HUB_ORIGINS_ENV] !== undefined;
|
|
214
|
+
if (!hadOrigin && !hadOrigins) return false;
|
|
215
|
+
let lines = parsed.lines;
|
|
216
|
+
if (hadOrigin) lines = removeEnvLine(lines, HUB_ORIGIN_ENV);
|
|
217
|
+
if (hadOrigins) lines = removeEnvLine(lines, HUB_ORIGINS_ENV);
|
|
218
|
+
writeEnvFile(path, lines);
|
|
219
|
+
if (hadOrigin) log(` cleared ${HUB_ORIGIN_ENV} from ${path} (exposure torn down)`);
|
|
220
|
+
if (hadOrigins) log(` cleared ${HUB_ORIGINS_ENV} from ${path} (exposure torn down)`);
|
|
128
221
|
return true;
|
|
129
222
|
}
|
|
130
223
|
|