From f0e7582c5d64114ba46fdc7a3e9b895f7a5a9087 Mon Sep 17 00:00:00 2001
From: Arbor <2653783589@qq.com>
Date: Tue, 28 Jul 2026 16:16:36 +0000
Subject: [PATCH] fix(security): restrict dev asset bridge, electron
navigation, and secret file access
Reject non-loopback callers on the dev asset bridge, keep the Electron renderer on its own document, add a Tauri CSP, and store launcher API keys owner-only.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
apps/desktop/electron/main.ts | 40 ++++-
apps/desktop/electron/navigation-policy.ts | 52 +++++++
.../desktop/src-tauri/src/dev_asset_bridge.rs | 146 +++++++++++++++++-
.../src-tauri/src/domain/launcher/settings.rs | 3 +-
.../src-tauri/src/infrastructure/fs.rs | 1 +
.../src/infrastructure/fs/secret_file.rs | 28 ++++
.../src/tests/unit/dev_asset_bridge_tests.rs | 70 +++++++++
.../unit/infrastructure/secret_file_tests.rs | 51 ++++++
apps/desktop/src-tauri/tauri.conf.json | 2 +-
.../electronNavigationPolicy.test.ts | 40 +++++
10 files changed, 421 insertions(+), 12 deletions(-)
create mode 100644 apps/desktop/electron/navigation-policy.ts
create mode 100644 apps/desktop/src-tauri/src/infrastructure/fs/secret_file.rs
create mode 100644 apps/desktop/src-tauri/src/tests/unit/dev_asset_bridge_tests.rs
create mode 100644 apps/desktop/src-tauri/src/tests/unit/infrastructure/secret_file_tests.rs
create mode 100644 apps/desktop/src/tests/architecture/electronNavigationPolicy.test.ts
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::