@openparachute/hub 0.7.4-rc.3 → 0.7.4-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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/hub",
3
- "version": "0.7.4-rc.3",
3
+ "version": "0.7.4-rc.4",
4
4
  "description": "parachute — the local hub for the Parachute ecosystem (discovery, ports, lifecycle, soon OAuth).",
5
5
  "license": "AGPL-3.0",
6
6
  "publishConfig": {
@@ -151,7 +151,10 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
151
151
  const hubOrigins = [LOOPBACK, "http://localhost:1939", PUBLIC];
152
152
 
153
153
  test("expands a loopback-rooted URI onto every other hub origin", () => {
154
- const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/oauth/callback`], hubOrigins);
154
+ const out = expandRedirectUrisForHubOrigins(
155
+ [`${LOOPBACK}/surface/notes/oauth/callback`],
156
+ hubOrigins,
157
+ );
155
158
  // Original is preserved + the public + localhost variants are added.
156
159
  expect(out).toContain(`${LOOPBACK}/surface/notes/oauth/callback`);
157
160
  expect(out).toContain(`${PUBLIC}/surface/notes/oauth/callback`);
@@ -188,10 +191,7 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
188
191
  });
189
192
 
190
193
  test("single known hub origin → no expansion (submitted set returned as-is)", () => {
191
- const out = expandRedirectUrisForHubOrigins(
192
- [`${LOOPBACK}/surface/notes/`],
193
- [LOOPBACK],
194
- );
194
+ const out = expandRedirectUrisForHubOrigins([`${LOOPBACK}/surface/notes/`], [LOOPBACK]);
195
195
  expect(out).toEqual([`${LOOPBACK}/surface/notes/`]);
196
196
  });
197
197
 
@@ -222,9 +222,9 @@ describe("expandRedirectUrisForHubOrigins (surface#118 cross-hub-origin DCR expa
222
222
  const r = registerClient(db, { redirectUris: expanded });
223
223
  // The public-origin variant now matches exactly at authorize time — the
224
224
  // off-localhost sign-in that surface#118 broke.
225
- expect(
226
- requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`),
227
- ).toBe(`${PUBLIC}/surface/notes/oauth/callback`);
225
+ expect(requireRegisteredRedirectUri(r.client, `${PUBLIC}/surface/notes/oauth/callback`)).toBe(
226
+ `${PUBLIC}/surface/notes/oauth/callback`,
227
+ );
228
228
  // A truly-unregistered URI is still rejected — strict match unchanged.
229
229
  expect(() =>
230
230
  requireRegisteredRedirectUri(r.client, "https://evil.example/surface/notes/oauth/callback"),
@@ -352,4 +352,24 @@ describe("isValidRedirectUri", () => {
352
352
  expect(isValidRedirectUri("/relative")).toBe(false);
353
353
  expect(isValidRedirectUri("not a url")).toBe(false);
354
354
  });
355
+ // hub#663: spec-forbidden shapes that the protocol allowlist alone passed.
356
+ test("rejects userinfo-bearing redirect URIs (hub#663)", () => {
357
+ expect(isValidRedirectUri("https://x@evil.com/cb")).toBe(false);
358
+ expect(isValidRedirectUri("https://user:pass@evil.com/cb")).toBe(false);
359
+ expect(isValidRedirectUri("http://attacker@127.0.0.1:3000/cb")).toBe(false);
360
+ });
361
+ test("rejects control chars in the raw input (hub#663)", () => {
362
+ // Control chars must be caught on the RAW string — URL parsing would
363
+ // otherwise strip a trailing \r\n and the smuggled value would pass.
364
+ expect(isValidRedirectUri("https://example.com/cb\r\nSet-Cookie: x")).toBe(false);
365
+ expect(isValidRedirectUri("https://example.com/\x00cb")).toBe(false);
366
+ expect(isValidRedirectUri("https://example.com/cb\x7f")).toBe(false);
367
+ });
368
+ test("still accepts clean http(s) with ports, paths, and queries (regression guard)", () => {
369
+ // Legitimate clients (hub modules, self-built surfaces, Notes, Claude DCR)
370
+ // all register clean URIs — these must keep passing.
371
+ expect(isValidRedirectUri("https://claude.ai/api/mcp/auth_callback")).toBe(true);
372
+ expect(isValidRedirectUri("http://localhost:1939/admin/oauth/callback")).toBe(true);
373
+ expect(isValidRedirectUri("https://my-surface.github.io/cb?x=1")).toBe(true);
374
+ });
355
375
  });
