feat(hardening): completar fase 3 con validación, navegación segura y shutdown

This commit is contained in:
Pandipipas
2026-02-21 18:46:32 +01:00
parent 50b145a320
commit 5c4ab5bed4
6 changed files with 172 additions and 14 deletions
+32 -4
View File
@@ -11,18 +11,21 @@ export type AppRuntimeConfig = {
nodecgKillTimeoutMs: number; nodecgKillTimeoutMs: number;
}; };
const MIN_TCP_PORT = 1;
const MAX_TCP_PORT = 65535;
export function getRuntimeConfig(): AppRuntimeConfig { export function getRuntimeConfig(): AppRuntimeConfig {
return { return {
title: getEnv("SCOREKO_APP_TITLE", "Scoreko"), title: getEnv("SCOREKO_APP_TITLE", "Scoreko"),
userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"), userModelId: getEnv("SCOREKO_APP_USER_MODEL_ID", "com.scoreko.desktop"),
iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"), iconPathOverride: getOptionalEnv("SCOREKO_APP_ICON_PATH"),
nodecgPort: getEnv("NODECG_PORT", "9090"), nodecgPort: parseEnvPort("NODECG_PORT", "9090"),
bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"), bundleName: getEnv("NODECG_BUNDLE_NAME", "scoreko-dev"),
dashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"), dashboardRoute: getEnv("SCOREKO_DASHBOARD_ROUTE", "dashboard/scoreko-dev/main.html?standalone=true"),
loadingRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"), loadingRoute: getEnv("SCOREKO_LOADING_ROUTE", "dashboard/loading/main.html?standalone=true"),
loadDelayMs: parseEnvInt("ELECTRON_LOAD_DELAY_MS", 10000), loadDelayMs: parseEnvIntInRange("ELECTRON_LOAD_DELAY_MS", 10000, 0, 600000),
startupTimeoutMs: parseEnvInt("NODECG_STARTUP_TIMEOUT_MS", 30000), startupTimeoutMs: parseEnvIntInRange("NODECG_STARTUP_TIMEOUT_MS", 30000, 1000, 600000),
nodecgKillTimeoutMs: parseEnvInt("NODECG_KILL_TIMEOUT_MS", 2500), 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); const parsedValue = Number.parseInt(rawValue, 10);
return Number.isFinite(parsedValue) ? parsedValue : fallback; 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);
}
+27 -6
View File
@@ -24,9 +24,11 @@ const nodecgManager = createNodecgProcessManager({
log, log,
}); });
type AppShutdownState = "running" | "stopping" | "stopped";
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let loadingWindow: BrowserWindow | null = null; let loadingWindow: BrowserWindow | null = null;
let isQuitting = false; let shutdownState: AppShutdownState = "running";
async function launch(): Promise<void> { async function launch(): Promise<void> {
mainWindow = createMainWindow({ runtimeConfig, rootPath, dashboardUrl }); mainWindow = createMainWindow({ runtimeConfig, rootPath, dashboardUrl });
@@ -75,6 +77,22 @@ function closeLoadingWindow(): void {
loadingWindow = null; loadingWindow = null;
} }
function stopNodecgGracefully(): Promise<void> {
if (shutdownState === "stopped") {
return Promise.resolve();
}
if (shutdownState === "stopping") {
return nodecgManager.stopNodeCG();
}
shutdownState = "stopping";
return nodecgManager.stopNodeCG().finally(() => {
shutdownState = "stopped";
});
}
app.on("ready", () => { app.on("ready", () => {
app.setName(runtimeConfig.title); app.setName(runtimeConfig.title);
@@ -104,24 +122,27 @@ app.on("window-all-closed", () => {
}); });
app.on("before-quit", (event) => { app.on("before-quit", (event) => {
if (isQuitting) { if (shutdownState !== "running") {
return; return;
} }
event.preventDefault(); event.preventDefault();
isQuitting = true;
nodecgManager.stopNodeCG().finally(() => { stopNodecgGracefully().finally(() => {
app.quit(); app.quit();
}); });
}); });
app.on("will-quit", () => { app.on("will-quit", () => {
void nodecgManager.stopNodeCG(); if (shutdownState === "running") {
void stopNodecgGracefully();
}
}); });
process.on("exit", () => { process.on("exit", () => {
void nodecgManager.stopNodeCG(); if (shutdownState === "running") {
void stopNodecgGracefully();
}
}); });
process.on("uncaughtException", (error) => { process.on("uncaughtException", (error) => {
+41
View File
@@ -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";
}
+10 -1
View File
@@ -2,6 +2,7 @@ import { BrowserWindow, BrowserWindowConstructorOptions, shell } from "electron"
import { AppRuntimeConfig } from "../config/runtime-config"; import { AppRuntimeConfig } from "../config/runtime-config";
import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants"; import { DEFAULT_WINDOW_BACKGROUND, DEFAULT_WINDOW_SIZE, LOADING_WINDOW_SIZE } from "../constants";
import { resolveAppIconPath } from "./icon-path"; import { resolveAppIconPath } from "./icon-path";
import { shouldAllowInternalNavigation, shouldOpenExternalNavigation } from "./navigation-security";
type WindowFactoryDependencies = { type WindowFactoryDependencies = {
runtimeConfig: AppRuntimeConfig; runtimeConfig: AppRuntimeConfig;
@@ -16,13 +17,21 @@ export function createMainWindow({ runtimeConfig, rootPath, dashboardUrl }: Wind
window.setMenuBarVisibility(false); window.setMenuBarVisibility(false);
window.webContents.setWindowOpenHandler(({ url }) => { window.webContents.setWindowOpenHandler(({ url }) => {
if (shouldOpenExternalNavigation(url)) {
void shell.openExternal(url); void shell.openExternal(url);
}
return { action: "deny" }; return { action: "deny" };
}); });
window.webContents.on("will-navigate", (event, url) => { window.webContents.on("will-navigate", (event, url) => {
if (url !== dashboardUrl) { if (shouldAllowInternalNavigation(url, dashboardUrl)) {
return;
}
event.preventDefault(); event.preventDefault();
if (shouldOpenExternalNavigation(url)) {
void shell.openExternal(url); void shell.openExternal(url);
} }
}); });
+35
View File
@@ -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);
});
+25 -1
View File
@@ -1,7 +1,7 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; 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 { function withEnv(name: string, value: string | undefined, run: () => void): void {
const previousValue = process.env[name]; const previousValue = process.env[name];
@@ -59,3 +59,27 @@ test("parseEnvInt parsea enteros válidos", () => {
assert.equal(parseEnvInt("TEST_ENV_INT", 100), 4500); 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");
});
});