mirror of
https://github.com/lexmount/moli.git
synced 2026-11-13 16:10:01 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user