From 36352c482ac7d7f4ea3e39c58e0e4ea422900264 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 8 Sep 2026 12:41:34 +0200 Subject: [PATCH] fix(frontend): keep private hub project names out of telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An instance pointed at its own hub imports its own projects, and the slug naming one is the customer's content — `template_import` was recording it verbatim, which the disclosure ("the name of any public hub project") does not cover and `hub_script` already avoids by collapsing a private script to `private`. `hubProjectUsageKey` gives projects the same treatment, deciding by the configured hub's host so a port, a scheme's case or a trailing slash cannot turn a private hub into a public one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG --- .../components/home/ImportProjectModal.svelte | 4 +- frontend/src/lib/utils/featureUsage.test.ts | 52 ++++++++++++++++++- frontend/src/lib/utils/featureUsage.ts | 32 +++++++++++- 3 files changed, 83 insertions(+), 5 deletions(-) diff --git a/frontend/src/lib/components/home/ImportProjectModal.svelte b/frontend/src/lib/components/home/ImportProjectModal.svelte index f56578dcad..8e9b6acca2 100644 --- a/frontend/src/lib/components/home/ImportProjectModal.svelte +++ b/frontend/src/lib/components/home/ImportProjectModal.svelte @@ -15,7 +15,7 @@ import { useSetupStep } from '$lib/importWizard/setupStep.svelte' import type { ImportPlan } from '$lib/importWizard/plan' import { workspaceStore } from '$lib/stores' - import { logFeatureUsage } from '$lib/utils/featureUsage' + import { hubProjectUsageKey, logFeatureUsage } from '$lib/utils/featureUsage' import { sendUserToast } from '$lib/toast' /** @@ -225,7 +225,7 @@ function finish(setupOutcome: SetupOutcome, outstanding = 1) { // On the way out rather than on the pick: what is worth counting is an import that // landed, not a dialog that was opened and abandoned. - if (slug) logFeatureUsage('home', 'template_import', { key: slug }) + if (slug) logFeatureUsage('home', 'template_import', { key: hubProjectUsageKey(slug) }) logFeatureUsage('home', 'template_setup', { key: setupKey(setupOutcome, outstanding) }) finishing = true // Through the same deferred reload every closing uses. `done` survives a retry, so diff --git a/frontend/src/lib/utils/featureUsage.test.ts b/frontend/src/lib/utils/featureUsage.test.ts index ef60cd2b36..e0a4c868e7 100644 --- a/frontend/src/lib/utils/featureUsage.test.ts +++ b/frontend/src/lib/utils/featureUsage.test.ts @@ -1,10 +1,29 @@ import { describe, expect, it, vi } from 'vitest' vi.mock('$lib/gen', () => ({ OpenAPI: { BASE: '/api' } })) -vi.mock('$lib/stores', () => ({ workspaceStore: { subscribe: () => () => {} } })) + +// A store `get()` can read, so a test can say which hub the instance points at. +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 () => {} + } + } + } +}) + +vi.mock('$lib/stores', () => ({ + workspaceStore: { subscribe: () => () => {} }, + hubBaseUrlStore: hubBaseUrl.store +})) import { createFeatureUsageBuffer, + hubProjectUsageKey, hubScriptUsageKey, type FeatureUsageEventPayload } from './featureUsage' @@ -107,3 +126,34 @@ describe('hubScriptUsageKey', () => { ).toBe('acme/list_a_user_s_items_sorted') }) }) + +describe('hubProjectUsageKey', () => { + it('reports the slug for every spelling of the public hub', () => { + for (const hub of [ + 'https://hub.windmill.dev', + 'http://hub.windmill.dev/', + 'HTTPS://hub.windmill.dev', + 'https://HUB.WINDMILL.DEV', + 'https://hub.windmill.dev:443', + ' https://hub.windmill.dev ' + ]) { + hubBaseUrl.set(hub) + expect(hubProjectUsageKey('stripe-invoices'), hub).toBe('stripe-invoices') + } + }) + + 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. + for (const hub of [ + 'https://hub.internal.example', + 'https://hub.windmill.dev.evil.example', + 'https://windmill.dev', + 'hub.windmill.dev', + 'not a url' + ]) { + hubBaseUrl.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 793d137593..cd75542681 100644 --- a/frontend/src/lib/utils/featureUsage.ts +++ b/frontend/src/lib/utils/featureUsage.ts @@ -1,7 +1,7 @@ import { get } from 'svelte/store' import { OpenAPI } from '$lib/gen' -import { workspaceStore } from '$lib/stores' -import { PRIVATE_HUB_MIN_VERSION } from '$lib/hub' +import { 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 // backend `feature_usage` accumulator. Only aggregated counts ever leave the @@ -187,3 +187,31 @@ export function hubScriptUsageKey(script: { if (!app) return PRIVATE_HUB_KEY return (summary ? `${app}/${summary}` : app).slice(0, 100) } + +/** + * A hub project's slug is only reportable when it names something on the public hub. An + * instance pointed at its own hub imports its own projects, whose names are the customer's + * content — the same reason `hubScriptUsageKey` collapses a private script to `private`, + * 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. + */ +export function hubProjectUsageKey(slug: string): string { + return isPublicHub(get(hubBaseUrlStore)) ? slug : PRIVATE_HUB_KEY +} + +function isPublicHub(hub: string): boolean { + const host = (url: string): string | undefined => { + try { + const parsed = new URL(url.trim()) + return parsed.protocol === 'http:' || parsed.protocol === 'https:' + ? parsed.hostname.replace(/\.$/, '').toLowerCase() + : undefined + } catch { + return undefined + } + } + const configured = host(hub) + return configured !== undefined && configured === host(DEFAULT_HUB_BASE_URL) +}