diff --git a/.env.example b/.env.example index 95bbfda..1de0602 100644 --- a/.env.example +++ b/.env.example @@ -206,6 +206,17 @@ RAKAZO_UPDATER_IMAGE_TAG=local RAKAZO_UPDATER_URL= RAKAZO_UPDATER_TOKEN= +# --- evOo Mission Control (Cockpit on port 3088) --- +PORT=3088 +EVOO_COCKPIT_HOST=127.0.0.1 +# Required when EVOO_COCKPIT_HOST binds beyond loopback (e.g. 0.0.0.0 for LAN/Tailscale). +# Send as Authorization: Bearer on all routes except /health and /api/health. +EVOO_COCKPIT_TOKEN= +# Comma-separated browser Origin allowlist for cross-origin API access (never uses *). +# Loopback binds default to http://127.0.0.1:5173 and http://localhost:5173 when unset. +# Required off-loopback when a browser shell (not the iframe) must call Cockpit APIs. +EVOO_COCKPIT_CORS_ORIGIN= + # Optional default UI locale for the web SPA (en | de | ko | tr | hi | pt-BR | zh-CN). # Overridden by localStorage key rakazo.uiLocale when the user picks a language in Settings. # VITE_DEFAULT_UI_LOCALE=en diff --git a/evoo-platform/server-config.js b/evoo-platform/server-config.js new file mode 100644 index 0000000..218d9c8 --- /dev/null +++ b/evoo-platform/server-config.js @@ -0,0 +1,135 @@ +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}`; +} diff --git a/evoo-platform/server-config.test.js b/evoo-platform/server-config.test.js new file mode 100644 index 0000000..17b34b3 --- /dev/null +++ b/evoo-platform/server-config.test.js @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; +import { + applyCockpitCorsHeaders, + formatCockpitListenUrl, + hasValidBearerToken, + isLoopbackHost, + isPrivilegedCockpitRoute, + isPublicCockpitRoute, + resolveAllowedCorsOrigin, + resolveCockpitConfig, + resolveCockpitCorsConfig, +} from "./server-config.js"; + +describe("resolveCockpitConfig", () => { + it("binds to loopback by default without requiring a token", () => { + expect(resolveCockpitConfig({})).toEqual({ + port: 3088, + host: "127.0.0.1", + token: null, + requiresAuth: false, + }); + }); + + it("honours explicit loopback hosts", () => { + for (const host of ["127.0.0.1", "localhost", "::1"]) { + expect(resolveCockpitConfig({ EVOO_COCKPIT_HOST: host })).toMatchObject({ + host, + token: null, + requiresAuth: false, + }); + } + }); + + it("requires a shared token before binding beyond loopback", () => { + expect(() => resolveCockpitConfig({ EVOO_COCKPIT_HOST: "0.0.0.0" })).toThrow( + /EVOO_COCKPIT_TOKEN/, + ); + }); + + it("allows a non-loopback bind when a token is configured", () => { + expect( + resolveCockpitConfig({ + EVOO_COCKPIT_HOST: "0.0.0.0", + EVOO_COCKPIT_TOKEN: "cockpit-review-token-000000000000", + }), + ).toEqual({ + port: 3088, + host: "0.0.0.0", + token: "cockpit-review-token-000000000000", + requiresAuth: true, + }); + }); + + it("rejects invalid ports", () => { + expect(() => resolveCockpitConfig({ PORT: "0" })).toThrow(/PORT/); + expect(() => resolveCockpitConfig({ PORT: "abc" })).toThrow(/PORT/); + }); +}); + +describe("cockpit auth helpers", () => { + const token = "cockpit-review-token-000000000000"; + + it("treats health routes as public when auth is enabled", () => { + expect(isPublicCockpitRoute("/health")).toBe(true); + expect(isPublicCockpitRoute("/api/health")).toBe(true); + expect(isPublicCockpitRoute("/api/status")).toBe(false); + }); + + it("accepts only bearer tokens with constant-time comparison", () => { + expect(hasValidBearerToken(`Bearer ${token}`, token)).toBe(true); + expect(hasValidBearerToken(`Bearer ${`${token}x`}`, token)).toBe(false); + expect(hasValidBearerToken(token, token)).toBe(false); + expect(hasValidBearerToken(undefined, token)).toBe(false); + }); + + it("formats listen URLs with bracketed IPv6 hosts", () => { + expect(formatCockpitListenUrl("127.0.0.1", 3088)).toBe("http://127.0.0.1:3088"); + expect(formatCockpitListenUrl("::1", 3088)).toBe("http://[::1]:3088"); + }); +}); + +describe("isLoopbackHost", () => { + it("recognises loopback hosts only", () => { + expect(isLoopbackHost("127.0.0.1")).toBe(true); + expect(isLoopbackHost("localhost")).toBe(true); + expect(isLoopbackHost("::1")).toBe(true); + expect(isLoopbackHost("0.0.0.0")).toBe(false); + expect(isLoopbackHost("192.168.1.10")).toBe(false); + }); +}); + +describe("isPrivilegedCockpitRoute", () => { + it("marks API routes except health as privileged", () => { + expect(isPrivilegedCockpitRoute("/health")).toBe(false); + expect(isPrivilegedCockpitRoute("/api/health")).toBe(false); + expect(isPrivilegedCockpitRoute("/api/status")).toBe(true); + expect(isPrivilegedCockpitRoute("/api/trading-bot/mode")).toBe(true); + expect(isPrivilegedCockpitRoute("/index.html")).toBe(false); + }); +}); + +describe("resolveCockpitCorsConfig", () => { + const loopbackCockpit = resolveCockpitConfig({ EVOO_COCKPIT_HOST: "127.0.0.1" }); + const publicCockpit = resolveCockpitConfig({ + EVOO_COCKPIT_HOST: "0.0.0.0", + EVOO_COCKPIT_TOKEN: "cockpit-review-token-000000000000", + }); + + it("defaults to local web origins on loopback binds", () => { + const cors = resolveCockpitCorsConfig({}, loopbackCockpit); + expect(cors.loopbackBind).toBe(true); + expect(cors.usesDefaultLoopbackOrigins).toBe(true); + expect(cors.allowedOrigins).toContain("http://127.0.0.1:5173"); + }); + + it("requires an explicit allowlist off loopback", () => { + const cors = resolveCockpitCorsConfig({}, publicCockpit); + expect(cors.allowedOrigins).toEqual([]); + expect(cors.usesDefaultLoopbackOrigins).toBe(false); + }); + + it("honours EVOO_COCKPIT_CORS_ORIGIN when set", () => { + const cors = resolveCockpitCorsConfig( + { EVOO_COCKPIT_CORS_ORIGIN: "https://ops.example.com, https://ops.example.com" }, + publicCockpit, + ); + expect(cors.allowedOrigins).toEqual(["https://ops.example.com"]); + }); +}); + +describe("resolveAllowedCorsOrigin", () => { + const loopbackCors = resolveCockpitCorsConfig({}, resolveCockpitConfig({})); + const publicCors = resolveCockpitCorsConfig( + { EVOO_COCKPIT_CORS_ORIGIN: "https://ops.example.com" }, + resolveCockpitConfig({ + EVOO_COCKPIT_HOST: "0.0.0.0", + EVOO_COCKPIT_TOKEN: "cockpit-review-token-000000000000", + }), + ); + const blockedPublicCors = resolveCockpitCorsConfig( + {}, + resolveCockpitConfig({ + EVOO_COCKPIT_HOST: "0.0.0.0", + EVOO_COCKPIT_TOKEN: "cockpit-review-token-000000000000", + }), + ); + + it("never returns a wildcard and omits headers without an Origin", () => { + expect(resolveAllowedCorsOrigin(undefined, loopbackCors, "/api/status")).toBeNull(); + expect(resolveAllowedCorsOrigin("", loopbackCors, "/api/status")).toBeNull(); + }); + + it("reflects allowlisted origins on privileged routes", () => { + expect(resolveAllowedCorsOrigin("http://127.0.0.1:5173", loopbackCors, "/api/status")).toBe( + "http://127.0.0.1:5173", + ); + expect(resolveAllowedCorsOrigin("https://evil.example", loopbackCors, "/api/status")).toBe( + false, + ); + }); + + it("blocks cross-origin privileged access off loopback without an allowlist", () => { + expect( + resolveAllowedCorsOrigin("https://ops.example.com", blockedPublicCors, "/api/status"), + ).toBe(false); + }); + + it("allows configured origins off loopback", () => { + expect( + resolveAllowedCorsOrigin("https://ops.example.com", publicCors, "/api/trading-bot"), + ).toBe("https://ops.example.com"); + }); +}); + +describe("applyCockpitCorsHeaders", () => { + it("sets reflected origins and never sets *", () => { + const cors = resolveCockpitCorsConfig({}, resolveCockpitConfig({})); + const headers = {}; + const res = { setHeader: (key, value) => { headers[key] = value; } }; + const allowed = applyCockpitCorsHeaders( + { headers: { origin: "http://127.0.0.1:5173" } }, + res, + "/api/status", + cors, + ); + + expect(allowed).toBe("http://127.0.0.1:5173"); + expect(headers["Access-Control-Allow-Origin"]).toBe("http://127.0.0.1:5173"); + expect(headers["Access-Control-Allow-Origin"]).not.toBe("*"); + expect(headers["Access-Control-Allow-Headers"]).toContain("Authorization"); + }); + + it("omits Access-Control-Allow-Origin for disallowed cross-origin requests", () => { + const cors = resolveCockpitCorsConfig({}, resolveCockpitConfig({})); + const headers = {}; + const res = { setHeader: (key, value) => { headers[key] = value; } }; + const allowed = applyCockpitCorsHeaders( + { headers: { origin: "https://evil.example" } }, + res, + "/api/status", + cors, + ); + + expect(allowed).toBe(false); + expect(headers["Access-Control-Allow-Origin"]).toBeUndefined(); + }); +}); diff --git a/evoo-platform/server.js b/evoo-platform/server.js index 9ae0aad..3c7c5e8 100644 --- a/evoo-platform/server.js +++ b/evoo-platform/server.js @@ -11,11 +11,21 @@ import http from "node:http"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + applyCockpitCorsHeaders, + formatCockpitListenUrl, + hasValidBearerToken, + isPublicCockpitRoute, + resolveCockpitConfig, + resolveCockpitCorsConfig, +} from "./server-config.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -const PORT = parseInt(process.env.PORT || "3088", 10); +const cockpit = resolveCockpitConfig(process.env); +const corsConfig = resolveCockpitCorsConfig(process.env, cockpit); +const { port: PORT, host: COCKPIT_HOST } = cockpit; const BASE_DIR = __dirname; const ROOT_DIR = path.resolve(BASE_DIR, ".."); @@ -275,19 +285,30 @@ async function getLiveCapitalTelemetry() { } const server = http.createServer(async (req, res) => { - // CORS & Security Headers - res.setHeader("Access-Control-Allow-Origin", "*"); - res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); - res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); + const urlPath = parsedUrl.pathname; + const corsAllowed = applyCockpitCorsHeaders(req, res, urlPath, corsConfig); if (req.method === "OPTIONS") { + if (corsAllowed === false && req.headers.origin) { + res.writeHead(403); + res.end(); + return; + } res.writeHead(204); res.end(); return; } - const parsedUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`); - const urlPath = parsedUrl.pathname; + if ( + cockpit.requiresAuth && + !isPublicCockpitRoute(urlPath) && + !hasValidBearerToken(req.headers.authorization, cockpit.token) + ) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "unauthorized" })); + return; + } // 1. API Health Check if (urlPath === "/health" || urlPath === "/api/health") { @@ -690,12 +711,21 @@ const server = http.createServer(async (req, res) => { } }); -server.listen(PORT, "0.0.0.0", () => { +server.listen(PORT, COCKPIT_HOST, () => { try { fs.writeFileSync(path.join(ROOT_DIR, ".evoo_server.pid"), process.pid.toString(), "utf8"); } catch (_e) { // Ignore if write fails } - console.log(`[evOo Core] Server listening on http://127.0.0.1:${PORT} (PID: ${process.pid})`); - console.log(`[evOo Core] Healthcheck available at http://127.0.0.1:${PORT}/health`); + const listenUrl = formatCockpitListenUrl(COCKPIT_HOST, PORT); + console.log(`[evOo Core] Server listening on ${listenUrl} (PID: ${process.pid})`); + console.log(`[evOo Core] Healthcheck available at ${listenUrl}/health`); + if (cockpit.requiresAuth) { + console.log("[evOo Core] Non-loopback bind: Bearer token required except /health"); + } + if (!corsConfig.loopbackBind && corsConfig.allowedOrigins.length === 0) { + console.log( + "[evOo Core] Non-loopback bind: set EVOO_COCKPIT_CORS_ORIGIN for browser cross-origin access", + ); + } }); diff --git a/vitest.config.ts b/vitest.config.ts index 0e3a59c..56c4211 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ "apps/mobile/plugins/**/*.test.js", "apps/api/src/**/*.test.ts", "apps/www/src/**/*.test.ts", + "evoo-platform/**/*.test.js", ], testTimeout: 30_000, hookTimeout: 60_000,