From 728da7965a1ec25077a1ea987d9f3b923ea8c11e Mon Sep 17 00:00:00 2001 From: Ben Noordhuis Date: Sun, 19 Jan 2025 22:59:10 +0100 Subject: [PATCH] Add property filter fallback mode to serializer (#336) * Add property filter fallback mode to serializer For a JS value that cannot be serialized as-is by V8's serializer, clone and filter its properties, then try again. With this change, serialization should be possible for practically all JS values. * fixup! truffleruby --- ext/mini_racer_extension/mini_racer_v8.cc | 97 ++++++++++++++++++++++- test/mini_racer_test.rb | 27 +++++-- 2 files changed, 116 insertions(+), 8 deletions(-) diff --git a/ext/mini_racer_extension/mini_racer_v8.cc b/ext/mini_racer_extension/mini_racer_v8.cc index 5565eaf..32cc154 100644 --- a/ext/mini_racer_extension/mini_racer_v8.cc +++ b/ext/mini_racer_extension/mini_racer_v8.cc @@ -11,6 +11,56 @@ #include #include +// note: the filter function gets called inside the safe context, +// i.e., the context that has not been tampered with by user JS +// convention: $-prefixed identifiers signify objects from the +// user JS context and should be handled with special care +static const char safe_context_script_source[] = R"js( +;(function($globalThis) { + const {Map: $Map, Set: $Set} = $globalThis + const sentinel = {} + return function filter(v) { + if (typeof v === "function") + return sentinel + if (typeof v !== "object" || v === null) + return v + if (v instanceof $Map) { + const m = new Map() + for (let [k, t] of Map.prototype.entries.call(v)) { + t = filter(t) + if (t !== sentinel) + m.set(k, t) + } + return m + } else if (v instanceof $Set) { + const s = new Set() + for (let t of Set.prototype.values.call(v)) { + t = filter(t) + if (t !== sentinel) + s.add(t) + } + return s + } else { + const o = Array.isArray(v) ? [] : {} + const pds = Object.getOwnPropertyDescriptors(v) + for (const [k, d] of Object.entries(pds)) { + if (!d.enumerable) + continue + let t = d.value + if (d.get) { + // *not* d.get.call(...), may have been tampered with + t = Function.prototype.call.call(d.get, v, k) + } + t = filter(t) + if (t !== sentinel) + Object.defineProperty(o, k, {value: t, enumerable: true}) + } + return o + } + } +}) +)js"; + struct Callback { struct State *st; @@ -32,8 +82,10 @@ struct State // extra context for when we need access to built-ins like Array // and want to be sure they haven't been tampered with by JS code v8::Local safe_context; - v8::Persistent persistent_context; // single-thread mode only - v8::Persistent persistent_safe_context; // single-thread mode only + v8::Local safe_context_function; + v8::Persistent persistent_context; // single-thread mode only + v8::Persistent persistent_safe_context; // single-thread mode only + v8::Persistent persistent_safe_context_function; // single-thread mode only Context *ruby_context; int64_t max_memory; int err_reason; @@ -73,6 +125,23 @@ struct Serialized // throws JS exception on serialization error bool reply(State& st, v8::Local v) { + v8::TryCatch try_catch(st.isolate); + { + Serialized serialized(st, v); + if (serialized.data) { + v8_reply(st.ruby_context, serialized.data, serialized.size); + return true; + } + } + if (!try_catch.CanContinue()) { + try_catch.ReThrow(); + return false; + } + auto recv = v8::Undefined(st.isolate); + if (!st.safe_context_function->Call(st.safe_context, recv, 1, &v).ToLocal(&v)) { + try_catch.ReThrow(); + return false; + } Serialized serialized(st, v); if (serialized.data) v8_reply(st.ruby_context, serialized.data, serialized.size); @@ -240,7 +309,29 @@ extern "C" State *v8_thread_init(Context *c, const uint8_t *snapshot_buf, st.safe_context = v8::Context::New(st.isolate); st.context = v8::Context::New(st.isolate); v8::Context::Scope context_scope(st.context); + { + v8::Context::Scope context_scope(st.safe_context); + auto source = v8::String::NewFromUtf8Literal(st.isolate, safe_context_script_source); + auto filename = v8::String::NewFromUtf8Literal(st.isolate, "safe_context_script.js"); + v8::ScriptOrigin origin(filename); + auto script = + v8::Script::Compile(st.safe_context, source, &origin) + .ToLocalChecked(); + auto function_v = script->Run(st.safe_context).ToLocalChecked(); + auto function = v8::Function::Cast(*function_v); + auto recv = v8::Undefined(st.isolate); + v8::Local arg = st.context->Global(); + // grant the safe context access to the user context's globalThis + st.safe_context->SetSecurityToken(st.context->GetSecurityToken()); + function_v = + function->Call(st.safe_context, recv, 1, &arg) + .ToLocalChecked(); + // revoke access again now that the script did its one-time setup + st.safe_context->UseDefaultSecurityToken(); + st.safe_context_function = v8::Local::Cast(function_v); + } if (single_threaded) { + st.persistent_safe_context_function.Reset(st.isolate, st.safe_context_function); st.persistent_safe_context.Reset(st.isolate, st.safe_context); st.persistent_context.Reset(st.isolate, st.context); return pst; // intentionally returning early and keeping alive @@ -792,12 +883,14 @@ extern "C" void v8_single_threaded_enter(State *pst, Context *c, void (*f)(Conte v8::Isolate::Scope isolate_scope(st.isolate); v8::HandleScope handle_scope(st.isolate); { + st.safe_context_function = v8::Local::New(st.isolate, st.persistent_safe_context_function); st.safe_context = v8::Local::New(st.isolate, st.persistent_safe_context); st.context = v8::Local::New(st.isolate, st.persistent_context); v8::Context::Scope context_scope(st.context); f(c); st.context = v8::Local(); st.safe_context = v8::Local(); + st.safe_context_function = v8::Local(); } } diff --git a/test/mini_racer_test.rb b/test/mini_racer_test.rb index 9314f43..5e2d8b9 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -899,7 +899,7 @@ def test_wasm_ref skip "TruffleRuby does not support WebAssembly" end context = MiniRacer::Context.new - expected = {"error" => "Error: [object Object] could not be cloned."} + expected = {} actual = context.eval(" var b = [0,97,115,109,1,0,0,0,1,26,5,80,0,95,0,80,0,95,1,127,0,96,0,1,110,96,1,100,2,1,111,96,0,1,100,3,3,4,3,3,2,4,7,26,2,12,99,114,101,97,116,101,83,116,114,117,99,116,0,1,7,114,101,102,70,117,110,99,0,2,9,5,1,3,0,1,0,10,23,3,8,0,32,0,20,2,251,27,11,7,0,65,12,251,0,1,11,4,0,210,0,11,0,44,4,110,97,109,101,1,37,3,0,11,101,120,112,111,114,116,101,100,65,110,121,1,12,99,114,101,97,116,101,83,116,114,117,99,116,2,7,114,101,102,70,117,110,99] var o = new WebAssembly.Instance(new WebAssembly.Module(new Uint8Array(b))).exports @@ -1083,19 +1083,34 @@ def test_regexp_string_iterator # TODO(bnoordhuis) maybe detect the iterator object and serialize # it as a string or array of strings; problem is there is no V8 API # to detect regexp string iterator objects - expected = {"error" => "Error: [object RegExp String Iterator] could not be cloned."} + expected = {} assert_equal expected, context.eval("'abc'.matchAll(/./g)") end def test_function_property context = MiniRacer::Context.new if RUBY_ENGINE == "truffleruby" - expected = {"x" => 42} + expected = { + "m" => {1 => 2, 3 => 4}, + "s" => {}, + "x" => 42, + } else - # regrettably loses the non-function properties - expected = {"error" => "Error: f() {} could not be cloned."} + expected = { + "m" => {"1" => 2, "3" => 4}, # TODO(bnoordhuis) retain numeric keys + "s" => [5, 7, 11, 13], + "x" => 42, + } end - assert_equal expected, context.eval("({ x: 42, f() {} })") + script = <<~JS + ({ + f: () => {}, + m: new Map([[1,2],[3,4]]), + s: new Set([5,7,11,13]), + x: 42, + }) + JS + assert_equal expected, context.eval(script) end def test_string_encoding