Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kody/sentry

src/host.ts

113 lines · 3.7 KB · TypeScript
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 }
}