//! `chj-module:///` (Windows: `http://chj-module.localhost//`): //! serves only files of the module that owns the requesting window, plus the shared //! window-chrome stylesheet. Port of `PluginRuntime.handleRequest` with stricter checks //! (canonical path containment, requesting window must belong to the module). use crate::core::app::CoreSlot; use crate::core::modules::service::MODULE_CSP; use std::path::{Component, Path, PathBuf}; use tauri::http::{Request, Response, StatusCode}; use tauri::{Manager, Runtime, UriSchemeContext}; const MAX_RESOURCE: u64 = 10 * 1024 * 1024; fn mime(path: &Path) -> &'static str { match path.extension().map(|e| e.to_string_lossy().to_lowercase()).as_deref() { Some("css") => "text/css; charset=utf-8", Some("gif") => "image/gif", Some("html") => "text/html; charset=utf-8", Some("jpeg") | Some("jpg") => "image/jpeg", Some("js") => "text/javascript; charset=utf-8", Some("json") => "application/json; charset=utf-8", Some("png") => "image/png", Some("svg") => "image/svg+xml", Some("webp") => "image/webp", Some("woff") => "font/woff", Some("woff2") => "font/woff2", Some("ttf") => "font/ttf", _ => "application/octet-stream", } } fn not_found() -> Response> { Response::builder().status(StatusCode::NOT_FOUND).header("Content-Type", "text/plain").body(b"Not found".to_vec()).unwrap() } /// Splits the request into (module id, relative path) for both URL forms. pub fn split_request(uri: &str) -> Option<(String, String)> { let url = url::Url::parse(uri).ok()?; let host = url.host_str()?.to_string(); let mut segments: Vec = url.path_segments()?.map(|s| percent_decode(s)).collect::>>()?; let id = if host == "chj-module.localhost" || host == "localhost" { if segments.is_empty() { return None; } segments.remove(0) } else { host }; Some((id, segments.join("/"))) } fn percent_decode(segment: &str) -> Option { let bytes = segment.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'%' { let hex = std::str::from_utf8(bytes.get(i + 1..i + 3)?).ok()?; out.push(u8::from_str_radix(hex, 16).ok()?); i += 3; } else { out.push(bytes[i]); i += 1; } } String::from_utf8(out).ok() } fn safe_relative(requested: &str) -> Option { if requested.is_empty() || requested.starts_with('.') || requested.contains('\\') { return None; } let path = PathBuf::from(requested); for component in path.components() { match component { Component::Normal(part) if !part.to_string_lossy().starts_with('.') => {} _ => return None, } } Some(path) } pub fn handle(context: UriSchemeContext<'_, R>, request: Request>) -> Response> { let app = context.app_handle(); let Some(core) = app.state::().try_get() else { return not_found() }; let Some((module_id, requested)) = split_request(&request.uri().to_string()) else { return not_found() }; // The requesting window must be the module's own window. match core.windows.module_by_label(context.webview_label()) { Some(owner) if owner.id == module_id => {} _ => { core.logger.warn("Plugin resource request rejected.", serde_json::json!({ "url": request.uri().to_string(), "message": "foreign window" })); return not_found(); } } let (root, relative) = if requested == "__chj_core__/window-chrome.css" { (core.paths.bundled_modules(), PathBuf::from("window-chrome.css")) } else { let Ok(resolved) = core.services.modules.registry.resolve(&module_id) else { return not_found() }; let entry = resolved.manifest["entry"].as_str().unwrap_or("").to_string(); let Some(relative) = safe_relative(if requested.is_empty() { &entry } else { &requested }) else { return not_found() }; (resolved.root, relative) }; let file = root.join(&relative); let (Ok(real_root), Ok(real_file)) = (std::fs::canonicalize(&root), std::fs::canonicalize(&file)) else { return not_found() }; if !real_file.starts_with(&real_root) { return not_found(); } match std::fs::metadata(&real_file) { Ok(meta) if meta.is_file() && meta.len() <= MAX_RESOURCE => {} _ => return not_found(), } let Ok(data) = std::fs::read(&real_file) else { return not_found() }; Response::builder() .status(StatusCode::OK) .header("Content-Type", mime(&real_file)) .header("Content-Security-Policy", MODULE_CSP) .header("Cache-Control", "no-store") .header("X-Content-Type-Options", "nosniff") .header("Referrer-Policy", "no-referrer") .body(data) .unwrap_or_else(|_| not_found()) } #[cfg(test)] mod tests { use super::*; #[test] fn request_splitting() { assert_eq!(split_request("chj-module://chj.users/ui/index.html"), Some(("chj.users".into(), "ui/index.html".into()))); assert_eq!(split_request("http://chj-module.localhost/chj.users/ui/a%20b.js"), Some(("chj.users".into(), "ui/a b.js".into()))); assert!(safe_relative("../x").is_none()); assert!(safe_relative("ui/.hidden").is_none()); assert!(safe_relative("ui/index.html").is_some()); } }