export const DEFAULT_SENTRY_HOST = 'sentry.io' export const OFFICIAL_SENTRY_HOSTS = ['sentry.io', 'us.sentry.io', 'de.sentry.io'] as const const HOSTNAME_RE = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?))+$/i const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1']) export function trimString(value: unknown): string { return typeof value === 'string' ? value.trim() : '' } /** * Normalize a Sentry API host to `hostname` or `hostname:port`. * Accepts `sentry.io`, `https://us.sentry.io`, or a self-hosted hostname. */ export function normalizeSentryHost(value?: unknown): string { const raw = trimString(value) || DEFAULT_SENTRY_HOST const withScheme = raw.includes('://') ? raw : `https://${raw}` let url: URL try { url = new URL(withScheme) } catch { throw new Error(`Sentry host is invalid: ${JSON.stringify(value)}`) } if (url.username || url.password) { throw new Error('Sentry host must not include credentials.') } if (url.pathname !== '/' && url.pathname !== '') { throw new Error('Sentry host must not include a path.') } if (url.search || url.hash) { throw new Error('Sentry host must not include a query or hash.') } const hostname = url.hostname.toLowerCase() const isOfficial = (OFFICIAL_SENTRY_HOSTS as readonly string[]).includes(hostname) const isLocal = LOCAL_HOSTS.has(hostname) if (!isOfficial && !isLocal && !HOSTNAME_RE.test(hostname)) { throw new Error( `Sentry host must be a hostname such as "sentry.io" or "sentry.example.com" (got ${JSON.stringify(value)}).`, ) } if (isOfficial && url.protocol !== 'https:') { throw new Error('Official Sentry hosts must use https.') } if (!isOfficial && !isLocal && url.protocol !== 'https:') { throw new Error('Self-hosted Sentry hosts must use https (http is allowed only for localhost).') } const port = url.port ? `:${url.port}` : '' return `${hostname}${port}` } export function sentryApiBaseUrl(host?: unknown): string { const normalized = normalizeSentryHost(host) const hostname = normalized.split(':')[0] ?? normalized const isLocal = LOCAL_HOSTS.has(hostname) const protocol = isLocal ? 'http' : 'https' return `${protocol}://${normalized}` } export function isSafeHttpMethod(method: string): boolean { switch (method.toUpperCase()) { case 'GET': case 'HEAD': case 'OPTIONS': return true default: return false } } export function runHostHelperSelfCheck(): { ok: true; checks: number } { const cases: Array<[unknown, string]> = [ [undefined, DEFAULT_SENTRY_HOST], ['sentry.io', 'sentry.io'], ['https://us.sentry.io', 'us.sentry.io'], ['https://de.sentry.io/', 'de.sentry.io'], ['Sentry.Example.com', 'sentry.example.com'], ['localhost:9000', 'localhost:9000'], ] for (const [input, expected] of cases) { const actual = normalizeSentryHost(input) if (actual !== expected) { throw new Error(`normalizeSentryHost(${JSON.stringify(input)}) => ${actual}, expected ${expected}`) } } if (sentryApiBaseUrl() !== 'https://sentry.io') { throw new Error('Default Sentry API base URL must be https://sentry.io') } if (sentryApiBaseUrl('us.sentry.io') !== 'https://us.sentry.io') { throw new Error('Regional Sentry API base URL must be https://us.sentry.io') } if (sentryApiBaseUrl('localhost:9000') !== 'http://localhost:9000') { throw new Error('Local self-hosted Sentry must use http://localhost:9000') } const rejects = ['https://evil.example/path', 'https://user:pass@sentry.io', 'ftp://sentry.io'] for (const input of rejects) { let threw = false try { normalizeSentryHost(input) } catch { threw = true } if (!threw) { throw new Error(`normalizeSentryHost should reject ${JSON.stringify(input)}`) } } return { ok: true, checks: cases.length + 3 + rejects.length } }