Skip to content

Commit

Permalink
Add property filter fallback mode to serializer
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
bnoordhuis committed Jan 18, 2025
1 parent 9bfacdd commit c5880f7
Show file tree
Hide file tree
Showing 2 changed files with 116 additions and 8 deletions.
97 changes: 95 additions & 2 deletions ext/mini_racer_extension/mini_racer_v8.cc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,56 @@
#include <cstring>
#include <vector>

// 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;
Expand All @@ -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<v8::Context> safe_context;
v8::Persistent<v8::Context> persistent_context; // single-thread mode only
v8::Persistent<v8::Context> persistent_safe_context; // single-thread mode only
v8::Local<v8::Function> safe_context_function;
v8::Persistent<v8::Context> persistent_context; // single-thread mode only
v8::Persistent<v8::Context> persistent_safe_context; // single-thread mode only
v8::Persistent<v8::Function> persistent_safe_context_function; // single-thread mode only
Context *ruby_context;
int64_t max_memory;
int err_reason;
Expand Down Expand Up @@ -73,6 +125,23 @@ struct Serialized
// throws JS exception on serialization error
bool reply(State& st, v8::Local<v8::Value> 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);
Expand Down Expand Up @@ -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<v8::Value> 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<v8::Function>::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
Expand Down Expand Up @@ -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<v8::Function>::New(st.isolate, st.persistent_safe_context_function);
st.safe_context = v8::Local<v8::Context>::New(st.isolate, st.persistent_safe_context);
st.context = v8::Local<v8::Context>::New(st.isolate, st.persistent_context);
v8::Context::Scope context_scope(st.context);
f(c);
st.context = v8::Local<v8::Context>();
st.safe_context = v8::Local<v8::Context>();
st.safe_context_function = v8::Local<v8::Function>();
}
}

Expand Down
27 changes: 21 additions & 6 deletions test/mini_racer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down

0 comments on commit c5880f7

Please sign in to comment.