const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
const TRUSTED_EXCHANGE_HOSTS = new Set([
'us-central1-ice-puzzle-game.cloudfunctions.net',
]);
function isIpv4Loopback(hostname: string): boolean {
const match = hostname.match(/^127\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (!match) {
return false;
}
return match.slice(1).every((part) => {
const value = Number(part);
return Number.isInteger(value) && value >= 0 && value <= 255;
});
}
function isLoopbackHost(hostname: string): boolean {
const normalized = hostname.toLowerCase();
return (
LOOPBACK_HOSTS.has(normalized)
|| normalized.endsWith('.localhost')
|| isIpv4Loopback(normalized)
);
}
export interface ExchangeUrlValidationResult {
valid: boolean;
normalizedUrl?: string;
error?: string;
}
export interface ExchangeUrlValidationOptions {
allowUntrustedHttpsHost?: boolean;
}
export function validateExchangeUrl(
url: string,
options: ExchangeUrlValidationOptions = {},
): ExchangeUrlValidationResult {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return { valid: false, error: `Invalid exchange URL: ${url}` };
}
if (parsed.username || parsed.password) {
return { valid: false, error: 'Exchange URL must not include username or password.' };
}
if (parsed.protocol === 'https:') {
const hostname = parsed.hostname.toLowerCase();
if (
TRUSTED_EXCHANGE_HOSTS.has(hostname)
|| isLoopbackHost(hostname)
|| options.allowUntrustedHttpsHost === true
) {
return { valid: true, normalizedUrl: parsed.toString() };
}
return {
valid: false,
error:
'Untrusted HTTPS exchange host. Use the official Ice Puzzle endpoint, localhost, or pass --allow-untrusted-exchange-url intentionally.',
};
}
if (parsed.protocol !== 'http:') {
return {
valid: false,
error: 'Exchange URL must use http:// or https://.',
};
}
if (!isLoopbackHost(parsed.hostname)) {
return {
valid: false,
error: 'Insecure HTTP exchange URLs are only allowed for localhost/loopback during local development.',
};
}
return { valid: true, normalizedUrl: parsed.toString() };
}