fix(frontend): treat an unread hub setting as private, not as the public hub

`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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG
This commit is contained in:
Guilhem Lemouel
2026-09-08 14:44:41 +02:00
co-authored by Claude Opus 5
parent 36352c482a
commit 63b8d9757b
4 changed files with 49 additions and 18 deletions
+4
View File
@@ -118,6 +118,10 @@ export const superadmin = writable<string | false | undefined>(undefined)
export const devopsRole = writable<string | false | undefined>(undefined)
export const lspTokenStore = writable<string | undefined>(undefined)
export const hubBaseUrlStore = writable<string>(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<boolean>(false)
export const wsBaseUrlStore = writable<string | undefined>(undefined)
export const disableHubStore = writable<boolean>(false)
// What a superadmin standing in a workspace they are not a member of needs to see it as a
+26 -11
View File
@@ -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 = <T>(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')
}
})
+6 -3
View File
@@ -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 {
@@ -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() {