Skip to content

fix: do not leak username to untrusted modules #736

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Apr 23, 2025
43 changes: 32 additions & 11 deletions runtime/module_loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ impl ModuleLoader for ZinniaModuleLoader {
ModuleLoaderError::from(JsErrorBox::generic(msg))
})?;

let sandboxed_path: PathBuf;

// Check that the module path is inside the module root directory
if let Some(canonical_root) = &module_root {
// Resolve any symlinks inside the path to prevent modules from escaping our sandbox
Expand All @@ -120,19 +122,28 @@ impl ModuleLoader for ZinniaModuleLoader {
ModuleLoaderError::from(JsErrorBox::generic(msg))
})?;

if !canonical_module.starts_with(canonical_root) {
let msg = format!(
"Cannot import files outside of the module root directory.\n\
let relative_path =
canonical_module.strip_prefix(canonical_root).map_err(|_| {
let msg = format!(
"Cannot import files outside of the module root directory.\n\
Root directory (canonical): {}\n\
Module file path (canonical): {}\
{}",
canonical_root.display(),
canonical_module.display(),
details()
);

return Err(ModuleLoaderError::from(JsErrorBox::generic(msg)));
}
canonical_root.display(),
canonical_module.display(),
details()
);
ModuleLoaderError::from(JsErrorBox::generic(msg))
})?;

let virtual_root = if cfg!(target_os = "windows") {
r"C:\ZINNIA"
} else {
r"/ZINNIA"
};
sandboxed_path = Path::new(virtual_root).join(relative_path).to_owned();
} else {
sandboxed_path = module_path.to_owned();
};

// Based on https://github.com/denoland/roll-your-own-javascript-runtime
Expand Down Expand Up @@ -211,10 +222,20 @@ impl ModuleLoader for ZinniaModuleLoader {
})?
.insert(spec_str.to_string(), code.clone());

let sandboxed_module_specifier = ModuleSpecifier::from_file_path(&sandboxed_path)
.map_err(|_| {
let msg = format!(
"Internal error: cannot convert relative path to module specifier: {}\n{}",
sandboxed_path.display(),
details()
);
ModuleLoaderError::from(JsErrorBox::generic(msg))
})?;

let module = ModuleSource::new(
module_type,
ModuleSourceCode::String(code.into()),
&module_specifier,
&sandboxed_module_specifier,
None,
);

Expand Down
3 changes: 3 additions & 0 deletions runtime/tests/js/print_source_code_paths.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
console.log("import.meta.filename:", import.meta.filename);
console.log("import.meta.dirname:", import.meta.dirname);
console.log("error stack:", new Error().stack.split("\n")[1].trim());
67 changes: 65 additions & 2 deletions runtime/tests/runtime_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// ./target/debug/zinnia run runtime/tests/js/timers_tests.js
// Most of the tests should pass on Deno too!
// deno run runtime/tests/js/timers_tests.js
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::rc::Rc;

use anyhow::{anyhow, Context};
Expand Down Expand Up @@ -94,8 +94,71 @@ js_tests!(ipfs_retrieval_tests);
test_runner_tests!(passing_tests);
test_runner_tests!(failing_tests expect_failure);

#[tokio::test]
async fn source_code_paths_when_no_module_root() -> Result<(), AnyError> {
let (activities, run_error) =
run_js_test_file_with_module_root("print_source_code_paths.js", None).await?;
if let Some(err) = run_error {
return Err(err);
}

let base_dir = get_base_dir();
let dirname = base_dir.to_str().unwrap().to_string();
let filename = Path::join(&base_dir, "print_source_code_paths.js").to_owned();
let filename = filename.to_str().unwrap().to_string();
let module_url = ModuleSpecifier::from_file_path(&filename).unwrap();

assert_eq!(
[
format!("import.meta.filename: {filename}"),
format!("import.meta.dirname: {dirname}"),
format!("error stack: at {module_url}:3:29"),
]
.map(|msg| { format!("console.info: {msg}\n") }),
activities.as_slice(),
);
Ok(())
}

#[tokio::test]
async fn source_code_paths_when_inside_module_root() -> Result<(), AnyError> {
let module_root = Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")));
let (activities, run_error) =
run_js_test_file_with_module_root("print_source_code_paths.js", module_root).await?;
if let Some(err) = run_error {
return Err(err);
}

let expected = if cfg!(target_os = "windows") {
[
r"import.meta.filename: C:\ZINNIA\tests\js\print_source_code_paths.js",
r"import.meta.dirname: C:\ZINNIA\tests\js",
"error stack: at file:///C:/ZINNIA/tests/js/print_source_code_paths.js:3:29",
]
} else {
[
"import.meta.filename: /ZINNIA/tests/js/print_source_code_paths.js",
"import.meta.dirname: /ZINNIA/tests/js",
"error stack: at file:///ZINNIA/tests/js/print_source_code_paths.js:3:29",
]
};

assert_eq!(
expected.map(|msg| { format!("console.info: {msg}\n") }),
activities.as_slice(),
);
Ok(())
}

// Run all tests in a single JS file
async fn run_js_test_file(name: &str) -> Result<(Vec<String>, Option<AnyError>), AnyError> {
run_js_test_file_with_module_root(name, None).await
}

async fn run_js_test_file_with_module_root(
name: &str,
module_root: Option<PathBuf>,
) -> Result<(Vec<String>, Option<AnyError>), AnyError> {
let _ = env_logger::builder().is_test(true).try_init();

let mut full_path = get_base_dir();
Expand All @@ -110,7 +173,7 @@ async fn run_js_test_file(name: &str) -> Result<(Vec<String>, Option<AnyError>),
format!("zinnia_runtime_tests/{}", env!("CARGO_PKG_VERSION")),
reporter.clone(),
helpers::lassie_daemon(),
None,
module_root,
);
let run_result = run_js_module(&main_module, &config).await;
let events = reporter.events.take();
Expand Down