@@ -3,10 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import {
7
- _resetBootstrapTokenForTests,
8
- generateBootstrapToken,
9
- } from "../bootstrap-token.ts";
6
+ import { _resetBootstrapTokenForTests, generateBootstrapToken } from "../bootstrap-token.ts";
10
7
  import { buildCsrfCookie, generateCsrfToken } from "../csrf.ts";
11
8
  import { HUB_SVC, hubPortPath } from "../hub-control.ts";
12
9
  import { createDbHolder } from "../hub-db-liveness.ts";
@@ -3715,7 +3712,9 @@ describe("layerOf — classify trust layer from proxy headers + peer (item E / #
3715
3712
  // flipped an empty XFF back to loopback would re-open the Caddy-direct leak.
3716
3713
  test("loopback peer + empty X-Forwarded-For → public (errs safe, not loopback) [#704]", () => {
3717
3714
  expect(layerOf(req("/", { headers: { "X-Forwarded-For": "" } }), "127.0.0.1")).toBe("public");
3718
- expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe("public");
3715
+ expect(layerOf(req("/", { headers: { "X-Forwarded-For": " " } }), "127.0.0.1")).toBe(
3716
+ "public",
3717
+ );
3719
3718
  });
3720
3719
 
3721
3720
  // The genuine on-box caller (CLI, health probe, init bootstrap-token loopback
@@ -6090,3 +6089,126 @@ describe("GET /admin/setup bootstrap-token probe — loopback-gated (hub#576 + C
6090
6089
  }
6091
6090
  });
6092
6091
  });
