mirror of
https://github.com/Pandipipas/scoreko-electron-dev.git
synced 2026-06-05 21:22:07 +00:00
feat(hardening): completar fase 3 con validación, navegación segura y shutdown
This commit is contained in:
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user