From 823a7e2d24cbdffd7c0e2779bf7b7d05ab419aff Mon Sep 17 00:00:00 2001 From: ldm0 Date: Fri, 6 Nov 2026 14:15:15 +0800 Subject: [PATCH] fix(v8): partition generated-code caches by referrer metadata Pinned V8 15.2 can reuse Function and indirect-eval compilation entries from a script with a different nonce or base URL. This rejects valid dynamic imports once module CSP admission checks the inherited metadata. Append a precomputed referrer-specific trailing comment after the existing CSP and Trusted Types checks. Keep untracked cache keys separate, preserve UTF-16 and Function source text, and retain creator metadata when generated functions outlive their compiling script. Cover dynamic Function variants, direct and indirect eval, cross-realm reuse, retained functions, lexical scope and source preservation. --- .../src/script_vm/security_policy.rs | 22 ++ moli-renderer-v8/src/util.rs | 38 +++- moli-renderer-v8/src/util_codegen_tests.rs | 198 ++++++++++++++++++ 3 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 moli-renderer-v8/src/util_codegen_tests.rs diff --git a/moli-renderer-v8/src/script_vm/security_policy.rs b/moli-renderer-v8/src/script_vm/security_policy.rs index 2b272bd58c..54e293f19e 100644 --- a/moli-renderer-v8/src/script_vm/security_policy.rs +++ b/moli-renderer-v8/src/script_vm/security_policy.rs @@ -206,6 +206,28 @@ pub(crate) fn string_code_generation_check_callback<'s>( scope: &mut v8::PinScope<'s, '_>, source: v8::Local<'s, v8::Value>, is_code_like: bool, +) -> v8::ModifyCodeGenerationFromStringsResult<'s> { + let result = check_string_code_generation(scope, source, is_code_like); + if !result.codegen_allowed { + return result; + } + let Some(source) = result + .modified_source + .or_else(|| v8::Local::::try_from(source).ok()) + else { + return result; + }; + let Some(partition) = crate::util::script_eval_cache_partition(scope) else { + return code_generation_result(false, None); + }; + let modified_source = v8::String::concat(scope, source, partition); + code_generation_result(modified_source.is_some(), modified_source) +} + +fn check_string_code_generation<'s>( + scope: &mut v8::PinScope<'s, '_>, + source: v8::Local<'s, v8::Value>, + is_code_like: bool, ) -> v8::ModifyCodeGenerationFromStringsResult<'s> { let worker_trusted_types_requirements = crate::worker::worker_trusted_types_for_script_requirements(scope); diff --git a/moli-renderer-v8/src/util.rs b/moli-renderer-v8/src/util.rs index 2f94d49d46..c7b4b19379 100644 --- a/moli-renderer-v8/src/util.rs +++ b/moli-renderer-v8/src/util.rs @@ -508,6 +508,26 @@ pub(crate) fn script_parser_inserted_from_host_defined_options( } } +pub(crate) fn script_eval_cache_partition<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Option> { + let partition = (|| { + let options = scope.get_current_host_defined_options()?; + let options = script_host_defined_options_as_fixed_array(options)?; + if options.length() < 5 { + return None; + } + let marker = v8::Local::::try_from(options.get(scope, 0)?).ok()?; + if marker.to_rust_string_lossy(scope) != SCRIPT_BASE_URL_HOST_DEFINED_OPTIONS_MARKER { + return None; + } + v8::Local::::try_from(options.get(scope, 4)?).ok() + })(); + // Untracked callers need a distinct suffix too: otherwise author-provided + // source could match a tracked caller's already-partitioned cache key. + partition.or_else(|| v8_string(scope, "\n// moli-referrer:untracked")) +} + fn script_host_defined_options_as_fixed_array<'s>( host_defined_options: v8::Local<'s, v8::Data>, ) -> Option> { @@ -549,6 +569,16 @@ pub(crate) fn script_host_defined_options_with_fetch_metadata<'s>( nonce: Option<&str>, parser_inserted: bool, ) -> Option> { + // V8 15.2 looks up Function/indirect-eval cache entries before resolving + // the caller script. Partition caller metadata without disabling caching. + // The trailing comment stays outside generated functions' source ranges. + let partition = format!( + "\n// moli-referrer:{}", + moli_crypto::sha256_hex(format!( + "{base_url}\0{}\0{parser_inserted}", + nonce.unwrap_or_default() + )) + ); let marker = v8_string(scope, SCRIPT_BASE_URL_HOST_DEFINED_OPTIONS_MARKER)?; let value = v8_string(scope, base_url.as_str())?; let nonce = v8_string(scope, nonce.unwrap_or_default())?; @@ -560,11 +590,13 @@ pub(crate) fn script_host_defined_options_with_fetch_metadata<'s>( "not-parser-inserted" }, )?; - let options = v8::PrimitiveArray::new(scope, 4); + let partition = v8_string(scope, &partition)?; + let options = v8::PrimitiveArray::new(scope, 5); options.set(scope, 0, marker.into()); options.set(scope, 1, value.into()); options.set(scope, 2, nonce.into()); options.set(scope, 3, parser_metadata.into()); + options.set(scope, 4, partition.into()); Some(options.into()) } @@ -1041,6 +1073,10 @@ pub(crate) fn utf16_units_contain_unpaired_surrogate(units: &[u16]) -> bool { false } +#[cfg(test)] +#[path = "util_codegen_tests.rs"] +mod generated_code_tests; + #[cfg(test)] mod tests { use std::pin::pin; diff --git a/moli-renderer-v8/src/util_codegen_tests.rs b/moli-renderer-v8/src/util_codegen_tests.rs new file mode 100644 index 0000000000..fff711c6df --- /dev/null +++ b/moli-renderer-v8/src/util_codegen_tests.rs @@ -0,0 +1,198 @@ +use std::{cell::RefCell, pin::pin}; + +use super::*; + +type ImportedNonces = RefCell>>; + +fn capture_import_nonce<'s>( + scope: &mut v8::PinScope<'s, '_>, + options: v8::Local<'s, v8::Data>, + _resource: v8::Local<'s, v8::Value>, + _specifier: v8::Local<'s, v8::String>, + _attributes: v8::Local<'s, v8::FixedArray>, +) -> Option> { + let nonce = script_nonce_from_host_defined_options(scope, options); + scope + .get_slot::() + .unwrap() + .borrow_mut() + .push(nonce); + let resolver = v8::PromiseResolver::new(scope)?; + let promise = resolver.get_promise(scope); + let undefined = v8::undefined(scope).into(); + resolver.resolve(scope, undefined); + Some(promise) +} + +fn isolate() -> v8::OwnedIsolate { + crate::ensure_v8_for_test(); + let mut isolate = v8::Isolate::new(v8::CreateParams::default()); + isolate.set_slot(ImportedNonces::default()); + isolate.set_modify_code_generation_from_strings_callback( + crate::script_vm::string_code_generation_check_callback, + ); + isolate.set_host_import_module_dynamically_callback(capture_import_nonce); + isolate +} + +fn run(scope: &mut v8::PinScope<'_, '_>, source: &str, nonce: Option<&str>) -> String { + let url = Url::parse("https://example.test/shared-script.js").unwrap(); + let origin = + create_script_origin_with_base_url_and_nonce(scope, url.as_str(), 0, Some(&url), nonce); + let source = v8_string(scope, source).unwrap(); + let script = v8::Script::compile(scope, source, Some(&origin)).unwrap(); + let result = crate::script_execution::execute_compiled_script(scope, script) + .expect("probe script should execute"); + let result = result.to_rust_string_lossy(scope); + scope.perform_microtask_checkpoint(); + result +} + +#[test] +fn dynamic_function_cache_preserves_nonce_across_realms() { + let mut isolate = isolate(); + for nonce in [Some("abc"), None, Some("def"), Some("ghi")] { + let handle = pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut handle.init(); + let context = v8::Context::new(scope, Default::default()); + context.set_allow_generation_from_strings(false); + let scope = &mut v8::ContextScope::new(scope, context); + run( + scope, + r#"(() => { + const constructors = [Function, + Object.getPrototypeOf(async function() {}).constructor, + Object.getPrototypeOf(function*() {}).constructor, + Object.getPrototypeOf(async function*() {}).constructor]; + for (const Constructor of constructors) { + const fn = new Constructor('x', 'return import(x)'); + const result = fn('./dependency.js'); + if (result.next) result.next(); + } + return 'done'; + })()"#, + nonce, + ); + } + let nonces = isolate.get_slot::().unwrap().borrow(); + let expected: Vec<_> = [Some("abc"), None, Some("def"), Some("ghi")] + .into_iter() + .flat_map(|nonce| std::iter::repeat_n(nonce.map(str::to_owned), 4)) + .collect(); + assert_eq!(*nonces, expected); +} + +#[test] +fn dynamic_function_cache_keeps_creator_nonce_after_later_compilations() { + let mut isolate = isolate(); + let handle = pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut handle.init(); + let context = v8::Context::new(scope, Default::default()); + context.set_allow_generation_from_strings(false); + let scope = &mut v8::ContextScope::new(scope, context); + for nonce in ["abc", "def", "ghi"] { + run( + scope, + r#" + globalThis.savedFunctions ||= []; + savedFunctions.push(new Function('x', 'return import(x)')); + "#, + Some(nonce), + ); + } + run( + scope, + r#" + for (const fn of savedFunctions) fn('./dependency.js'); + "#, + Some("ghi"), + ); + assert_eq!( + *scope.get_slot::().unwrap().borrow(), + ["abc", "def", "ghi"].map(|nonce| Some(nonce.to_owned())) + ); +} + +#[test] +fn eval_cache_preserves_nonce_for_direct_and_indirect_eval() { + let mut isolate = isolate(); + for nonce in [Some("abc"), None, Some("def"), Some("ghi")] { + let handle = pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut handle.init(); + let context = v8::Context::new(scope, Default::default()); + context.set_allow_generation_from_strings(false); + let scope = &mut v8::ContextScope::new(scope, context); + run( + scope, + r#"(() => { + eval("import('./dependency.js')"); + (0, eval)("import('./dependency.js')"); + return 'done'; + })()"#, + nonce, + ); + } + let expected: Vec<_> = [Some("abc"), None, Some("def"), Some("ghi")] + .into_iter() + .flat_map(|nonce| std::iter::repeat_n(nonce.map(str::to_owned), 2)) + .collect(); + assert_eq!( + *isolate.get_slot::().unwrap().borrow(), + expected + ); +} + +#[test] +fn nonced_codegen_preserves_function_source_eval_and_utf16() { + let mut isolate = isolate(); + let source = r#"(() => { + const constructors = [Function, + Object.getPrototypeOf(async function() {}).constructor, + Object.getPrototypeOf(function*() {}).constructor, + Object.getPrototypeOf(async function*() {}).constructor]; + const sources = constructors.map(Constructor => + new Constructor('value', 'return value').toString()); + let invalidEval = false, invalidFunction = false; + try { eval('/*'); } catch (error) { invalidEval = error instanceof SyntaxError; } + try { new Function('/*'); } catch (error) { invalidFunction = error instanceof SyntaxError; } + const value = {}; + return JSON.stringify({sources, invalidEval, invalidFunction, + completion: eval('17 // trailing comment'), + lexicalScope: (() => { const lexical = 23; return eval('lexical'); })(), + indirectCompletion: (0, eval)('29 // trailing comment'), + strict: eval("'use strict'; (function() {return this})()") === undefined, + nonString: eval(value) === value, + globalThisValue: new Function('return this')() === globalThis, + surrogate: new Function('return "' + String.fromCharCode(0xd800) + '"')().charCodeAt(0), + sourceUrl: eval("new Error().stack\n//# sourceURL=custom-eval.js").includes('custom-eval.js')}); + })()"#; + let mut expected = None; + for nonce in [None, Some("abc"), Some("def")] { + let handle = pin!(v8::HandleScope::new(&mut isolate)); + let scope = &mut handle.init(); + let context = v8::Context::new(scope, Default::default()); + // Compare modified code against V8's unmodified compilation path. + context.set_allow_generation_from_strings(nonce.is_none()); + let scope = &mut v8::ContextScope::new(scope, context); + let result: serde_json::Value = serde_json::from_str(&run(scope, source, nonce)).unwrap(); + assert_eq!(result["surrogate"], 0xd800); + assert_eq!(result["completion"], 17); + assert_eq!(result["lexicalScope"], 23); + assert_eq!(result["indirectCompletion"], 29); + for key in [ + "invalidEval", + "invalidFunction", + "strict", + "nonString", + "globalThisValue", + "sourceUrl", + ] { + assert_eq!(result[key], true, "{key}: {result}"); + } + if let Some(expected) = &expected { + assert_eq!(&result, expected); + } else { + expected = Some(result); + } + } +}