From 6472d9dcbd4492b4e414e41c8b9837b04a41988d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 13 Jul 2026 18:48:13 +0200 Subject: [PATCH] fix(hub): validate persisted origins --- .../hub/relationship-controller.test.ts | 32 +++++++++++++++++++ .../src/server/hub/relationship-controller.ts | 20 ++++++++++-- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/hub/relationship-controller.test.ts b/packages/server/src/server/hub/relationship-controller.test.ts index ca0df85709..376b4585ec 100644 --- a/packages/server/src/server/hub/relationship-controller.test.ts +++ b/packages/server/src/server/hub/relationship-controller.test.ts @@ -227,6 +227,38 @@ describe("Hub relationship", () => { }, ); + test.each([ + ["a non-HTTP scheme", "ftp://hub.test"], + ["embedded credentials", "https://user:password@hub.test"], + ["a query", "https://hub.test?token=secret"], + ["a fragment", "https://hub.test#secret"], + ])("a persisted Hub origin with %s is quarantined before startup", async (_, hubOrigin) => { + relationship = await HubRelationshipHarness.start(); + await relationship.corruptRelationshipFile( + JSON.stringify({ + version: 1, + state: "pending", + relationship: { + daemonId: "daemon-1", + idempotencyKey: "ceremony-1", + hubOrigin, + createdAt: "2026-07-13T00:00:00.000Z", + scopes: ["hub.execution.*"], + }, + credential: { secret: "credential" }, + enrollment: { token: "enrollment-token" }, + identity: { serverId: "server-1", daemonPublicKey: "public-key" }, + }), + ); + + await relationship.startStoppedDaemon(); + + expect(await relationship.status()).toMatchObject({ state: "not_connected" }); + expect(relationship.relationshipFile()).toBeNull(); + expect(await relationship.quarantinedRelationshipFiles()).toHaveLength(1); + expect(relationship.enrollmentAttempts()).toEqual([]); + }); + test("a persisted non-WebSocket transport is quarantined before daemon startup", async () => { relationship = await HubRelationshipHarness.start(); await relationship.corruptRelationshipFile( diff --git a/packages/server/src/server/hub/relationship-controller.ts b/packages/server/src/server/hub/relationship-controller.ts index 32275f03b2..6ac3fb3d9a 100644 --- a/packages/server/src/server/hub/relationship-controller.ts +++ b/packages/server/src/server/hub/relationship-controller.ts @@ -16,11 +16,24 @@ import { BoundedExponentialHubRetryPolicy } from "./relationship-retry.js"; const FILE_NAME = "hub-relationship.json"; const SCOPES = ["hub.execution.*"] as const; +const HubOriginSchema = z + .string() + .url() + .superRefine((value, context) => { + try { + normalizeHubUrl(value); + } catch (error) { + context.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : "Invalid Hub URL", + }); + } + }); const RelationshipSchema = z.object({ daemonId: z.string().min(1), idempotencyKey: z.string().min(1), - hubOrigin: z.string().url(), + hubOrigin: HubOriginSchema, createdAt: z.string(), scopes: z.array(z.string()), }); @@ -153,9 +166,10 @@ function normalizeHubUrl(value: string): string { if (url.username || url.password) { throw new Error("Hub URL cannot include credentials"); } + if (url.search || url.hash) { + throw new Error("Hub URL cannot include a query or fragment"); + } url.pathname = url.pathname.replace(/\/$/, ""); - url.search = ""; - url.hash = ""; return url.toString().replace(/\/$/, ""); }