diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 94a11f23..9282e56a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -1,10 +1,11 @@ -import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, Tray } from 'electron' +import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, shell, Tray } from 'electron' import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import fs from 'node:fs/promises' import { createInterface, type Interface as ReadlineInterface } from 'node:readline' import path from 'node:path' import type { OpenDialogOptions, SaveDialogOptions } from '../src/shared/contracts/platform' import { resolveLinuxOrtSidecar } from './linux-cuda-runtime.mjs' +import { isExternalBrowserUrl, isInternalNavigationUrl, type NavigationPolicy } from './navigation-policy' type RpcResponse = { id?: number @@ -33,6 +34,11 @@ const appDisplayName = process.env.MODFORGE_APP_NAME?.trim() || 'ModForge Studio const appDesktopId = process.env.MODFORGE_DESKTOP_ID?.trim() || 'io.github.Arborsm.ModForgeStudio' const isDev = !app.isPackaged const devUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://127.0.0.1:5173' +const appFilePath = path.resolve(__dirname, '../dist/index.html') +const navigationPolicy: NavigationPolicy = { + devUrl: isDev ? devUrl : undefined, + appFilePath, +} const windowCloseRequestTimeoutMs = 1500 const sidecarStopTimeoutMs = 2500 let mainWindow: BrowserWindow | null = null @@ -370,6 +376,8 @@ function createMainWindow() { }, }) + applyNavigationPolicy(mainWindow) + mainWindow.once('ready-to-show', () => mainWindow?.show()) mainWindow.once('closed', () => { mainWindow = null @@ -399,10 +407,38 @@ function createMainWindow() { if (isDev) { void mainWindow.loadURL(devUrl) } else { - void mainWindow.loadFile(path.resolve(__dirname, '../dist/index.html')) + void mainWindow.loadFile(appFilePath) } } +function applyNavigationPolicy(window: BrowserWindow) { + window.webContents.setWindowOpenHandler(({ url }) => { + openInSystemBrowser(url) + return { action: 'deny' } + }) + + window.webContents.on('will-navigate', (event, url) => { + if (isInternalNavigationUrl(url, navigationPolicy)) { + return + } + + event.preventDefault() + openInSystemBrowser(url) + }) + + window.webContents.on('will-attach-webview', (event) => { + event.preventDefault() + }) +} + +function openInSystemBrowser(url: string) { + if (!isExternalBrowserUrl(url)) { + return + } + + void shell.openExternal(url) +} + function createTray() { const iconPath = resolveWindowIconPath() tray = new Tray(iconPath) diff --git a/apps/desktop/electron/navigation-policy.ts b/apps/desktop/electron/navigation-policy.ts new file mode 100644 index 00000000..b35bba6f --- /dev/null +++ b/apps/desktop/electron/navigation-policy.ts @@ -0,0 +1,52 @@ +/** + * Navigation policy for the Electron renderer. The renderer holds a privileged + * preload bridge, so it must stay on the local application document: any other + * target is handed to the system browser instead of being loaded in-app. + */ +export type NavigationPolicy = { + /** Dev server document URL, when the app runs against the Vite dev server. */ + devUrl?: string + /** Absolute path of the packaged renderer entry document. */ + appFilePath?: string +} + +function normalizeFileUrl(value: string) { + return decodeURIComponent(value).replace(/\\/gu, '/') +} + +/** True when a navigation target is the application document itself. */ +export function isInternalNavigationUrl(target: string, policy: NavigationPolicy) { + let url: URL + try { + url = new URL(target) + } catch { + return false + } + + if (url.protocol === 'file:') { + if (!policy.appFilePath) { + return false + } + return normalizeFileUrl(url.pathname) === normalizeFileUrl(`/${policy.appFilePath.replace(/^\/+/u, '')}`) + } + + if (!policy.devUrl) { + return false + } + + try { + return url.origin === new URL(policy.devUrl).origin + } catch { + return false + } +} + +/** True when a navigation target may be handed to the system browser. */ +export function isExternalBrowserUrl(target: string) { + try { + const { protocol } = new URL(target) + return protocol === 'http:' || protocol === 'https:' + } catch { + return false + } +} diff --git a/apps/desktop/src-tauri/src/dev_asset_bridge.rs b/apps/desktop/src-tauri/src/dev_asset_bridge.rs index f3c51ab4..f5c3bbb4 100644 --- a/apps/desktop/src-tauri/src/dev_asset_bridge.rs +++ b/apps/desktop/src-tauri/src/dev_asset_bridge.rs @@ -1,4 +1,4 @@ -use crate::domain::{assets, resource_registry}; +use crate::domain::{assets, resource_registry}; use crate::support::logging::{LogEvent, targets, write_dev_asset_bridge_log}; use std::collections::HashMap; use std::io::{Read, Write}; @@ -10,6 +10,7 @@ const DEFAULT_BIND_ADDR: &str = "127.0.0.1:5187"; pub fn run_from_env() -> Result<(), String> { let bind_addr = std::env::var("MODFORGE_EVENT_ASSET_BRIDGE_ADDR") .unwrap_or_else(|_| DEFAULT_BIND_ADDR.to_string()); + let bind_addr = resolve_bind_addr(&bind_addr)?; let listener = TcpListener::bind(&bind_addr) .map_err(|error| format!("Failed to bind dev asset bridge at {bind_addr}: {error}"))?; write_dev_asset_bridge_log( @@ -58,6 +59,30 @@ fn handle_connection(mut stream: TcpStream) -> Result<(), String> { let mut request_parts = request_line.split_whitespace(); let method = request_parts.next().unwrap_or_default(); let target = request_parts.next().unwrap_or_default(); + let headers = parse_headers(&request); + let origin = headers.get("origin").map(String::as_str); + + // The bridge exposes local game assets without authentication, so it must only + // answer loopback callers: a remote page (or a rebound DNS name pointing at + // 127.0.0.1) would otherwise read arbitrary files through it. + if !is_loopback_host(headers.get("host").map(String::as_str)) || !is_allowed_origin(origin) { + write_dev_asset_bridge_log( + log::Level::Warn, + targets::DEV_ASSET_BRIDGE, + LogEvent::new("devAssetBridge.requestRejected") + .optional("host", headers.get("host").map(String::as_str)) + .optional("origin", origin) + .render(), + ); + return write_response( + &mut stream, + 403, + "Forbidden", + "text/plain; charset=utf-8", + None, + "The dev asset bridge only serves loopback callers.", + ); + } if method == "OPTIONS" { return write_response( @@ -65,6 +90,7 @@ fn handle_connection(mut stream: TcpStream) -> Result<(), String> { 204, "No Content", "text/plain; charset=utf-8", + origin, "", ); } @@ -75,6 +101,7 @@ fn handle_connection(mut stream: TcpStream) -> Result<(), String> { 405, "Method Not Allowed", "text/plain; charset=utf-8", + origin, "Only GET is supported.", ); } @@ -88,15 +115,17 @@ fn handle_connection(mut stream: TcpStream) -> Result<(), String> { 200, "OK", "application/json; charset=utf-8", + origin, "{\"ok\":true}", ), "/detect-default-game-directory" => { - write_json_value(&mut stream, assets::detect_default_game_directory()) + write_json_value(&mut stream, origin, assets::detect_default_game_directory()) } "/validate-game-directory" => { let root_path = required_param(¶ms, "path")?; write_json_result( &mut stream, + origin, assets::validate_game_directory(root_path.to_string()), ) } @@ -109,6 +138,7 @@ fn handle_connection(mut stream: TcpStream) -> Result<(), String> { .cloned(); write_json_result( &mut stream, + origin, assets::load_map_asset(root_path.to_string(), map_path.to_string(), locale), ) } @@ -121,6 +151,7 @@ fn handle_connection(mut stream: TcpStream) -> Result<(), String> { .cloned(); write_json_result( &mut stream, + origin, assets::load_text_asset(root_path.to_string(), asset_path.to_string(), locale), ) } @@ -133,6 +164,7 @@ fn handle_connection(mut stream: TcpStream) -> Result<(), String> { .cloned(); write_json_result( &mut stream, + origin, assets::load_event_asset(root_path.to_string(), asset_path.to_string(), locale), ) } @@ -148,12 +180,13 @@ fn handle_connection(mut stream: TcpStream) -> Result<(), String> { .unwrap_or(false); if is_optional { match assets::load_image_data_url(path.to_string(), locale) { - Ok(value) => write_json_value(&mut stream, Some(value)), - Err(_) => write_json_value::>(&mut stream, None), + Ok(value) => write_json_value(&mut stream, origin, Some(value)), + Err(_) => write_json_value::>(&mut stream, origin, None), } } else { write_json_result( &mut stream, + origin, assets::load_image_data_url(path.to_string(), locale), ) } @@ -166,6 +199,7 @@ fn handle_connection(mut stream: TcpStream) -> Result<(), String> { .cloned(); write_json_result( &mut stream, + origin, resource_registry::load_resource_registry(root_path.to_string(), locale), ) } @@ -174,11 +208,71 @@ fn handle_connection(mut stream: TcpStream) -> Result<(), String> { 404, "Not Found", "text/plain; charset=utf-8", + origin, "Unknown bridge endpoint.", ), } } +/// Validates a configured bind address, refusing anything the network can reach. +pub(crate) fn resolve_bind_addr(bind_addr: &str) -> Result { + let host = bind_addr + .rsplit_once(':') + .map_or(bind_addr, |(host, _)| host); + if !is_loopback_host(Some(host)) { + return Err(format!( + "The dev asset bridge only binds loopback addresses, but {bind_addr} was requested." + )); + } + + Ok(bind_addr.to_string()) +} + +/// Lowercased header names mapped to their trimmed values from a raw request head. +pub(crate) fn parse_headers(request: &str) -> HashMap { + request + .lines() + .skip(1) + .take_while(|line| !line.trim().is_empty()) + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_string())) + .collect() +} + +/// True when the `Host` header targets a loopback address, blocking DNS rebinding. +pub(crate) fn is_loopback_host(host: Option<&str>) -> bool { + let Some(host) = host.map(str::trim).filter(|value| !value.is_empty()) else { + return false; + }; + let hostname = match host.rsplit_once(':') { + Some((hostname, port)) if port.chars().all(|value| value.is_ascii_digit()) => hostname, + _ => host, + }; + let hostname = hostname.trim_start_matches('[').trim_end_matches(']'); + hostname.eq_ignore_ascii_case("localhost") + || hostname == "::1" + || hostname + .parse::() + .is_ok_and(|address| address.is_loopback()) +} + +/// True when a request carries no browser origin or a loopback development origin. +pub(crate) fn is_allowed_origin(origin: Option<&str>) -> bool { + let Some(origin) = origin.map(str::trim).filter(|value| !value.is_empty()) else { + return true; + }; + let Some(rest) = origin + .strip_prefix("http://") + .or_else(|| origin.strip_prefix("https://")) + else { + return false; + }; + if rest.contains('/') { + return false; + } + is_loopback_host(Some(rest)) +} + fn split_target(target: &str) -> (&str, &str) { match target.split_once('?') { Some((path, query)) => (path, query), @@ -232,13 +326,21 @@ fn required_param<'a>(params: &'a HashMap, key: &str) -> Result< fn write_json_result( stream: &mut TcpStream, + origin: Option<&str>, result: anyhow::Result, ) -> Result<(), String> { match result { Ok(value) => { let body = serde_json::to_string(&value) .map_err(|error| format!("Failed to serialize bridge response: {error}"))?; - write_response(stream, 200, "OK", "application/json; charset=utf-8", &body) + write_response( + stream, + 200, + "OK", + "application/json; charset=utf-8", + origin, + &body, + ) } Err(error) => { let error = error.to_string(); @@ -249,16 +351,28 @@ fn write_json_result( 500, "Internal Server Error", "application/json; charset=utf-8", + origin, &body, ) } } } -fn write_json_value(stream: &mut TcpStream, value: T) -> Result<(), String> { +fn write_json_value( + stream: &mut TcpStream, + origin: Option<&str>, + value: T, +) -> Result<(), String> { let body = serde_json::to_string(&value) .map_err(|error| format!("Failed to serialize bridge response: {error}"))?; - write_response(stream, 200, "OK", "application/json; charset=utf-8", &body) + write_response( + stream, + 200, + "OK", + "application/json; charset=utf-8", + origin, + &body, + ) } fn write_response( @@ -266,13 +380,29 @@ fn write_response( status_code: u16, status_text: &str, content_type: &str, + origin: Option<&str>, body: &str, ) -> Result<(), String> { let response = format!( - "HTTP/1.1 {status_code} {status_text}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\nConnection: close\r\n\r\n{body}", + "HTTP/1.1 {status_code} {status_text}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n{}Vary: Origin\r\nConnection: close\r\n\r\n{body}", body.as_bytes().len(), + cors_headers(origin), ); stream .write_all(response.as_bytes()) .map_err(|error| format!("Failed to write response: {error}")) } + +/// CORS headers echoing an already validated loopback origin, empty for same-origin callers. +pub(crate) fn cors_headers(origin: Option<&str>) -> String { + match origin.filter(|origin| is_allowed_origin(Some(origin))) { + Some(origin) => format!( + "Access-Control-Allow-Origin: {origin}\r\nAccess-Control-Allow-Methods: GET, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type\r\n" + ), + None => String::new(), + } +} + +#[cfg(test)] +#[path = "tests/unit/dev_asset_bridge_tests.rs"] +mod dev_asset_bridge_tests; diff --git a/apps/desktop/src-tauri/src/domain/launcher/settings.rs b/apps/desktop/src-tauri/src/domain/launcher/settings.rs index 969f1ed5..533e3101 100644 --- a/apps/desktop/src-tauri/src/domain/launcher/settings.rs +++ b/apps/desktop/src-tauri/src/domain/launcher/settings.rs @@ -2,6 +2,7 @@ use super::paths::launcher_settings_path; use super::types::{LauncherSettings, NullablePatch, SaveLauncherSettingsRequest}; use crate::AppHandle; use crate::infrastructure::fs::pathing::{clean_input_path, normalize_path}; +use crate::infrastructure::fs::secret_file::write_secret_file; use crate::infrastructure::text_encoding::read_text_file; use crate::support::logging::{LogEvent, targets}; use anyhow::Context; @@ -136,7 +137,7 @@ fn save_settings_at_path_unlocked( let normalized = normalize_settings(settings.clone()); let json = serde_json::to_string_pretty(&normalized) .with_context(|| format!("Failed to serialize launcher settings JSON"))?; - fs::write(settings_path, format!("{json}\n")).with_context(|| { + write_secret_file(settings_path, &format!("{json}\n")).with_context(|| { format!( "Failed to write launcher settings {}", normalize_path(settings_path) diff --git a/apps/desktop/src-tauri/src/infrastructure/fs.rs b/apps/desktop/src-tauri/src/infrastructure/fs.rs index ef6461d6..b56a94dc 100644 --- a/apps/desktop/src-tauri/src/infrastructure/fs.rs +++ b/apps/desktop/src-tauri/src/infrastructure/fs.rs @@ -1 +1,2 @@ pub mod pathing; +pub mod secret_file; diff --git a/apps/desktop/src-tauri/src/infrastructure/fs/secret_file.rs b/apps/desktop/src-tauri/src/infrastructure/fs/secret_file.rs new file mode 100644 index 00000000..1ca77738 --- /dev/null +++ b/apps/desktop/src-tauri/src/infrastructure/fs/secret_file.rs @@ -0,0 +1,28 @@ +use std::fs; +use std::io; +use std::path::Path; + +/// Writes credential-bearing content and restricts the file to the current user +/// on Unix, so settings that hold API keys are not left world-readable. +pub fn write_secret_file(path: &Path, contents: &str) -> io::Result<()> { + fs::write(path, contents)?; + restrict_secret_file(path) +} + +/// Restricts an existing credential-bearing file to owner-only access on Unix. +pub fn restrict_secret_file(path: &Path) -> io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600)) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(()) + } +} + +#[cfg(test)] +#[path = "../../tests/unit/infrastructure/secret_file_tests.rs"] +mod secret_file_tests; diff --git a/apps/desktop/src-tauri/src/tests/unit/dev_asset_bridge_tests.rs b/apps/desktop/src-tauri/src/tests/unit/dev_asset_bridge_tests.rs new file mode 100644 index 00000000..9ddb9e54 --- /dev/null +++ b/apps/desktop/src-tauri/src/tests/unit/dev_asset_bridge_tests.rs @@ -0,0 +1,70 @@ +use super::{cors_headers, is_allowed_origin, is_loopback_host, parse_headers, resolve_bind_addr}; + +#[test] +fn resolve_bind_addr_rejects_network_reachable_addresses() { + assert_eq!( + resolve_bind_addr("127.0.0.1:5187").expect("loopback address"), + "127.0.0.1:5187" + ); + assert_eq!( + resolve_bind_addr("localhost:5187").expect("loopback host"), + "localhost:5187" + ); + + assert!(resolve_bind_addr("0.0.0.0:5187").is_err()); + assert!(resolve_bind_addr("192.168.1.10:5187").is_err()); + assert!(resolve_bind_addr("[::]:5187").is_err()); +} + +#[test] +fn parse_headers_lowercases_names_and_stops_at_the_body() { + let request = "GET /health HTTP/1.1\r\nHost: 127.0.0.1:5187\r\nOrigin: http://localhost:5173\r\n\r\nbody: ignored\r\n"; + + let headers = parse_headers(request); + + assert_eq!( + headers.get("host").map(String::as_str), + Some("127.0.0.1:5187") + ); + assert_eq!( + headers.get("origin").map(String::as_str), + Some("http://localhost:5173") + ); + assert!(!headers.contains_key("body")); +} + +#[test] +fn is_loopback_host_accepts_loopback_targets_and_rejects_rebound_names() { + assert!(is_loopback_host(Some("127.0.0.1:5187"))); + assert!(is_loopback_host(Some("127.1.2.3"))); + assert!(is_loopback_host(Some("localhost:5187"))); + assert!(is_loopback_host(Some("[::1]:5187"))); + + assert!(!is_loopback_host(None)); + assert!(!is_loopback_host(Some(""))); + assert!(!is_loopback_host(Some("attacker.example:5187"))); + assert!(!is_loopback_host(Some("192.168.1.10:5187"))); +} + +#[test] +fn is_allowed_origin_only_accepts_missing_or_loopback_origins() { + assert!(is_allowed_origin(None)); + assert!(is_allowed_origin(Some("http://127.0.0.1:5173"))); + assert!(is_allowed_origin(Some("http://localhost:5173"))); + + assert!(!is_allowed_origin(Some("https://evil.example"))); + assert!(!is_allowed_origin(Some("http://localhost.evil.example"))); + assert!(!is_allowed_origin(Some("http://evil.example/localhost"))); + assert!(!is_allowed_origin(Some("null"))); + assert!(!is_allowed_origin(Some("file://"))); +} + +#[test] +fn cors_headers_echo_loopback_origins_and_never_wildcard() { + let headers = cors_headers(Some("http://127.0.0.1:5173")); + + assert!(headers.contains("Access-Control-Allow-Origin: http://127.0.0.1:5173")); + assert!(!headers.contains('*')); + assert_eq!(cors_headers(None), ""); + assert_eq!(cors_headers(Some("https://evil.example")), ""); +} diff --git a/apps/desktop/src-tauri/src/tests/unit/infrastructure/secret_file_tests.rs b/apps/desktop/src-tauri/src/tests/unit/infrastructure/secret_file_tests.rs new file mode 100644 index 00000000..ab9be8eb --- /dev/null +++ b/apps/desktop/src-tauri/src/tests/unit/infrastructure/secret_file_tests.rs @@ -0,0 +1,51 @@ +use super::{restrict_secret_file, write_secret_file}; +use crate::test_support::create_temp_dir; +use std::fs; + +#[test] +fn write_secret_file_writes_contents() { + let dir = create_temp_dir("secret-file-write"); + let path = dir.join("settings.json"); + + write_secret_file(&path, "{\"nexusApiKey\":\"secret\"}\n").expect("write secret file"); + + assert_eq!( + fs::read_to_string(&path).expect("read secret file"), + "{\"nexusApiKey\":\"secret\"}\n" + ); + fs::remove_dir_all(&dir).ok(); +} + +#[cfg(unix)] +#[test] +fn write_secret_file_restricts_access_to_the_owner() { + use std::os::unix::fs::PermissionsExt; + + let dir = create_temp_dir("secret-file-mode"); + let path = dir.join("settings.json"); + fs::write(&path, "world readable").expect("seed secret file"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).expect("seed permissions"); + + write_secret_file(&path, "secret").expect("write secret file"); + + let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + fs::remove_dir_all(&dir).ok(); +} + +#[cfg(unix)] +#[test] +fn restrict_secret_file_tightens_existing_files() { + use std::os::unix::fs::PermissionsExt; + + let dir = create_temp_dir("secret-file-restrict"); + let path = dir.join("ai-settings.json"); + fs::write(&path, "secret").expect("seed secret file"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o666)).expect("seed permissions"); + + restrict_secret_file(&path).expect("restrict secret file"); + + let mode = fs::metadata(&path).expect("metadata").permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + fs::remove_dir_all(&dir).ok(); +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index bc703eb3..66b006f1 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -31,7 +31,7 @@ "requireLiteralLeadingDot": false } }, - "csp": null + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' asset: http://asset.localhost https://asset.localhost data: blob: https:; media-src 'self' asset: http://asset.localhost https://asset.localhost data: blob:; connect-src 'self' ipc: http://ipc.localhost asset: http://asset.localhost https://asset.localhost; worker-src 'self' blob:; object-src 'none'; frame-src 'none'; base-uri 'self'; form-action 'none'" } }, "bundle": { diff --git a/apps/desktop/src/tests/architecture/electronNavigationPolicy.test.ts b/apps/desktop/src/tests/architecture/electronNavigationPolicy.test.ts new file mode 100644 index 00000000..e8435a1a --- /dev/null +++ b/apps/desktop/src/tests/architecture/electronNavigationPolicy.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vite-plus/test' + +describe('electron navigation policy', () => { + it('keeps the renderer on the dev server document only', async () => { + const { isInternalNavigationUrl } = await import('../../../electron/navigation-policy') + const policy = { devUrl: 'http://127.0.0.1:5173', appFilePath: '/opt/app/resources/dist/index.html' } + + expect(isInternalNavigationUrl('http://127.0.0.1:5173/', policy)).toBe(true) + expect(isInternalNavigationUrl('http://127.0.0.1:5173/workbench?view=map', policy)).toBe(true) + + expect(isInternalNavigationUrl('http://127.0.0.1:5174/', policy)).toBe(false) + expect(isInternalNavigationUrl('https://www.nexusmods.com/stardewvalley/mods/1', policy)).toBe(false) + expect(isInternalNavigationUrl('http://localhost:5173/', policy)).toBe(false) + expect(isInternalNavigationUrl('not a url', policy)).toBe(false) + }) + + it('keeps the packaged renderer on its own entry document', async () => { + const { isInternalNavigationUrl } = await import('../../../electron/navigation-policy') + const policy = { appFilePath: '/opt/app/resources/dist/index.html' } + + expect(isInternalNavigationUrl('file:///opt/app/resources/dist/index.html', policy)).toBe(true) + expect(isInternalNavigationUrl('file:///opt/app/resources/dist/index.html?view=map', policy)).toBe(true) + + expect(isInternalNavigationUrl('file:///etc/passwd', policy)).toBe(false) + expect(isInternalNavigationUrl('file:///opt/app/resources/dist/../../secrets.html', policy)).toBe(false) + expect(isInternalNavigationUrl('http://127.0.0.1:5173/', policy)).toBe(false) + }) + + it('only hands http and https targets to the system browser', async () => { + const { isExternalBrowserUrl } = await import('../../../electron/navigation-policy') + + expect(isExternalBrowserUrl('https://www.nexusmods.com/')).toBe(true) + expect(isExternalBrowserUrl('http://smapi.io/')).toBe(true) + + expect(isExternalBrowserUrl('file:///etc/passwd')).toBe(false) + expect(isExternalBrowserUrl('javascript:alert(1)')).toBe(false) + expect(isExternalBrowserUrl('modforge-asset://local/etc/passwd')).toBe(false) + expect(isExternalBrowserUrl('')).toBe(false) + }) +})