Files
Multi-Debrid-Downloader/src/main/browser-security.ts
T

163 lines
5.3 KiB
TypeScript

import path from "node:path";
import { fileURLToPath } from "node:url";
import { shell, type WebPreferences } from "electron";
export type HttpsHostRule = {
hostname: string;
includeSubdomains?: boolean;
};
type NavigationEvent = {
preventDefault: () => void;
};
type SecurityWindow = {
webContents: {
on: unknown;
setWindowOpenHandler: unknown;
session: {
setPermissionRequestHandler: unknown;
};
};
};
export type MainWindowSecurityOptions = {
rendererUrl: string;
externalHosts: readonly HttpsHostRule[];
};
export type RemoteLoginSecurityOptions = {
providerHosts: readonly HttpsHostRule[];
externalHosts: readonly HttpsHostRule[];
};
export const MAIN_WINDOW_EXTERNAL_HOSTS: readonly HttpsHostRule[] = [
{ hostname: "github.com" },
{ hostname: "codeberg.org" },
{ hostname: "real-debrid.com", includeSubdomains: true },
{ hostname: "alldebrid.com", includeSubdomains: true },
{ hostname: "bestdebrid.com", includeSubdomains: true }
];
export const REALDEBRID_LOGIN_HOSTS: readonly HttpsHostRule[] = [
{ hostname: "real-debrid.com", includeSubdomains: true }
];
export const ALLDEBRID_LOGIN_HOSTS: readonly HttpsHostRule[] = [
{ hostname: "alldebrid.com", includeSubdomains: true }
];
export function createMainWindowWebPreferences(preload: string): WebPreferences {
return {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false,
preload
};
}
export function createRemoteLoginWebPreferences(partition: string): WebPreferences {
return {
partition,
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
webSecurity: true,
allowRunningInsecureContent: false
};
}
export function isAllowedHttpsUrl(rawUrl: string, hosts: readonly HttpsHostRule[]): boolean {
let parsed: URL;
try {
parsed = new URL(String(rawUrl || ""));
} catch {
return false;
}
if (parsed.protocol !== "https:") {
return false;
}
const hostname = parsed.hostname.toLowerCase();
return hosts.some((host) => {
const expected = host.hostname.toLowerCase();
return hostname === expected || (host.includeSubdomains === true && hostname.endsWith(`.${expected}`));
});
}
export async function openAllowedExternalUrl(rawUrl: string, hosts: readonly HttpsHostRule[]): Promise<boolean> {
if (!isAllowedHttpsUrl(rawUrl, hosts)) {
return false;
}
try {
await shell.openExternal(new URL(rawUrl).toString());
return true;
} catch {
return false;
}
}
export function applyMainWindowSecurity(window: SecurityWindow, options: MainWindowSecurityOptions): void {
applyCommonSecurity(window, options.externalHosts);
const handleNavigation = (event: NavigationEvent, url: string): void => {
if (isExpectedRendererUrl(url, options.rendererUrl)) {
return;
}
event.preventDefault();
void openAllowedExternalUrl(url, options.externalHosts);
};
onNavigation(window, "will-navigate", handleNavigation);
onNavigation(window, "will-redirect", handleNavigation);
}
export function applyRemoteLoginSecurity(window: SecurityWindow, options: RemoteLoginSecurityOptions): void {
applyCommonSecurity(window, options.externalHosts);
const handleNavigation = (event: NavigationEvent, url: string): void => {
if (isAllowedHttpsUrl(url, options.providerHosts)) {
return;
}
event.preventDefault();
void openAllowedExternalUrl(url, options.externalHosts);
};
onNavigation(window, "will-navigate", handleNavigation);
onNavigation(window, "will-redirect", handleNavigation);
}
function applyCommonSecurity(window: SecurityWindow, externalHosts: readonly HttpsHostRule[]): void {
const setWindowOpenHandler = window.webContents.setWindowOpenHandler as (handler: (details: { url: string }) => { action: "deny" }) => unknown;
setWindowOpenHandler.call(window.webContents, (details) => {
void openAllowedExternalUrl(details.url, externalHosts);
return { action: "deny" };
});
const setPermissionRequestHandler = window.webContents.session.setPermissionRequestHandler as (handler: (webContents: unknown, permission: string, callback: (allowed: boolean) => void) => void) => unknown;
setPermissionRequestHandler.call(window.webContents.session, (_webContents, _permission, callback) => {
callback(false);
});
}
function onNavigation(window: SecurityWindow, event: "will-navigate" | "will-redirect", listener: (event: NavigationEvent, url: string) => void): void {
const on = window.webContents.on as (event: "will-navigate" | "will-redirect", listener: (event: NavigationEvent, url: string) => void) => unknown;
on.call(window.webContents, event, listener);
}
function isExpectedRendererUrl(rawUrl: string, expectedUrl: string): boolean {
try {
const parsed = new URL(String(rawUrl || ""));
const expected = new URL(String(expectedUrl || ""));
if (expected.protocol === "file:") {
if (parsed.protocol !== "file:") {
return false;
}
const actualPath = path.resolve(fileURLToPath(parsed));
const expectedPath = path.resolve(fileURLToPath(expected));
return process.platform === "win32"
? actualPath.toLowerCase() === expectedPath.toLowerCase()
: actualPath === expectedPath;
}
return parsed.origin === expected.origin;
} catch {
return false;
}
}