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.
This commit is contained in:
ldm0
2026-11-06 21:13:35 +08:00
committed by Donough Liu
parent eda3762c1b
commit 823a7e2d24
3 changed files with 257 additions and 1 deletions
@@ -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::<v8::String>::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);
+37 -1
View File
@@ -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<v8::Local<'s, v8::String>> {
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::<v8::String>::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::<v8::String>::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<v8::Local<'s, v8::FixedArray>> {
@@ -549,6 +569,16 @@ pub(crate) fn script_host_defined_options_with_fetch_metadata<'s>(
nonce: Option<&str>,
parser_inserted: bool,
) -> Option<v8::Local<'s, v8::Data>> {
// 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;
+198
View File
@@ -0,0 +1,198 @@
use std::{cell::RefCell, pin::pin};
use super::*;
type ImportedNonces = RefCell<Vec<Option<String>>>;
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<v8::Local<'s, v8::Promise>> {
let nonce = script_nonce_from_host_defined_options(scope, options);
scope
.get_slot::<ImportedNonces>()
.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::<ImportedNonces>().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::<ImportedNonces>().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::<ImportedNonces>().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);
}
}
}