From 63b8d9757b98b643376a237100374ebaccf4a3b0 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 8 Sep 2026 14:44:41 +0200 Subject: [PATCH] fix(frontend): treat an unread hub setting as private, not as the public hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hubBaseUrlStore` is seeded with the public hub and written in one place, by a loader with no catch and no retry. A settings read that threw therefore left the store naming hub.windmill.dev for the rest of the session, and the import counter read that as permission to report a private instance's project slug — the leak the previous commit closed, narrowed to "after one failed read". The fact has three states and the store held two, so `hubBaseUrlKnown` carries the third: the loader sets it only once the value is the instance's own, and the telemetry key requires it. Links keep rendering the default meanwhile, which is what they always did. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG --- frontend/src/lib/stores.ts | 4 ++ frontend/src/lib/utils/featureUsage.test.ts | 37 +++++++++++++------ frontend/src/lib/utils/featureUsage.ts | 9 +++-- .../src/routes/(root)/(logged)/+layout.svelte | 17 +++++++-- 4 files changed, 49 insertions(+), 18 deletions(-) diff --git a/frontend/src/lib/stores.ts b/frontend/src/lib/stores.ts index 2e8658bb4b..229ecff7ad 100644 --- a/frontend/src/lib/stores.ts +++ b/frontend/src/lib/stores.ts @@ -118,6 +118,10 @@ export const superadmin = writable(undefined) export const devopsRole = writable(undefined) export const lspTokenStore = writable(undefined) export const hubBaseUrlStore = writable(DEFAULT_HUB_BASE_URL) +// Whether the store above is the instance's answer or still the default it was seeded with. +// It reads as the public hub either way, which is fine for a link and wrong for anything +// deciding what may be reported about a hub — those must treat unknown as private. +export const hubBaseUrlKnown = writable(false) export const wsBaseUrlStore = writable(undefined) export const disableHubStore = writable(false) // What a superadmin standing in a workspace they are not a member of needs to see it as a diff --git a/frontend/src/lib/utils/featureUsage.test.ts b/frontend/src/lib/utils/featureUsage.test.ts index e0a4c868e7..70dd2e9de9 100644 --- a/frontend/src/lib/utils/featureUsage.test.ts +++ b/frontend/src/lib/utils/featureUsage.test.ts @@ -2,23 +2,28 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('$lib/gen', () => ({ OpenAPI: { BASE: '/api' } })) -// A store `get()` can read, so a test can say which hub the instance points at. +// Stores `get()` can read, so a test can say which hub the instance points at and whether +// the instance has answered at all. const hubBaseUrl = vi.hoisted(() => { - let value = 'https://hub.windmill.dev' - return { - set: (v: string) => (value = v), - store: { - subscribe: (run: (v: string) => void) => { - run(value) - return () => {} + const readable = (initial: T) => { + let value = initial + return { + set: (v: T) => (value = v), + store: { + subscribe: (run: (v: T) => void) => { + run(value) + return () => {} + } } } } + return { url: readable('https://hub.windmill.dev'), known: readable(true) } }) vi.mock('$lib/stores', () => ({ workspaceStore: { subscribe: () => () => {} }, - hubBaseUrlStore: hubBaseUrl.store + hubBaseUrlStore: hubBaseUrl.url.store, + hubBaseUrlKnown: hubBaseUrl.known.store })) import { @@ -137,11 +142,21 @@ describe('hubProjectUsageKey', () => { 'https://hub.windmill.dev:443', ' https://hub.windmill.dev ' ]) { - hubBaseUrl.set(hub) + hubBaseUrl.url.set(hub) expect(hubProjectUsageKey('stripe-invoices'), hub).toBe('stripe-invoices') } }) + it('answers private until the instance has said which hub it points at', () => { + // The store is seeded with the public hub, so a settings read that failed must not + // read as permission to report the name. + hubBaseUrl.url.set('https://hub.windmill.dev') + hubBaseUrl.known.set(false) + expect(hubProjectUsageKey('acme-payroll')).toBe('private') + hubBaseUrl.known.set(true) + expect(hubProjectUsageKey('acme-payroll')).toBe('acme-payroll') + }) + it("keeps a private hub's project names off the wire", () => { // The slug is the customer's own content on an instance running its own hub, and the // disclosure only claims public project names. @@ -152,7 +167,7 @@ describe('hubProjectUsageKey', () => { 'hub.windmill.dev', 'not a url' ]) { - hubBaseUrl.set(hub) + hubBaseUrl.url.set(hub) expect(hubProjectUsageKey('acme-payroll'), hub).toBe('private') } }) diff --git a/frontend/src/lib/utils/featureUsage.ts b/frontend/src/lib/utils/featureUsage.ts index cd75542681..31b33153cf 100644 --- a/frontend/src/lib/utils/featureUsage.ts +++ b/frontend/src/lib/utils/featureUsage.ts @@ -1,6 +1,6 @@ import { get } from 'svelte/store' import { OpenAPI } from '$lib/gen' -import { hubBaseUrlStore, workspaceStore } from '$lib/stores' +import { hubBaseUrlKnown, hubBaseUrlStore, workspaceStore } from '$lib/stores' import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub' // Anonymous product-usage counters (e.g. AI session activity), batched into the @@ -195,10 +195,13 @@ export function hubScriptUsageKey(script: { * and what the disclosure means by "the name of any public hub project". * * Compared by host, so the port, scheme and trailing slash an operator may have typed do - * not decide it. Anything unparseable answers private. + * not decide it. Anything unparseable, and anything not yet read, answers private. */ export function hubProjectUsageKey(slug: string): string { - return isPublicHub(get(hubBaseUrlStore)) ? slug : PRIVATE_HUB_KEY + // `hubBaseUrlKnown` and not the URL alone: the store is seeded with the public hub, so an + // instance whose setting could not be read would otherwise report its own project names. + if (!get(hubBaseUrlKnown) || !isPublicHub(get(hubBaseUrlStore))) return PRIVATE_HUB_KEY + return slug.slice(0, 100) } function isPublicHub(hub: string): boolean { diff --git a/frontend/src/routes/(root)/(logged)/+layout.svelte b/frontend/src/routes/(root)/(logged)/+layout.svelte index d5ca0d5167..5aa90b80a2 100644 --- a/frontend/src/routes/(root)/(logged)/+layout.svelte +++ b/frontend/src/routes/(root)/(logged)/+layout.svelte @@ -32,6 +32,7 @@ type UserExt, defaultScripts, hubBaseUrlStore, + hubBaseUrlKnown, wsBaseUrlStore, disableHubStore, usedTriggerKinds, @@ -474,10 +475,18 @@ } async function loadHubBaseUrl() { - $hubBaseUrlStore = - ((await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string) || - ((await SettingService.getGlobal({ key: 'hub_base_url' })) as string) || - DEFAULT_HUB_BASE_URL + // A read that throws leaves the store on its seeded default, which names the public hub + // — so the flag, not the value, is what says the instance has answered. An instance that + // simply has no setting still answers: the chain falls through to the default. + try { + $hubBaseUrlStore = + ((await SettingService.getGlobal({ key: 'hub_accessible_url' })) as string) || + ((await SettingService.getGlobal({ key: 'hub_base_url' })) as string) || + DEFAULT_HUB_BASE_URL + $hubBaseUrlKnown = true + } catch (error) { + console.error('Could not read the hub URL:', error) + } } async function loadWsBaseUrl() {