6092
+
6093
+ // hub#643 (Tier-1): non-script security headers on proxied module/surface
6094
+ // text/html pages. The vault content proxy and the generic services-mount
6095
+ // proxy both flow through `decorateWithChrome`, so the headers land on both.
6096
+ // DELIBERATELY no `script-src` — a strict script-src would white-screen
6097
+ // self-built GitHub-hosted surfaces + inline-script module pages (that's the
6098
+ // deferred Tier-2). Header-only: non-HTML proxied responses are NOT decorated.
6099
+ describe("hubFetch proxied-page security headers (hub#643 Tier-1)", () => {
6100
+ const TIER1_CSP = "frame-ancestors 'self'; object-src 'none'; base-uri 'self'";
6101
+
6102
+ // Live upstream that echoes a fixed content-type + body so the test can
6103
+ // exercise both the text/html (decorated) and JSON (untouched) branches.
6104
+ function startUpstream(contentType: string, body: string): { port: number; stop: () => void } {
6105
+ const server = Bun.serve({
6106
+ port: 0,
6107
+ hostname: "127.0.0.1",
6108
+ fetch: () => new Response(body, { status: 200, headers: { "content-type": contentType } }),
6109
+ });
6110
+ return { port: server.port as number, stop: () => server.stop(true) };
6111
+ }
6112
+
6113
+ test("decorates a proxied text/html generic-mount page with nosniff + the Tier-1 CSP", async () => {
6114
+ const h = makeHarness();
6115
+ const upstream = startUpstream(
6116
+ "text/html; charset=utf-8",
6117
+ "<html><body><h1>my surface</h1></body></html>",
6118
+ );
6119
+ try {
6120
+ writeManifest(
6121
+ {
6122
+ services: [
6123
+ {
6124
+ name: "parachute-surface",
6125
+ port: upstream.port,
6126
+ paths: ["/surface"],
6127
+ health: "/surface/health",
6128
+ version: "0.2.0",
6129
+ },
6130
+ ],
6131
+ },
6132
+ h.manifestPath,
6133
+ );
6134
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/foo"));
6135
+ expect(res.status).toBe(200);
6136
+ expect(res.headers.get("content-type")).toContain("text/html");
6137
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
6138
+ const csp = res.headers.get("content-security-policy");
6139
+ expect(csp).toBe(TIER1_CSP);
6140
+ // The critical Tier-1/Tier-2 boundary: NO script-src — self-built
6141
+ // GitHub-hosted surfaces + inline-script module pages must stay
6142
+ // unrestricted. A strict script-src is the deferred Tier-2.
6143
+ expect(csp).not.toContain("script-src");
6144
+ } finally {
6145
+ upstream.stop();
6146
+ h.cleanup();
6147
+ }
6148
+ });
6149
+
6150
+ test("decorates a proxied text/html per-vault page (the Notes-PWA path) with the same headers", async () => {
6151
+ const h = makeHarness();
6152
+ const upstream = startUpstream("text/html; charset=utf-8", "<html><body>notes</body></html>");
6153
+ try {
6154
+ writeManifest(
6155
+ {
6156
+ services: [
6157
+ {
6158
+ name: "parachute-vault",
6159
+ port: upstream.port,
6160
+ paths: ["/vault/default"],
6161
+ health: "/vault/default/health",
6162
+ version: "0.4.0",
6163
+ },
6164
+ ],
6165
+ },
6166
+ h.manifestPath,
6167
+ );
6168
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(
6169
+ req("/vault/default/some-page"),
6170
+ );
6171
+ expect(res.status).toBe(200);
6172
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
6173
+ expect(res.headers.get("content-security-policy")).toBe(TIER1_CSP);
6174
+ expect(res.headers.get("content-security-policy")).not.toContain("script-src");
6175
+ } finally {
6176
+ upstream.stop();
6177
+ h.cleanup();
6178
+ }
6179
+ });
6180
+
6181
+ test("leaves a proxied NON-HTML response (JSON) undecorated", async () => {
6182
+ const h = makeHarness();
6183
+ const upstream = startUpstream("application/json", JSON.stringify({ ok: true }));
6184
+ try {
6185
+ writeManifest(
6186
+ {
6187
+ services: [
6188
+ {
6189
+ name: "parachute-surface",
6190
+ port: upstream.port,
6191
+ paths: ["/surface"],
6192
+ health: "/surface/health",
6193
+ version: "0.2.0",
6194
+ },
6195
+ ],
6196
+ },
6197
+ h.manifestPath,
6198
+ );
6199
+ const res = await hubFetch(h.dir, { manifestPath: h.manifestPath })(req("/surface/api/data"));
6200
+ expect(res.status).toBe(200);
6201
+ expect(res.headers.get("content-type")).toContain("application/json");
6202
+ // No HTML CSP on a JSON API response (proves the header is gated on
6203
+ // content-type, so a `.js` asset proxied through the same path is also
6204
+ // left alone).
6205
+ expect(res.headers.get("content-security-policy")).toBeNull();
6206
+ expect(res.headers.get("x-content-type-options")).toBeNull();
6207
+ const body = (await res.json()) as { ok: boolean };
6208
+ expect(body.ok).toBe(true);
6209
+ } finally {
6210
+ upstream.stop();
6211
+ h.cleanup();
6212
+ }
6213
+ });
6214
+ });
@@ -990,6 +990,123 @@ describe("handleSetupGet", () => {
990
990
  db.close();
991
991
  }
992
992
  });
