From c4fb31f1004b8991f7f943e3b48e3a0ceb48de35 Mon Sep 17 00:00:00 2001 From: kipavy Date: Mon, 9 Nov 2026 16:36:02 +0000 Subject: [PATCH] feat(open): accept enroll links --- landing/app/open/fragment.test.ts | 13 +++++++++++++ landing/app/open/fragment.ts | 22 ++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/landing/app/open/fragment.test.ts b/landing/app/open/fragment.test.ts index 466c106..7d816a6 100644 --- a/landing/app/open/fragment.test.ts +++ b/landing/app/open/fragment.test.ts @@ -152,3 +152,16 @@ test("no new route produces a paste-able code", () => { expect(readFragment("#plugin-install?id=docker")?.code).toBeNull(); expect(readFragment("#snippet-install?id=docker-cleanup")?.code).toBeNull(); }); + +const ENROLL_TOKEN = `vte1.${btoa("https://voltius.acme.com/api").replace(/=+$/, "")}.${"0123456789abcdef".repeat(2)}.${"A".repeat(43)}`; + +test("an enroll fragment forwards a well-formed token and rejects a malformed one", () => { + expect(readFragment(`#enroll?t=${ENROLL_TOKEN}`)?.url).toBe(`voltius://enroll?t=${ENROLL_TOKEN}`); + expect(readFragment(`#enroll?t=${ENROLL_TOKEN}`)?.code).toBeNull(); + expect(readFragment("#enroll")).toBeNull(); + expect(readFragment("#enroll?t=nonsense")).toBeNull(); + expect(readFragment(`#enroll?t=${ENROLL_TOKEN}x`)).toBeNull(); + expect(readFragment(`#enroll?t=${ENROLL_TOKEN.replace("vte1", "vte2")}`)).toBeNull(); + const ftp = `vte1.${btoa("ftp://voltius.acme.com").replace(/=+$/, "")}.${"0123456789abcdef".repeat(2)}.${"A".repeat(43)}`; + expect(readFragment(`#enroll?t=${ftp}`)).toBeNull(); +}); diff --git a/landing/app/open/fragment.ts b/landing/app/open/fragment.ts index e6ddb21..03abebb 100644 --- a/landing/app/open/fragment.ts +++ b/landing/app/open/fragment.ts @@ -36,6 +36,27 @@ const PLUGIN_ID_MAX_LENGTH = 64; const MAX_SOURCE_ID = 100; const MAX_CATALOG_ID = 100; +// Mirrors `parseEnrollmentToken` in voltius (src/services/enrollment/enrollmentToken.ts). +const ENROLL_ID_RE = /^[0-9a-f]{32}$/; +const ENROLL_SECRET_RE = /^[A-Za-z0-9_-]{43}$/; + +function isEnrollmentToken(token: string): boolean { + const parts = token.trim().split("."); + if (parts.length !== 4 || parts[0] !== "vte1") return false; + const [, encodedUrl, id, secret] = parts; + if (!ENROLL_ID_RE.test(id) || !ENROLL_SECRET_RE.test(secret) || !/^[A-Za-z0-9_-]+$/.test(encodedUrl)) { + return false; + } + try { + const padded = encodedUrl.replace(/-/g, "+").replace(/_/g, "/"); + const bytes = Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); + const { protocol } = new URL(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); + return protocol === "https:" || protocol === "http:"; + } catch { + return false; + } +} + const ROUTE_VALIDATORS: Record boolean> = { join: (params) => isSessionId(params.get("s") ?? "") && !!params.get("t"), verified: (params) => isSessionId(params.get("u") ?? ""), @@ -44,6 +65,7 @@ const ROUTE_VALIDATORS: Record boolean> = { notification: (params) => (params.get("n") ?? "").length <= MAX_ENTRY_ID, settings: (params) => SETTINGS_SECTIONS.has(params.get("section") ?? ""), billing: () => true, + enroll: (params) => isEnrollmentToken(params.get("t") ?? ""), invite: (params) => HANDLE_RE.test((params.get("h") ?? "").replace(/^@/, "").toLowerCase()), // The source is a catalogue *id* the client resolves against its own configured // sources, never a URL — so only its length is checked here.