From 6a4046915ceadfe25aa2ff78fe929dc4b042a8b9 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 20 Apr 2026 23:38:41 +0200 Subject: [PATCH] fix: harden startup telemetry urls --- src/lib/telemetryConfig.test.ts | 112 ++++++++++++++++++++++---------- src/lib/telemetryConfig.ts | 34 +++++++++- 2 files changed, 109 insertions(+), 37 deletions(-) diff --git a/src/lib/telemetryConfig.test.ts b/src/lib/telemetryConfig.test.ts index 5a62aa76..3e310f6f 100644 --- a/src/lib/telemetryConfig.test.ts +++ b/src/lib/telemetryConfig.test.ts @@ -5,6 +5,19 @@ import { sanitizeTelemetryEnvValue, } from './telemetryConfig' +function resolveConfig(overrides: { + VITE_SENTRY_DSN?: string + VITE_POSTHOG_KEY?: string + VITE_POSTHOG_HOST?: string +} = {}) { + return resolveFrontendTelemetryConfig({ + VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456', + VITE_POSTHOG_KEY: 'phc_test_key', + VITE_POSTHOG_HOST: 'https://eu.i.posthog.com', + ...overrides, + }) +} + describe('sanitizeTelemetryEnvValue', () => { it('trims surrounding whitespace', () => { expect(sanitizeTelemetryEnvValue(' value ')).toBe('value') @@ -22,50 +35,77 @@ describe('sanitizeTelemetryEnvValue', () => { }) describe('resolveFrontendTelemetryConfig', () => { - it('keeps valid telemetry values after sanitizing them', () => { - expect(resolveFrontendTelemetryConfig({ - VITE_SENTRY_DSN: ' "https://public@example.ingest.sentry.io/123456" ', - VITE_POSTHOG_KEY: " 'phc_test_key' ", - VITE_POSTHOG_HOST: ' https://eu.i.posthog.com ', - })).toEqual({ - sentryDsn: 'https://public@example.ingest.sentry.io/123456', - posthogKey: 'phc_test_key', - posthogHost: 'https://eu.i.posthog.com', - }) + it.each([ + { + name: 'keeps valid telemetry values after sanitizing them', + overrides: { + VITE_SENTRY_DSN: ' "https://public@example.ingest.sentry.io/123456" ', + VITE_POSTHOG_KEY: " 'phc_test_key' ", + VITE_POSTHOG_HOST: ' https://eu.i.posthog.com ', + }, + expected: { + sentryDsn: 'https://public@example.ingest.sentry.io/123456', + posthogKey: 'phc_test_key', + posthogHost: 'https://eu.i.posthog.com', + }, + }, + { + name: 'adds https to scheme-less DSNs and PostHog hosts', + overrides: { + VITE_SENTRY_DSN: 'public@example.ingest.sentry.io/123456', + VITE_POSTHOG_KEY: 'phc_test_key', + VITE_POSTHOG_HOST: 'eu.i.posthog.com', + }, + expected: { + sentryDsn: 'https://public@example.ingest.sentry.io/123456', + posthogKey: 'phc_test_key', + posthogHost: 'https://eu.i.posthog.com', + }, + }, + ])('$name', ({ overrides, expected }) => { + expect(resolveConfig(overrides)).toEqual(expected) }) it('uses the default PostHog host when one is not configured', () => { - expect(resolveFrontendTelemetryConfig({ - VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456', - VITE_POSTHOG_KEY: 'phc_test_key', - }).posthogHost).toBe(defaultPostHogHost) - }) - - it('adds https to scheme-less DSNs and PostHog hosts', () => { - expect(resolveFrontendTelemetryConfig({ - VITE_SENTRY_DSN: 'public@example.ingest.sentry.io/123456', - VITE_POSTHOG_KEY: 'phc_test_key', - VITE_POSTHOG_HOST: 'eu.i.posthog.com', - })).toEqual({ - sentryDsn: 'https://public@example.ingest.sentry.io/123456', - posthogKey: 'phc_test_key', - posthogHost: 'https://eu.i.posthog.com', - }) + expect(resolveConfig({ VITE_POSTHOG_HOST: undefined }).posthogHost).toBe(defaultPostHogHost) }) it('drops invalid Sentry DSNs instead of passing them to the SDK', () => { - expect(resolveFrontendTelemetryConfig({ - VITE_SENTRY_DSN: 'not a dsn', - VITE_POSTHOG_KEY: 'phc_test_key', - VITE_POSTHOG_HOST: 'https://eu.i.posthog.com', - }).sentryDsn).toBe('') + expect(resolveConfig({ VITE_SENTRY_DSN: 'not a dsn' }).sentryDsn).toBe('') }) it('drops invalid PostHog hosts instead of loading scripts from them', () => { - expect(resolveFrontendTelemetryConfig({ - VITE_SENTRY_DSN: 'https://public@example.ingest.sentry.io/123456', - VITE_POSTHOG_KEY: 'phc_test_key', - VITE_POSTHOG_HOST: 'not a url', - }).posthogHost).toBeNull() + expect(resolveConfig({ VITE_POSTHOG_HOST: 'not a url' }).posthogHost).toBeNull() + }) + + it('drops placeholder telemetry hosts that would create broken startup requests', () => { + expect(resolveConfig({ + VITE_SENTRY_DSN: 'https://public@false/123456', + VITE_POSTHOG_HOST: 'false', + })).toEqual({ + sentryDsn: '', + posthogKey: 'phc_test_key', + posthogHost: null, + }) + }) + + it('drops single-label telemetry hosts but keeps localhost for dev', () => { + expect(resolveConfig({ + VITE_SENTRY_DSN: 'https://public@le/123456', + VITE_POSTHOG_HOST: 'https://le', + })).toEqual({ + sentryDsn: '', + posthogKey: 'phc_test_key', + posthogHost: null, + }) + + expect(resolveConfig({ + VITE_SENTRY_DSN: 'http://public@localhost:9000/123456', + VITE_POSTHOG_HOST: 'http://localhost:8010', + })).toEqual({ + sentryDsn: 'http://public@localhost:9000/123456', + posthogKey: 'phc_test_key', + posthogHost: 'http://localhost:8010', + }) }) }) diff --git a/src/lib/telemetryConfig.ts b/src/lib/telemetryConfig.ts index 5168a841..20859cac 100644 --- a/src/lib/telemetryConfig.ts +++ b/src/lib/telemetryConfig.ts @@ -1,4 +1,12 @@ const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com' +const DISALLOWED_TELEMETRY_HOSTS = new Set([ + 'false', + 'true', + 'null', + 'undefined', + 'none', + 'disabled', +]) type TelemetryEnv = { VITE_SENTRY_DSN?: string @@ -35,12 +43,36 @@ export function sanitizeTelemetryEnvValue(value: string | undefined): string { function isHttpUrl(value: string): boolean { try { const url = new URL(value) - return url.protocol === 'http:' || url.protocol === 'https:' + return (url.protocol === 'http:' || url.protocol === 'https:') + && isAllowedTelemetryHostname(url.hostname) } catch { return false } } +function normalizeHostname(hostname: string): string { + const normalized = hostname.trim().replace(/\.$/, '').toLowerCase() + if (normalized.startsWith('[') && normalized.endsWith(']')) { + return normalized.slice(1, -1) + } + return normalized +} + +function isIpAddress(hostname: string): boolean { + if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(hostname)) { + return hostname.split('.').every((segment) => Number(segment) <= 255) + } + + return hostname.includes(':') && /^[\da-f:]+$/i.test(hostname) +} + +function isAllowedTelemetryHostname(hostname: string): boolean { + const normalized = normalizeHostname(hostname) + if (!normalized || DISALLOWED_TELEMETRY_HOSTS.has(normalized)) return false + if (normalized === 'localhost') return true + return normalized.includes('.') || isIpAddress(normalized) +} + function normalizeHttpLikeValue(value: string): string { if (!value) return '' if (/^[a-z][a-z\d+\-.]*:\/\//i.test(value)) return value