mirror of
https://github.com/LCE-Hub/LCE-Emerald-Launcher.git
synced 2026-07-19 08:47:19 +00:00
fix(LCEL-03): allowlist http_proxy_request hosts
This commit is contained in:
parent
8da6a217bc
commit
d5a7c81fee
|
|
@ -40,12 +40,20 @@ pub fn save_file_dialog(title: String, filename: String, filters: Vec<String>) -
|
|||
}
|
||||
}
|
||||
|
||||
// security: write_binary_file / read_binary_file used to accept arbitrary
|
||||
// paths with no validation, no dialog, no scope. combined with the
|
||||
// opener:allow-open-path: ** capability, a compromised webview could
|
||||
// write+launch any file anywhere. (LCEL-01)
|
||||
// TODO: scope these to a launcher-specific subdirectory with canonical
|
||||
// containment checks, or remove from the IPC surface entirely and route
|
||||
// all binary file ops through rfd-gated dialogs.
|
||||
// for now, refuse all paths. callers must use pick_file / save_file_dialog.
|
||||
#[tauri::command]
|
||||
pub fn write_binary_file(path: String, data: Vec<u8>) -> Result<(), String> {
|
||||
fs::write(path, data).map_err(|e| e.to_string())
|
||||
pub fn write_binary_file(_path: String, _data: Vec<u8>) -> Result<(), String> {
|
||||
Err("write_binary_file disabled: use save_file_dialog instead".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_binary_file(path: String) -> Result<Vec<u8>, String> {
|
||||
fs::read(path).map_err(|e| e.to_string())
|
||||
pub fn read_binary_file(_path: String) -> Result<Vec<u8>, String> {
|
||||
Err("read_binary_file disabled: use pick_file instead".into())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,13 @@ pub async fn launch_game(
|
|||
}
|
||||
all_args.extend(args.clone());
|
||||
all_args.push(game_exe.to_string_lossy().to_string());
|
||||
// security: extra_args from the frontend pass through to the
|
||||
// spawned game verbatim. Wine/Proton honor many flags
|
||||
// influencing DLL loading, debug ports, etc. an attacker
|
||||
// controlling the webview (see LCEL-01) could craft args
|
||||
// that influence the spawn. (LCEL-05)
|
||||
// TODO: allowlist extra_args against a known safe set
|
||||
// (e.g. --server, --port). reject unknown flags.
|
||||
all_args.extend(extra_args.clone());
|
||||
let (final_prog, final_args) = apply_launch_prefix(prog, all_args, &config_val);
|
||||
let mut cmd = tokio::process::Command::new(&final_prog);
|
||||
|
|
|
|||
|
|
@ -15,16 +15,13 @@ pub fn get_plugins_dir(app: tauri::AppHandle) -> Result<String, String> {
|
|||
|
||||
#[tauri::command]
|
||||
pub fn list_directory(path: String) -> Result<Vec<DirEntry>, String> {
|
||||
let entries = fs::read_dir(&path).map_err(|e| e.to_string())?;
|
||||
let mut results = Vec::new();
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
results.push(DirEntry {
|
||||
name: entry.file_name().to_string_lossy().to_string(),
|
||||
is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
|
||||
});
|
||||
}
|
||||
Ok(results)
|
||||
// security: list_directory used to accept arbitrary paths with no
|
||||
// validation. combined with write_binary_file + opener:allow-open-path: **
|
||||
// a compromised webview could enumerate startup folders, drop a binary,
|
||||
// and launch it. (LCEL-01)
|
||||
// TODO: scope to the launcher's plugins dir or remove from IPC entirely.
|
||||
// for now, refuse all paths.
|
||||
Err("list_directory disabled: use get_plugins_dir instead".into())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,34 @@
|
|||
use crate::types::HttpResponse;
|
||||
// security: allowlist of hosts http_proxy_request is allowed to talk to.
|
||||
// the command used to accept any URL with no validation - full SSRF to
|
||||
// loopback (127.0.0.1:5582 workshop server), LAN peers' relay ports,
|
||||
// cloud metadata (169.254.169.254), internal admin panels. (LCEL-03)
|
||||
const ALLOWED_HOSTS: &[&str] = &[
|
||||
"auth.mclegacyedition.xyz",
|
||||
"social.mclegacyedition.xyz",
|
||||
"raw.githubusercontent.com",
|
||||
"api.github.com",
|
||||
];
|
||||
|
||||
fn is_url_allowed(url: &str) -> bool {
|
||||
let parsed = match reqwest::Url::parse(url) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let host = match parsed.host_str() {
|
||||
Some(h) => h.to_lowercase(),
|
||||
None => return false,
|
||||
};
|
||||
// block loopback and link-local by default
|
||||
if host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
|| host.starts_with("127.") || host.starts_with("169.254.")
|
||||
|| host.starts_with("192.168.") || host.starts_with("10.")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
ALLOWED_HOSTS.iter().any(|h| host == *h)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn http_proxy_request(
|
||||
method: String,
|
||||
|
|
@ -6,6 +36,9 @@ pub async fn http_proxy_request(
|
|||
body: Option<String>,
|
||||
headers: std::collections::HashMap<String, String>,
|
||||
) -> Result<HttpResponse, String> {
|
||||
if !is_url_allowed(&url) {
|
||||
return Err(format!("url not allowed: {}", url));
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
let mut req = match method.to_uppercase().as_str() {
|
||||
"GET" => client.get(&url),
|
||||
|
|
|
|||
|
|
@ -35,6 +35,15 @@ pub async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -
|
|||
for (zip_name, placeholder) in &request.zips {
|
||||
let zip_url = format!("{}/{}", raw_base, zip_name);
|
||||
let zip_tmp = tmp_dir.join(zip_name);
|
||||
// security: zip_name is user/registry-controlled. reject any path
|
||||
// separator or .. segment so the downloaded archive can't escape
|
||||
// tmp_dir. (LCEL-02)
|
||||
if zip_name.contains("..") || zip_name.contains('/') || zip_name.contains('\\')
|
||||
|| zip_name.starts_with('/') || zip_name.starts_with('\\')
|
||||
{
|
||||
let _ = fs::remove_dir_all(&tmp_dir);
|
||||
return Err(format!("invalid zip_name: {}", zip_name));
|
||||
}
|
||||
let response = reqwest::get(&zip_url).await.map_err(|e| e.to_string())?;
|
||||
if !response.status().is_success() {
|
||||
let _ = fs::remove_dir_all(&tmp_dir);
|
||||
|
|
@ -45,11 +54,28 @@ pub async fn workshop_install(app: AppHandle, request: WorkshopInstallRequest) -
|
|||
let dest_dir = if placeholder.is_empty() {
|
||||
instance_dir.clone()
|
||||
} else {
|
||||
// security: placeholder is user/registry-controlled. reject any
|
||||
// .. segment or absolute path before joining to instance_dir.
|
||||
// (LCEL-02)
|
||||
if placeholder.contains("..") || placeholder.starts_with('/')
|
||||
|| placeholder.starts_with('\\')
|
||||
{
|
||||
let _ = fs::remove_dir_all(&tmp_dir);
|
||||
return Err(format!("invalid placeholder: {}", placeholder));
|
||||
}
|
||||
let resolved = instance_dir.clone().join(placeholder
|
||||
.replace("{MediaDir}", media_dir.to_str().unwrap_or(""))
|
||||
.replace("{DLCDir}", dlc_dir.to_str().unwrap_or(""))
|
||||
.replace("{GameHDD}", game_hdd.to_str().unwrap_or(""))
|
||||
.replace("{MobDir}", mob_dir.to_str().unwrap_or("")));
|
||||
// security: canonicalize and assert the resolved path stays
|
||||
// inside instance_dir. (LCEL-02)
|
||||
let resolved_canon = resolved.canonicalize().unwrap_or(resolved.clone());
|
||||
let instance_canon = instance_dir.canonicalize().unwrap_or(instance_dir.clone());
|
||||
if !resolved_canon.starts_with(&instance_canon) {
|
||||
let _ = fs::remove_dir_all(&tmp_dir);
|
||||
return Err(format!("placeholder escapes instance dir: {}", placeholder));
|
||||
}
|
||||
PathBuf::from(resolved)
|
||||
};
|
||||
|
||||
|
|
@ -178,6 +204,21 @@ pub async fn workshop_uninstall(app: AppHandle, instance_id: String, package_id:
|
|||
if let Some(pkg) = packages.iter().find(|p| p.id == package_id) {
|
||||
for file in &pkg.dirs {
|
||||
let path = PathBuf::from(file);
|
||||
// security: re-validate that the persisted path stays inside
|
||||
// instance_dir before removing. if an attacker injected paths
|
||||
// into workshop_packages.json (via the install traversals above
|
||||
// or via LCEL-01's write primitive), uninstall used to delete
|
||||
// arbitrary files. (LCEL-02)
|
||||
let path_canon = match path.canonicalize() {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let instance_canon = instance_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| instance_dir.clone());
|
||||
if !path_canon.starts_with(&instance_canon) {
|
||||
continue;
|
||||
}
|
||||
if path.is_file() {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,29 +129,39 @@ pub fn run() {
|
|||
}
|
||||
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
if args.len() > 1 && !args[1].starts_with('-') {
|
||||
let first = &args[1];
|
||||
let is_deep_link = first.starts_with("emerald://")
|
||||
|| first.starts_with("emeraldlauncher://")
|
||||
|| first.starts_with("discord-1482504445152460871://");
|
||||
if !is_deep_link {
|
||||
let instance_id = first.clone();
|
||||
let app_handle_clone = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(window) = app_handle_clone.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
let state = app_handle_clone.state::<GameState>();
|
||||
match game::launch_game(app_handle_clone.clone(), state, instance_id, Vec::new(), vec![]).await {
|
||||
Ok(_) => app_handle_clone.exit(0),
|
||||
Err(e) => {
|
||||
let _ = app_handle_clone.emit("backend-error", format!("Auto-launch: {e}"));
|
||||
eprintln!("Auto-launch error: {}", e);
|
||||
app_handle_clone.exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
// security: require an explicit --launch flag for silent auto-launch.
|
||||
// the old code treated any positional argv[1] (that wasn't a flag
|
||||
// or deep-link) as an instance_id, hid the main window, and spawned
|
||||
// the game. a crafted shortcut, scheduled task, or shell-out from
|
||||
// another program could stealth-launch any installed instance.
|
||||
// (LCEL-06)
|
||||
let mut launch_instance: Option<String> = None;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
let a = &args[i];
|
||||
if a == "--launch" && i + 1 < args.len() {
|
||||
launch_instance = Some(args[i + 1].clone());
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
if let Some(instance_id) = launch_instance {
|
||||
let app_handle_clone = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Some(window) = app_handle_clone.get_webview_window("main") {
|
||||
let _ = window.hide();
|
||||
}
|
||||
let state = app_handle_clone.state::<GameState>();
|
||||
match game::launch_game(app_handle_clone.clone(), state, instance_id, Vec::new(), vec![]).await {
|
||||
Ok(_) => app_handle_clone.exit(0),
|
||||
Err(e) => {
|
||||
let _ = app_handle_clone.emit("backend-error", format!("Auto-launch: {e}"));
|
||||
eprintln!("Auto-launch error: {}", e);
|
||||
app_handle_clone.exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ use tokio::time::sleep;
|
|||
use tokio_util::sync::CancellationToken;
|
||||
use crate::state::ProxyGuard;
|
||||
const PROXY_ADDR: &str = "proxy.mclegacyedition.xyz:2052"; //neo: yeah bro im hardcoding it
|
||||
// security: cap read_line at 8 KiB. relay control lines are tiny (HOST/JOIN
|
||||
// tokens + small id). a malicious peer (relay or LAN) sending an unbounded
|
||||
// line without \n used to OOM the launcher. (LCEL-04)
|
||||
const MAX_RELAY_LINE_BYTES: usize = 8 * 1024;
|
||||
async fn read_line(stream: &mut TcpStream) -> Result<String, String> {
|
||||
let mut buf = Vec::new();
|
||||
let mut byte = [0u8; 1];
|
||||
|
|
@ -13,6 +17,9 @@ async fn read_line(stream: &mut TcpStream) -> Result<String, String> {
|
|||
stream.read_exact(&mut byte).await.map_err(|e| e.to_string())?;
|
||||
if byte[0] == b'\n' { break; }
|
||||
if byte[0] != b'\r' { buf.push(byte[0]); }
|
||||
if buf.len() > MAX_RELAY_LINE_BYTES {
|
||||
return Err(format!("relay line exceeded {} bytes", MAX_RELAY_LINE_BYTES));
|
||||
}
|
||||
}
|
||||
String::from_utf8(buf).map_err(|e| e.to_string())
|
||||
}
|
||||
|
|
@ -111,7 +118,11 @@ async fn run_relay_proxy(
|
|||
.map_err(|e| format!("Proxy connect failed: {}", e))?;
|
||||
|
||||
write_line(&mut stream, &format!("JOIN {} 0 {}", auth_token, target_session)).await?;
|
||||
let listener = TcpListener::bind("0.0.0.0:61000")
|
||||
// security: bind to loopback only. the game process connects on
|
||||
// 127.0.0.1:61000; binding 0.0.0.0 was LAN-reachable so any host on
|
||||
// the user's network could intercept/inject the relayed game traffic.
|
||||
// (LCEL-04)
|
||||
let listener = TcpListener::bind("127.0.0.1:61000")
|
||||
.await
|
||||
.map_err(|e| format!("Bind failed: {}", e))?;
|
||||
let local_port = listener.local_addr().map_err(|e| e.to_string())?.port();
|
||||
|
|
|
|||
|
|
@ -92,7 +92,10 @@ async fn handle(stream: tokio::net::TcpStream) {
|
|||
"application/octet-stream"
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nConnection: close\r\n\r\n",
|
||||
// security: scope CORS to the tauri webview origin. was
|
||||
// "*" which let any website the user visits fetch the
|
||||
// workshop server. (LCEL-07)
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: http://localhost:1420\r\nConnection: close\r\n\r\n",
|
||||
content_type,
|
||||
body.len(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
// security: the access token currently lives in localStorage under
|
||||
// SESSION_KEY, which is readable by any JS in the webview origin
|
||||
// (XSS exposure). the proper fix is to move it to a Rust-side secret
|
||||
// store accessed via a narrow IPC that returns only scoped capability,
|
||||
// or to use an httpOnly cookie + same-site strict backend auth flow.
|
||||
// (LCEL-08)
|
||||
const SESSION_KEY = "lceonline_session";
|
||||
const SOCIAL_BASE_URL = "https://social.mclegacyedition.xyz";
|
||||
const AUTH_BASE_URL = "https://auth.mclegacyedition.xyz"; //neo: yeah bro im hardcoding all three
|
||||
|
|
|
|||
Loading…
Reference in a new issue