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>
208 lines
7.1 KiB
JavaScript
208 lines
7.1 KiB
JavaScript
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();
|
|
});
|
|
});
|