import { Schema } from "effect";import { isIP } from "node:net"; const hostnamePattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;const numericIpv4Pattern = /^(?:0x[0-9a-f]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|[0-9]+))*$/;const loopbackHosts = ["localhost", "127.0.0.1", "::1", "host.docker.internal"]; const normalizeIp = (value: string): string | null => { const version = isIP(value); if (version === 4) return value; if (version !== 6) return null; try { return new URL(`http://[${value}]`).hostname.slice(1, -1).toLowerCase(); } catch { return null; }}; export const normalizeControllerHost = (value: string): string | null => { const candidate = value.trim().toLowerCase(); const bracketed = candidate.startsWith("[") || candidate.endsWith("]"); if (bracketed && !(candidate.startsWith("[") && candidate.endsWith("]"))) return null; const unwrapped = bracketed ? candidate.slice(1, -1) : candidate; const ip = normalizeIp(unwrapped); if (ip) return bracketed && isIP(unwrapped) !== 6 ? null : ip; if (numericIpv4Pattern.test(unwrapped)) { try { const numericIp = new URL(`http://${unwrapped}`).hostname; return isIP(numericIp) === 4 ? numericIp : null; } catch { return null; } } if ( bracketed || candidate.length > 253 || candidate.endsWith(".") || candidate.includes(":") || candidate.includes("@") || candidate.includes("/") || candidate.includes("*") ) { return null; } return candidate.split(".").every((label) => hostnamePattern.test(label)) ? candidate : null;}; export const isLoopbackHost = (value: string): boolean => { const normalized = normalizeControllerHost(value); return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";}; export const isWildcardHost = (value: string): boolean => { const normalized = normalizeControllerHost(value); return normalized === "0.0.0.0" || normalized === "::";}; export const normalizeHttpOrigin = (value: string): string | null => { try { const parsed = new URL(value.trim()); if ( (parsed.protocol !== "http:" && parsed.protocol !== "https:") || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash || !normalizeControllerHost(parsed.hostname) ) { return null; } return parsed.origin === "null" ? null : parsed.origin; } catch { return null; }}; const allowedHostSchema = Schema.String.check( Schema.makeFilter( (value) => { const normalized = normalizeControllerHost(value); return normalized !== null && !isWildcardHost(normalized); }, { expected: "an exact hostname or IP address" }, ),);const allowedHostsSchema = Schema.Array(allowedHostSchema).check(Schema.isNonEmpty()); export const decodeAllowedHosts = (value: string): string[] => { try { const entries = Schema.decodeUnknownSync(allowedHostsSchema)(value.split(",")); return [ ...new Set( entries.flatMap((entry) => { const normalized = normalizeControllerHost(entry); return normalized ? [normalized] : []; }), ), ]; } catch { throw new Error( "LOCAL_STUDIO_ALLOWED_HOSTS must contain a nonempty comma-separated list of exact hostnames or IP addresses", ); }}; export const defaultAllowedHosts = (host: string): string[] => { const normalized = normalizeControllerHost(host); if (!normalized) throw new Error("LOCAL_STUDIO_HOST must be a hostname or IP address"); if (isLoopbackHost(normalized)) return [...new Set([normalized, ...loopbackHosts])]; if (isWildcardHost(normalized)) return []; return [normalized];}; export const normalizeRequestAuthority = (value: string, expectedPort: number): string | null => { const candidate = value.trim().toLowerCase(); if (!candidate || /[\s/@?#]/.test(candidate)) return null; const bracketed = candidate.startsWith("["); const match = bracketed ? candidate.match(/^\[([^\]]+)](?::([0-9]+))?$/) : candidate.match(/^([^:]+)(?::([0-9]+))?$/); if (!match?.[1]) return null; if (bracketed && isIP(match[1]) !== 6) return null; const host = normalizeControllerHost(match[1]); if (!host || isWildcardHost(host)) return null; const suppliedPort = match[2]; if (suppliedPort !== undefined) { const port = Number(suppliedPort); if (!Number.isSafeInteger(port) || port < 1 || port > 65_535 || port !== expectedPort) { return null; } } return host;};
import { Schema } from "effect";
import { isIP } from "node:net";
const hostnamePattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
const numericIpv4Pattern = /^(?:0x[0-9a-f]+|[0-9]+)(?:\.(?:0x[0-9a-f]+|[0-9]+))*$/;
const loopbackHosts = ["localhost", "127.0.0.1", "::1", "host.docker.internal"];
const normalizeIp = (value: string): string | null => {
const version = isIP(value);
if (version === 4) return value;
if (version !== 6) return null;
try {
return new URL(`http://[${value}]`).hostname.slice(1, -1).toLowerCase();
} catch {
return null;
}
};
export const normalizeControllerHost = (value: string): string | null => {
const candidate = value.trim().toLowerCase();
const bracketed = candidate.startsWith("[") || candidate.endsWith("]");
if (bracketed && !(candidate.startsWith("[") && candidate.endsWith("]"))) return null;
const unwrapped = bracketed ? candidate.slice(1, -1) : candidate;
const ip = normalizeIp(unwrapped);
if (ip) return bracketed && isIP(unwrapped) !== 6 ? null : ip;
if (numericIpv4Pattern.test(unwrapped)) {
try {
const numericIp = new URL(`http://${unwrapped}`).hostname;
return isIP(numericIp) === 4 ? numericIp : null;
} catch {
return null;
}
}
if (
bracketed ||
candidate.length > 253 ||
candidate.endsWith(".") ||
candidate.includes(":") ||
candidate.includes("@") ||
candidate.includes("/") ||
candidate.includes("*")
) {
return null;
}
return candidate.split(".").every((label) => hostnamePattern.test(label)) ? candidate : null;
};
export const isLoopbackHost = (value: string): boolean => {
const normalized = normalizeControllerHost(value);
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
};
export const isWildcardHost = (value: string): boolean => {
const normalized = normalizeControllerHost(value);
return normalized === "0.0.0.0" || normalized === "::";
};
export const normalizeHttpOrigin = (value: string): string | null => {
try {
const parsed = new URL(value.trim());
if (
(parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
parsed.username ||
parsed.password ||
parsed.pathname !== "/" ||
parsed.search ||
parsed.hash ||
!normalizeControllerHost(parsed.hostname)
) {
return null;
}
return parsed.origin === "null" ? null : parsed.origin;
} catch {
return null;
}
};
const allowedHostSchema = Schema.String.check(
Schema.makeFilter(
(value) => {
const normalized = normalizeControllerHost(value);
return normalized !== null && !isWildcardHost(normalized);
},
{ expected: "an exact hostname or IP address" },
),
);
const allowedHostsSchema = Schema.Array(allowedHostSchema).check(Schema.isNonEmpty());
export const decodeAllowedHosts = (value: string): string[] => {
try {
const entries = Schema.decodeUnknownSync(allowedHostsSchema)(value.split(","));
return [
...new Set(
entries.flatMap((entry) => {
const normalized = normalizeControllerHost(entry);
return normalized ? [normalized] : [];
}),
),
];
} catch {
throw new Error(
"LOCAL_STUDIO_ALLOWED_HOSTS must contain a nonempty comma-separated list of exact hostnames or IP addresses",
);
}
};
export const defaultAllowedHosts = (host: string): string[] => {
const normalized = normalizeControllerHost(host);
if (!normalized) throw new Error("LOCAL_STUDIO_HOST must be a hostname or IP address");
if (isLoopbackHost(normalized)) return [...new Set([normalized, ...loopbackHosts])];
if (isWildcardHost(normalized)) return [];
return [normalized];
};
export const normalizeRequestAuthority = (value: string, expectedPort: number): string | null => {
const candidate = value.trim().toLowerCase();
if (!candidate || /[\s/@?#]/.test(candidate)) return null;
const bracketed = candidate.startsWith("[");
const match = bracketed
? candidate.match(/^\[([^\]]+)](?::([0-9]+))?$/)
: candidate.match(/^([^:]+)(?::([0-9]+))?$/);
if (!match?.[1]) return null;
if (bracketed && isIP(match[1]) !== 6) return null;
const host = normalizeControllerHost(match[1]);
if (!host || isWildcardHost(host)) return null;
const suppliedPort = match[2];
if (suppliedPort !== undefined) {
const port = Number(suppliedPort);
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535 || port !== expectedPort) {
return null;
}
}
return host;
};