993
+
994
+ // hub#618: gate the JSON `?op=` op-snapshot once setup is complete.
995
+ // Mid-setup it stays OPEN (the unauth CLI wizard + brand-new-operator
996
+ // browser both poll it before any session exists); post-complete it
997
+ // requires a session or loopback (it's a post-setup admin surface, and
998
+ // `/admin/setup` is always lockout-exempt so it's otherwise unauth-reachable).
999
+
1000
+ test("mid-setup unauth ?op= still returns the op snapshot (hub#618 regression guard)", async () => {
1001
+ const db = openHubDb(hubDbPath(h.dir));
1002
+ try {
1003
+ // No admin yet → setup INCOMPLETE → the surface stays open.
1004
+ const reg = getDefaultOperationsRegistry();
1005
+ const op = reg.create("install", "vault");
1006
+ reg.update(op.id, { status: "running" }, "running bun add -g @openparachute/vault@latest");
1007
+ const res = handleSetupGet(
1008
+ req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
1009
+ {
1010
+ db,
1011
+ manifestPath: h.manifestPath,
1012
+ configDir: h.dir,
1013
+ readExposeStateFn: h.readExposeStateFn,
1014
+ issuer: "https://hub.example",
1015
+ registry: reg,
1016
+ // No loopback flag, no session — the unauth first-boot poll.
1017
+ },
1018
+ );
1019
+ expect(res.status).toBe(200);
1020
+ const body = (await res.json()) as {
1021
+ hasAdmin: boolean;
1022
+ operation?: { id: string; status: string; log: readonly string[] };
1023
+ };
1024
+ expect(body.hasAdmin).toBe(false);
1025
+ expect(body.operation).toBeDefined();
1026
+ expect(body.operation?.id).toBe(op.id);
1027
+ expect(body.operation?.status).toBe("running");
1028
+ } finally {
1029
+ db.close();
1030
+ }
1031
+ });
1032
+
1033
+ test("post-complete unauth ?op= omits the op snapshot; session OR loopback restores it (hub#618)", async () => {
1034
+ const db = openHubDb(hubDbPath(h.dir));
1035
+ try {
1036
+ // Drive state to COMPLETE: admin + vault + expose mode.
1037
+ const user = await createUser(db, "owner", "pw");
1038
+ writeManifest(
1039
+ {
1040
+ services: [
1041
+ {
1042
+ name: "parachute-vault",
1043
+ version: "0.1.0",
1044
+ port: 1940,
1045
+ paths: ["/vault/default"],
1046
+ health: "/health",
1047
+ },
1048
+ ],
1049
+ },
1050
+ h.manifestPath,
1051
+ );
1052
+ setSetting(db, "setup_expose_mode", "localhost");
1053
+ const reg = getDefaultOperationsRegistry();
1054
+ const op = reg.create("install", "vault");
1055
+ reg.update(op.id, { status: "running" }, "still running");
1056
+
1057
+ const deps = {
1058
+ db,
1059
+ manifestPath: h.manifestPath,
1060
+ configDir: h.dir,
1061
+ readExposeStateFn: h.readExposeStateFn,
1062
+ issuer: "https://hub.example",
1063
+ registry: reg,
1064
+ };
1065
+
1066
+ // (a) Unauth, non-loopback → operation omitted.
1067
+ const unauth = handleSetupGet(
1068
+ req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
1069
+ deps,
1070
+ );
1071
+ expect(unauth.status).toBe(200);
1072
+ const unauthBody = (await unauth.json()) as {
1073
+ hasAdmin: boolean;
1074
+ hasVault: boolean;
1075
+ hasExposeMode: boolean;
1076
+ operation?: unknown;
1077
+ };
1078
+ // Confirm setup actually derived as complete (else the gate is vacuous).
1079
+ expect(unauthBody.hasAdmin).toBe(true);
1080
+ expect(unauthBody.hasVault).toBe(true);
1081
+ expect(unauthBody.hasExposeMode).toBe(true);
1082
+ expect(unauthBody.operation).toBeUndefined();
1083
+
1084
+ // (b) Valid session → operation restored.
1085
+ const { createSession } = await import("../sessions.ts");
1086
+ const session = createSession(db, { userId: user.id });
1087
+ const authed = handleSetupGet(
1088
+ req(`/admin/setup?op=${op.id}`, {
1089
+ headers: {
1090
+ accept: "application/json",
1091
+ cookie: `${SESSION_COOKIE_NAME}=${session.id}`,
1092
+ },
1093
+ }),
1094
+ deps,
1095
+ );
1096
+ const authedBody = (await authed.json()) as { operation?: { id: string } };
1097
+ expect(authedBody.operation?.id).toBe(op.id);
1098
+
1099
+ // (c) Loopback (no session) → operation restored.
1100
+ const loopback = handleSetupGet(
1101
+ req(`/admin/setup?op=${op.id}`, { headers: { accept: "application/json" } }),
1102
+ { ...deps, requestIsLoopback: true },
1103
+ );
1104
+ const loopbackBody = (await loopback.json()) as { operation?: { id: string } };
1105
+ expect(loopbackBody.operation?.id).toBe(op.id);
1106
+ } finally {
1107
+ db.close();
1108
+ }
1109
+ });
993
1110
  });
