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;
};
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);
}
+27 -6
View File
@@ -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<void> {
mainWindow = createMainWindow({ runtimeConfig, rootPath, dashboardUrl });
@@ -75,6 +77,22 @@ function closeLoadingWindow(): void {
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.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) => {
+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";
}
+12 -3
View File
@@ -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);
}
});