Mission Control previously set Access-Control-Allow-Origin: * on every route. That widens cross-origin browser access when Cockpit binds beyond loopback, even with bearer auth. Add server-config helpers compatible with PR #28 bind/token patterns: - loopback binds default to local web/API origins for the Heute shell - privileged /api/* routes never receive *; only reflected allowlist hits - non-loopback binds require explicit EVOO_COCKPIT_CORS_ORIGIN - default bind moves to 127.0.0.1 with token gate off-loopback Document env vars in .env.example and cover config/CORS in unit tests. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
136 lines
4.3 KiB
JavaScript
136 lines
4.3 KiB
JavaScript
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
|
|
/** Default web/API origins allowed on loopback binds for the Heute iframe shell. */
|
|
const DEFAULT_LOOPBACK_WEB_ORIGINS = [
|
|
"http://127.0.0.1:5173",
|
|
"http://localhost:5173",
|
|
"http://127.0.0.1:3100",
|
|
"http://localhost:3100",
|
|
];
|
|
|
|
export function isLoopbackHost(host) {
|
|
return LOOPBACK_HOSTS.has(host.trim());
|
|
}
|
|
|
|
export function timingSafeStringEqual(supplied, expected) {
|
|
const actual = Buffer.from(expected);
|
|
const candidate = Buffer.from(supplied ?? "");
|
|
if (actual.length !== candidate.length) return false;
|
|
let difference = 0;
|
|
for (let index = 0; index < actual.length; index += 1) {
|
|
difference |= actual[index] ^ candidate[index];
|
|
}
|
|
return difference === 0;
|
|
}
|
|
|
|
export function hasValidBearerToken(authorization, expectedToken) {
|
|
const supplied = authorization?.startsWith("Bearer ") ? authorization.slice(7) : "";
|
|
return timingSafeStringEqual(supplied, expectedToken);
|
|
}
|
|
|
|
export function isPublicCockpitRoute(pathname) {
|
|
return pathname === "/health" || pathname === "/api/health";
|
|
}
|
|
|
|
/** Privileged API routes must never receive Access-Control-Allow-Origin: *. */
|
|
export function isPrivilegedCockpitRoute(pathname) {
|
|
if (isPublicCockpitRoute(pathname)) return false;
|
|
return pathname.startsWith("/api/");
|
|
}
|
|
|
|
export function parseCorsOriginAllowlist(value) {
|
|
if (!value?.trim()) return [];
|
|
return [...new Set(value.split(",").map((entry) => entry.trim()).filter(Boolean))];
|
|
}
|
|
|
|
/**
|
|
* Cockpit bind/auth settings. Defaults to loopback-only with no token so local
|
|
* Mission Control keeps working out of the box.
|
|
*/
|
|
export function resolveCockpitConfig(env) {
|
|
const port = Number.parseInt(env.PORT?.trim() || "3088", 10);
|
|
if (!Number.isInteger(port) || port <= 0 || port > 65_535) {
|
|
throw new Error("PORT is not a valid port number.");
|
|
}
|
|
|
|
const host = env.EVOO_COCKPIT_HOST?.trim() || "127.0.0.1";
|
|
const loopback = isLoopbackHost(host);
|
|
const token = env.EVOO_COCKPIT_TOKEN?.trim() || "";
|
|
|
|
if (!loopback && token === "") {
|
|
throw new Error(
|
|
"Set EVOO_COCKPIT_TOKEN when EVOO_COCKPIT_HOST is not loopback (127.0.0.1, localhost, or ::1).",
|
|
);
|
|
}
|
|
|
|
return {
|
|
port,
|
|
host,
|
|
token: loopback ? null : token,
|
|
requiresAuth: !loopback,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* CORS allowlist. Loopback binds default to local web/API origins; non-loopback
|
|
* binds require an explicit EVOO_COCKPIT_CORS_ORIGIN comma-separated list.
|
|
*/
|
|
export function resolveCockpitCorsConfig(env, cockpitConfig) {
|
|
const explicit = parseCorsOriginAllowlist(env.EVOO_COCKPIT_CORS_ORIGIN);
|
|
const loopbackBind = isLoopbackHost(cockpitConfig.host);
|
|
|
|
if (!loopbackBind && explicit.length === 0) {
|
|
return {
|
|
loopbackBind: false,
|
|
allowedOrigins: [],
|
|
usesDefaultLoopbackOrigins: false,
|
|
};
|
|
}
|
|
|
|
const usesDefaultLoopbackOrigins = loopbackBind && explicit.length === 0;
|
|
const allowedOrigins = usesDefaultLoopbackOrigins ? DEFAULT_LOOPBACK_WEB_ORIGINS : explicit;
|
|
|
|
return {
|
|
loopbackBind,
|
|
allowedOrigins,
|
|
usesDefaultLoopbackOrigins,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Resolve the Access-Control-Allow-Origin value for a request.
|
|
* Returns a reflected origin string, null when CORS headers should be omitted,
|
|
* or false when the Origin is present but not allowed.
|
|
*/
|
|
export function resolveAllowedCorsOrigin(requestOrigin, corsConfig, pathname) {
|
|
const origin = requestOrigin?.trim() ?? "";
|
|
if (!origin) return null;
|
|
|
|
if (isPrivilegedCockpitRoute(pathname)) {
|
|
return corsConfig.allowedOrigins.includes(origin) ? origin : false;
|
|
}
|
|
|
|
if (corsConfig.loopbackBind && corsConfig.usesDefaultLoopbackOrigins) {
|
|
return corsConfig.allowedOrigins.includes(origin) ? origin : false;
|
|
}
|
|
|
|
return corsConfig.allowedOrigins.includes(origin) ? origin : false;
|
|
}
|
|
|
|
export function applyCockpitCorsHeaders(req, res, pathname, corsConfig) {
|
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
|
|
const allowed = resolveAllowedCorsOrigin(req.headers.origin, corsConfig, pathname);
|
|
if (typeof allowed === "string") {
|
|
res.setHeader("Access-Control-Allow-Origin", allowed);
|
|
res.setHeader("Vary", "Origin");
|
|
}
|
|
return allowed;
|
|
}
|
|
|
|
export function formatCockpitListenUrl(host, port) {
|
|
const displayHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
return `http://${displayHost}:${port}`;
|
|
}
|