diff --git a/src/main/config/runtime-config.ts b/src/main/config/runtime-config.ts index c2668d5..c1e555d 100644 --- a/src/main/config/runtime-config.ts +++ b/src/main/config/runtime-config.ts @@ -11,18 +11,21 @@ export type AppRuntimeConfig = { nodecgKillTimeoutMs: number; }; +const MIN_TCP_PORT = 1; +const MAX_TCP_PORT = 65535; + export function getRuntimeConfig(): AppRuntimeConfig { return { title: getEnv("SCOREKO_APP_TITLE", "Scoreko"), userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"), iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"), - nodecgPort: getEnv("NODECG_PORT", "9090"), + nodecgPort: parseEnvPort("NODECG_PORT", "9090"), bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"), dashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"), loadingRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"), - loadDelayMs: parseEnvInt("ELECTRON_LOAD_DELAY_MS", 10000), - startupTimeoutMs: parseEnvInt("NODECG_STARTUP_TIMEOUT_MS", 30000), - nodecgKillTimeoutMs: parseEnvInt("NODECG_KILL_TIMEOUT_MS", 2500), + loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000), + startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000), + nodecgKillTimeoutMs: parseEnvIntInRange("NODECG_KILL_TIMEOUT_MS", 2500, 0, 120000), }; } @@ -44,3 +47,28 @@ export function parseEnvInt(name: string, fallback: number): number { const parsedValue = Number.parseInt(rawValue, 10); return Number.isFinite(parsedValue) ? parsedValue : fallback; } + +export function parseEnvIntInRange(name: string, fallback: number, min: number, max: number): number { + const rawValue = process.env[name]; + if (!rawValue) { + return fallback; + } + + const parsedValue = Number.parseInt(rawValue, 10); + if (!Number.isFinite(parsedValue) || parsedValue < min || parsedValue > max) { + throw new Error(`La variable ${name} debe ser un entero entre ${min} y ${max}. Valor recibido: '${rawValue}'.`); + } + + return parsedValue; +} + +export function parseEnvPort(name: string, fallback: string): string { + const rawValue = getEnv(name, fallback); + const parsedValue = Number.parseInt(rawValue, 10); + + if (!Number.isFinite(parsedValue) || parsedValue < MIN_TCP_PORT || parsedValue > MAX_TCP_PORT) { + throw new Error(`La variable ${name} debe ser un puerto TCP válido (${MIN_TCP_PORT}-${MAX_TCP_PORT}). Valor recibido: '${rawValue}'.`); + } + + return String(parsedValue); +} diff --git a/src/main/main.ts b/src/main/main.ts index 29a8a8c..3757bb5 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -24,9 +24,11 @@ const nodecgManager = createNodecgProcessManager({ log, }); +type AppShutdownState = "running" | "stopping" | "stopped"; + let mainWindow: BrowserWindow | null = null; let loadingWindow: BrowserWindow | null = null; -let isQuitting = false; +let shutdownState: AppShutdownState = "running"; async function launch(): Promise { mainWindow = createMainWindow({ runtimeConfig, rootPath, dashboardUrl }); @@ -75,6 +77,22 @@ function closeLoadingWindow(): void { loadingWindow = null; } +function stopNodecgGracefully(): Promise { + if (shutdownState === "stopped") { + return Promise.resolve(); + } + + if (shutdownState === "stopping") { + return nodecgManager.stopNodeCG(); + } + + shutdownState = "stopping"; + + return nodecgManager.stopNodeCG().finally(() => { + shutdownState = "stopped"; + }); +} + app.on("ready", () => { app.setName(runtimeConfig.title); @@ -104,24 +122,27 @@ app.on("window-all-closed", () => { }); app.on("before-quit", (event) => { - if (isQuitting) { + if (shutdownState !== "running") { return; } event.preventDefault(); - isQuitting = true; - nodecgManager.stopNodeCG().finally(() => { + stopNodecgGracefully().finally(() => { app.quit(); }); }); app.on("will-quit", () => { - void nodecgManager.stopNodeCG(); + if (shutdownState === "running") { + void stopNodecgGracefully(); + } }); process.on("exit", () => { - void nodecgManager.stopNodeCG(); + if (shutdownState === "running") { + void stopNodecgGracefully(); + } }); process.on("uncaughtException", (error) => { diff --git a/src/main/windows/navigation-security.ts b/src/main/windows/navigation-security.ts new file mode 100644 index 0000000..149c4ef --- /dev/null +++ b/src/main/windows/navigation-security.ts @@ -0,0 +1,41 @@ +const SAFE_EXTERNAL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]); + +export function shouldAllowInternalNavigation(targetUrl: string, dashboardUrl: string): boolean { + try { + const target = new URL(targetUrl); + const dashboard = new URL(dashboardUrl); + + if (!isSafeProtocol(target.protocol)) { + return false; + } + + if (!isLoopbackHost(target.hostname)) { + return false; + } + + if (target.port !== dashboard.port) { + return false; + } + + return target.pathname.startsWith("/bundles/"); + } catch { + return false; + } +} + +export function shouldOpenExternalNavigation(targetUrl: string): boolean { + try { + const target = new URL(targetUrl); + return SAFE_EXTERNAL_PROTOCOLS.has(target.protocol); + } catch { + return false; + } +} + +function isSafeProtocol(protocol: string): boolean { + return protocol === "http:" || protocol === "https:"; +} + +function isLoopbackHost(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1"; +} diff --git a/src/main/windows/window-factory.ts b/src/main/windows/window-factory.ts index 2241402..4d38d6e 100644 --- a/src/main/windows/window-factory.ts +++ b/src/main/windows/window-factory.ts @@ -2,6 +2,7 @@ import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron" import { AppRuntimeConfig } from "../config/runtime-config"; import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants"; import { resolveAppIconPath } from "./icon-path"; +import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-security"; type WindowFactoryDependencies = { runtimeConfig: AppRuntimeConfig; @@ -16,13 +17,21 @@ export function createMainWindow({ runtimeConfig, rootPath, dashboardUrl }: Wind window.setMenuBarVisibility(false); window.webContents.setWindowOpenHandler(({ url }) => { - void shell.openExternal(url); + if (shouldOpenExternalNavigation(url)) { + void shell.openExternal(url); + } + return { action: "deny" }; }); window.webContents.on("will-navigate", (event, url) => { - if (url !== dashboardUrl) { - event.preventDefault(); + if (shouldAllowInternalNavigation(url, dashboardUrl)) { + return; + } + + event.preventDefault(); + + if (shouldOpenExternalNavigation(url)) { void shell.openExternal(url); } }); diff --git a/src/tests/navigation-security.test.ts b/src/tests/navigation-security.test.ts new file mode 100644 index 0000000..0f2ec39 --- /dev/null +++ b/src/tests/navigation-security.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "../main/windows/navigation-security"; + +const dashboardUrl = "http://localhost:9090/bundles/scoreko-dev/dashboard/main.html"; + +test("shouldAllowInternalNavigation permite navegación interna esperada", () => { + assert.equal( + shouldAllowInternalNavigation("http://127.0.0.1:9090/bundles/scoreko-dev/dashboard/page.html", dashboardUrl), + true, + ); +}); + +test("shouldAllowInternalNavigation rechaza host no permitido", () => { + assert.equal(shouldAllowInternalNavigation("http://evil.local:9090/bundles/scoreko-dev/dashboard/page.html", dashboardUrl), false); +}); + +test("shouldAllowInternalNavigation rechaza puerto distinto", () => { + assert.equal(shouldAllowInternalNavigation("http://localhost:8080/bundles/scoreko-dev/dashboard/page.html", dashboardUrl), false); +}); + +test("shouldAllowInternalNavigation rechaza esquemas inseguros", () => { + assert.equal(shouldAllowInternalNavigation("javascript:alert(1)", dashboardUrl), false); +}); + +test("shouldOpenExternalNavigation permite protocolos externos seguros", () => { + assert.equal(shouldOpenExternalNavigation("https://scoreko.com/docs"), true); + assert.equal(shouldOpenExternalNavigation("mailto:test@scoreko.com"), true); +}); + +test("shouldOpenExternalNavigation rechaza protocolos inseguros", () => { + assert.equal(shouldOpenExternalNavigation("file:///etc/passwd"), false); + assert.equal(shouldOpenExternalNavigation("javascript:alert(1)"), false); +}); diff --git a/src/tests/runtime-config.test.ts b/src/tests/runtime-config.test.ts index 5a8ed4f..d2653fa 100644 --- a/src/tests/runtime-config.test.ts +++ b/src/tests/runtime-config.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { getEnv, getOptionalEnv, parseEnvInt } from "../main/config/runtime-config"; +import { getEnv, getOptionalEnv, parseEnvInt, parseEnvIntInRange, parseEnvPort } from "../main/config/runtime-config"; function withEnv(name: string, value: string | undefined, run: () => void): void { const previousValue = process.env[name]; @@ -59,3 +59,27 @@ test("parseEnvInt parsea enteros válidos", () => { assert.equal(parseEnvInt("TEST_ENV_INT", 100), 4500); }); }); + +test("parseEnvIntInRange hace hard-fail para valores fuera de rango", () => { + withEnv("TEST_ENV_INT_RANGE", "999", () => { + assert.throws(() => parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), /debe ser un entero/); + }); +}); + +test("parseEnvIntInRange acepta valor válido", () => { + withEnv("TEST_ENV_INT_RANGE", "42", () => { + assert.equal(parseEnvIntInRange("TEST_ENV_INT_RANGE", 100, 0, 100), 42); + }); +}); + +test("parseEnvPort valida rango TCP", () => { + withEnv("TEST_ENV_PORT", "70000", () => { + assert.throws(() => parseEnvPort("TEST_ENV_PORT", "9090"), /puerto TCP válido/); + }); +}); + +test("parseEnvPort normaliza el puerto válido", () => { + withEnv("TEST_ENV_PORT", "009090", () => { + assert.equal(parseEnvPort("TEST_ENV_PORT", "9090"), "9090"); + }); +});