994
1111
 
995
1112
  // --- POST /admin/setup/account -------------------------------------------
package/src/clients.ts CHANGED
@@ -323,9 +323,23 @@ function timingSafeEqualHex(a: string, b: string): boolean {
323
323
  * URIs). Doesn't try to match a registered URI; that's `requireRegisteredRedirectUri`.
324
324
  */
325
325
  export function isValidRedirectUri(uri: string): boolean {
326
+ // hub#663: reject control chars (C0 0x00-0x1f + DEL 0x7f) in the RAW input
327
+ // BEFORE URL parsing normalizes/strips them. A `\r`/`\n`/NUL smuggled into a
328
+ // redirect_uri is a header/log-injection vector even though our exact-match +
329
+ // verbatim foreign-storage neutralize it downstream — spec-forbidden hygiene.
330
+ // (Charcode scan rather than a control-char regex literal, which biome's
331
+ // noControlCharactersInRegex rightly flags as an easy footgun.)
332
+ for (let i = 0; i < uri.length; i++) {
333
+ const c = uri.charCodeAt(i);
334
+ if (c <= 0x1f || c === 0x7f) return false;
335
+ }
326
336
  try {
327
337
  const u = new URL(uri);
328
338
  if (u.protocol === "javascript:" || u.protocol === "data:") return false;
339
+ // hub#663: reject userinfo (`https://x@evil.com/cb`). A redirect target
340
+ // carrying credentials is spec-forbidden and an open-redirect / phishing
341
+ // shape; the protocol allowlist alone let it through.
342
+ if (u.username !== "" || u.password !== "") return false;
329
343
  return u.protocol === "http:" || u.protocol === "https:";
330
344
  } catch {
331
345
  return false;
package/src/hub-server.ts CHANGED
@@ -3824,13 +3824,57 @@ async function decorateWithChrome(
3824
3824
  if (setCookie && out !== res) {
3825
3825
  const headers = new Headers(out.headers);
3826
3826
  headers.append("set-cookie", setCookie);
3827
- return new Response(out.body, {
3828
- status: out.status,
3829
- statusText: out.statusText,
3830
- headers,
3831
- });
3827
+ return withProxySecurityHeaders(
3828
+ new Response(out.body, {
3829
+ status: out.status,
3830
+ statusText: out.statusText,
3831
+ headers,
3832
+ }),
3833
+ );
3832
3834
  }
3833
- return out;
3835
+ // hub#643: every exit runs through the security-header step, which self-
3836
+ // gates on content-type — so a non-HTML pass-through (`out === res`, e.g. a
3837
+ // 502 proxy error or a JSON/asset body) is returned unchanged, preserving
3838
+ // the pre-existing behavior for those responses.
3839
+ return withProxySecurityHeaders(out);
3840
+ }
3841
+
3842
+ /**
3843
+ * hub#643 (Tier-1): stamp non-script security headers on proxied `text/html`
3844
+ * pages — the per-vault `/vault/<name>/*` proxy and the generic
3845
+ * services-mount `/<mount>/*` proxy both flow through `decorateWithChrome`,
3846
+ * so this is the single chokepoint that covers a module / surface page.
3847
+ *
3848
+ * - `X-Content-Type-Options: nosniff` — stops content-type sniffing.
3849
+ * - `Content-Security-Policy: frame-ancestors 'self'; object-src 'none';
3850
+ * base-uri 'self'` — clickjacking (external framing) + plugin + base-tag
3851
+ * hardening.
3852
+ *
3853
+ * Deliberately NO `script-src`: a strict script-src would white-screen
3854
+ * self-built GitHub-hosted surfaces (the primary surface story) and
3855
+ * inline-script module pages. The opt-in strict script-src CSP is Tier-2,
3856
+ * explicitly deferred (hub#643 stays open).
3857
+ *
3858
+ * Header-only: we never buffer the body. Only `text/html` responses are
3859
+ * decorated, so JSON / `.js` / CSS / image assets proxied through the same
3860
+ * path are left untouched. Existing headers are preserved (a fresh Headers
3861
+ * copy is mutated); we set (not append) so a re-decorated response can't
3862
+ * accumulate duplicates.
3863
+ */
3864
+ function withProxySecurityHeaders(res: Response): Response {
3865
+ const contentType = res.headers.get("content-type") ?? "";
3866
+ if (!contentType.toLowerCase().includes("text/html")) return res;
3867
+ const headers = new Headers(res.headers);
3868
+ headers.set("x-content-type-options", "nosniff");
3869
+ headers.set(
3870
+ "content-security-policy",
3871
+ "frame-ancestors 'self'; object-src 'none'; base-uri 'self'",
3872
+ );
3873
+ return new Response(res.body, {
3874
+ status: res.status,
3875
+ statusText: res.statusText,
3876
+ headers,
3877
+ });
3834
3878
  }
3835
3879
 
3836
3880
  if (import.meta.main) {
@@ -1592,14 +1592,33 @@ export function handleSetupGet(req: Request, deps: SetupWizardDeps): Response {
1592
1592
  // poll on the auth the wizard already carries.
1593
1593
  const opId = url.searchParams.get("op");
1594
1594
  if (opId) {
1595
- const op = deps.registry?.get(opId);
1596
- if (op) {
1597
- envelope.operation = {
1598
- id: op.id,
1599
- status: op.status,
1600
- log: op.log,
1601
- ...(op.error !== undefined ? { error: op.error } : {}),
1602
- };
1595
+ // hub#618: post-setup this JSON `?op=` surface is unauth-reachable —
1596
+ // `/admin/setup` is always lockout-exempt (the dispatcher's
1597
+ // `shouldGateForSetup` lets it through so a stale bookmark resolves), and
1598
+ // the snapshot is read BEFORE any session check. The leak is small (an
1599
+ // in-memory op's status + install-progress log lines, behind an
1600
+ // unguessable UUID), but it's still a post-setup admin surface, so gate
1601
+ // it once setup is COMPLETE. During setup (no admin yet) the surface
1602
+ // stays OPEN: the unauth CLI wizard (`parachute init`) AND the brand-new-
1603
+ // operator browser both poll this `?op=` snapshot mid-setup before any
1604
+ // session exists — gating then would break first-boot vault
1605
+ // provisioning. Loopback always passes (same on-box trust as the
1606
+ // `bootstrapToken` branch below); a valid session also passes.
1607
+ const setupComplete = state.hasAdmin && state.hasVault && state.hasExposeMode;
1608
+ const opSnapshotAllowed =
1609
+ !setupComplete ||
1610
+ deps.requestIsLoopback === true ||
1611
+ findActiveSession(deps.db, req) !== null;
1612
+ if (opSnapshotAllowed) {
1613
+ const op = deps.registry?.get(opId);
1614
+ if (op) {
1615
+ envelope.operation = {
1616
+ id: op.id,
1617
+ status: op.status,
1618
+ log: op.log,
1619
+ ...(op.error !== undefined ? { error: op.error } : {}),
1620
+ };
1621
+ }
1603
1622
  }
1604
1623
  }
1605
1624
  // hub#576: hand the actual token to a LOOPBACK caller only. The on-box