Pass Ruby values into JS with VM#define_const / #define_let / #define_var - #67
Pass Ruby values into JS with VM#define_const / #define_let / #define_var#67hmsk wants to merge 8 commits into
Conversation
666af05 to
e83128e
Compare
f651bd6 to
89e5804
Compare
Pass Ruby values into JS without interpolating them into source. The caller picks the binding form, so JS's own rules apply unchanged: a `const` is read-only and a colliding declaration in user code is a loud SyntaxError, and `var` is the form that lands on `globalThis`. Each declaration runs as its own eval so the caller's source is never rewritten and backtrace line numbers keep pointing at their code. Values are serialized to JS source here rather than through the C converter, which falls back to `#inspect` for unrecognized objects and would silently turn a Time into a string. Names are validated as JS identifiers, without which a name would splice arbitrary JS into the source we evaluate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Re-reading this against a main that has moved: #66 and #73 landed after this branch was written, and injecting configuration and then importing a module that reads it is the obvious pairing of the two. That a declaration reaches module scope isn't obvious from either feature alone, since a module has its own scope and only `var` lands on `globalThis`, so it is worth pinning down. Same for a `Runnable` run on the VM. The README said the name "must be a valid JavaScript identifier", which overstates what is accepted: `値` is a valid JS identifier and this rejects it. The restriction is deliberate, since the name is concatenated into evaluated source, so the docs now give the pattern and the reason instead of implying JS's own rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
var is the only form that lands on globalThis, which is what makes it the
right choice for a bundle expecting a global and also the only one an
existing property can intercept. Two shapes, both of which reported
success:
An accessor takes the value in its setter and hands JS back whatever its
getter likes:
vm.eval_code("Object.defineProperty(globalThis, 'APP_CONFIG', {
configurable: true,
set(v) { globalThis.stolen = JSON.stringify(v) },
get() { return { evil: true } } });")
vm.define_var(:APP_CONFIG, { apiKey: 's3cret' }) #=> :APP_CONFIG
vm.eval_code('globalThis.stolen') #=> "{\"apiKey\":\"s3cret\"}"
vm.eval_code('APP_CONFIG.evil') #=> true
A non-writable property discards the assignment silently, because a
declaration eval is sloppy mode.
Both now raise ArgumentError before the value is written, so the accessor
never sees it. const and let are untouched: neither goes near globalThis,
and an accessor sitting on the name cannot observe them or change what the
name resolves to.
The check reads the VM through JS and the README says plainly that it is
not a defence. Code that has run in a VM owns that environment and can
replace Object.getOwnPropertyDescriptor as easily as it can install the
accessor. What it buys is that an unusable global fails loudly instead of
silently, and the README now says to define before running code you do not
control.
Found by review of #67.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Recording what the cold review turned up beyond the Nothing here blocks the merge as far as I can tell. Taking them in the PR or filing them once it lands both work; the split below is how I would divide them. Worth deciding before this landsShared substructures expand rather than share.
Doubling per level. Reachable from a small Ruby structure with aliases, which YAML or A Documentation, where the code and the README disagreeAll verified on this branch:
Robustness
What the review could not breakNo escape from a literal into executable JS, through string values, symbols, hash keys, floats, integers, or the variable name. |
A Ruby structure that reaches the same object twice is written out twice, so a graph whose branches repeat expands as it nests: each level that references the level below twice doubles the output. Twenty-five of those is a third of a gigabyte from a handful of objects, thirty is ten. YAML aliases and Marshal round-trips produce that shape without anyone meaning to, and nothing stopped it. The bound is the VM's own memory_limit rather than a number invented for the occasion. A source larger than the whole JS heap budget cannot be evaluated by that VM under any circumstances, so this refuses work that provably cannot succeed, and the message names the option to change. It is a ceiling and not a prediction, which the README now says: a 256KB source already exhausts a 4MB VM once parsed, because object-heavy literals cost many times their source size. Sources between those two points remain the caller's problem. Checked per container rather than once at the end, since the expansion is bottom-up and an inner container crosses the line long before the outer one exists. Peak Ruby memory measured at three to four times the limit across limits from 4MB to 128MB, proportional to the limit rather than to the structure. Found by review of #67. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`{"__proto__": v}` in an object literal is the prototype-setting form, and
quoting the key does not opt out of it. A Hash key of that name therefore
vanished: the entry became the object's prototype, `Object.keys` never
listed it, and the value came back from JS as if it had never been sent.
vm.define_const(:o, { '__proto__' => { 'p' => 1 } })
vm.eval_code('Object.getOwnPropertyNames(o).length') # was 0, now 1
vm.eval_code('JSON.stringify(o)') # was {}, now the object
The round trip is the clearest statement of it. Before:
sent {"name" => "bob", "__proto__" => {"isAdmin" => true}}
received {"name" => "bob"}
Data going missing is half of it. The other half is that Ruby and JS then
disagree about what the value contains, which is the shape a host-side
check gets walked past: a request body with a nested "__proto__" reads in
Ruby as an ordinary key with a Hash under it, `body['isAdmin']` is nil,
and it arrives in JS with `req.isAdmin` true and `Object.keys` showing
nothing. Ruby's own JSON.parse produces that key without comment, so a
parsed body reaches this directly.
A computed key is not the special form. Only this one name needs it, since
nothing else in an object literal means anything but itself.
This is the generated source only. A guest writing `{__proto__: x}` still
sets a prototype, which is what the language says it should do, and JS to
Ruby was already correct: the walk reads own properties, so a `__proto__`
property arrives as that key and a prototype is not collected.
The same key through the C converter is unchanged and stays open as #108.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The review of this pull request found several places where the documentation described something other than the behaviour. Each claim below was run against the branch rather than reasoned about. The scope claim was too broad. A colliding declaration is a loud error at the top level only; a function body or a module shadows silently, as JavaScript always has. The paragraph said so without the qualifier, which is the version that matters to someone importing a module. "Converted the same way as elsewhere in the gem" was not true either. This path takes a narrower set than the converter used for define_function return values, which also handles File and Exception. That is deliberate, since defining is an input path, so the text now says which way it is narrow and why rather than claiming they match. Four edges are named that were not documented at all: an ASCII-8BIT string whose bytes are valid UTF-8 is reinterpreted as those characters; integers past JavaScript's range become Infinity rather than losing precision; Hash keys collide after to_s and the last one wins; and the reserved-word check does not stop you replacing a built-in, which `define_var(:eval, 1)` demonstrates. The exception classes are documented as they are rather than smoothed over. JSON::GeneratorError for bytes that are not valid UTF-8 and Encoding::CompatibilityError for a name that cannot be compared against ASCII both leak the layer that noticed. Reporting them as TypeError would read better and is worth doing on its own, not as part of a documentation pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Closing out the list above, since most of it has been dealt with since and the comment reads as if none of it had. Fixed here
The three code fixes are separate commits and each reverts on its own. The Left deliberately, and the one thing still worth doing The exception classes. Two unsupported inputs report the layer that noticed them rather than the contract the API advertises: vm.define_const(:x, "\xff\xfe".b) #=> JSON::GeneratorError
vm.define_const("abc".encode("UTF-16LE"), 1) #=> Encoding::CompatibilityErrorBoth are unsupported values, and the README says unsupported values raise Kept out of the documentation pass on purpose: changing what a method raises is a behaviour change, and burying one in a commit that claims to be correcting prose is how a behaviour change gets shipped without being reviewed as one. It also wants a decision rather than a patch, since Worth an issue once this lands, for the same reason the |
A second review of this branch found one escape and four edges. All of
them were reproduced before being fixed.
**A String subclass could write the source.** The value path called
`value.to_json`, which is dispatched on the caller's object:
class Evil < String
def to_json(*) = %q{1; globalThis.PWNED = 'yes'; var zz = 1}
end
vm.define_let(:v, Evil.new('hi'))
vm.eval_code('globalThis.PWNED') #=> "yes"
The key path was already written as to_s.to_json and was never reachable
this way. The value path now matches it. Host-side rather than guest-side
under the threat model here, and a parsed request body has no String
subclasses in it, but the comment above the name check claims escaping is
what stops arbitrary JS reaching the source, and that should hold for
values without depending on what a caller's object does.
**The budget bounded the result, not the work.** _within_budget ran after
map and join had materialised the whole container, so width was unbounded:
three hundred references to one twenty-thousand-element array reached
103MB of Ruby against a 4MB limit that never fired in time. Counting as
each element is produced stops that value at 6MB. The doubling shape drops
from 42MB to 30MB at a 4MB limit and from 866MB to 548MB at the default.
That makes the comment I put on this in b624590 wrong twice over, and it
now says what was measured instead: a multiple of the limit rather than
the limit, four to eight times for nesting and about one and a half for
width, and no longer proportional to the structure.
**Depth is capped at 1000.** Deeper values raised SystemStackError, which
is not a StandardError, so a caller's `rescue => e` did not catch it and
the thread went down.
**A memory_limit at or above 2**63** reads back negative through
malloc_limit, which is an int64_t, and every define on an otherwise
healthy VM compared against it and refused.
**The define_var guard could be taken away by its own caller.** It reads
globalThis and Object through JS, so define_var(:globalThis, 1) made every
later probe answer for a Number and report a clean global, and
define_var(:Object, 1) made it throw with a message naming nothing. Both
now refuse rather than reporting a success that did not happen.
Also documented, not changed: a name defined here shadows a
define_function of the same name silently, in either order. And the
reserved-word comment claimed QuickJS refuses those words anyway, which is
true of await and false of the strict-mode-only ones, since a declaration
eval is sloppy mode.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pass Ruby values into JS without interpolating them into source.
Why three methods
"Globals" is ambiguous in this VM:
let/constdeclarations persist acrosseval_codecalls but never appear onglobalThis. Picking one destination silently diverges from user code:So the caller picks the binding form instead, and JS's own rules apply unchanged. Nothing new to learn, and nothing invented.
globalThisdefine_constQuickjs::TypeErrorQuickjs::SyntaxErrordefine_letQuickjs::SyntaxErrordefine_vardefine_varis the one for JS you don't control, a bundle readingglobalThis.APP_CONFIG. Because these are real declarations rather than property assignments, a colliding declaration in user code is a loud error instead of a silent shadow.Notes
#inspectfor unrecognized objects and would silently turn aTimeinto a string. Unsupported values raiseTypeError. Circular structures raiseArgumentError./\A[A-Za-z_$][A-Za-z0-9_$]*\z/, which is narrower than JS (it rejects Unicode identifiers like値). Without validation, a name splices arbitrary JS into the source we evaluate.let/varassigns to it.constand binding-form switches raiseArgumentError, tracked by a per-VMname -> kindregistry that is only written after the eval succeeds.define_functionremains the way to have JS read the current Ruby value on every access.Open questions
define_varon a name user code already declared asvarsilently overwrites, sincevarredeclaration is legal JS. Onlyconst/letcollisions are loud, sodefine_varis the least protected of the three.Number(str)path, but silent.Quickjs.eval_codeis not covered, since there is no VM handle to define on.Re-review against current main
Rebased and re-read after #66, #72, #73, #74 and #75 landed. No defects found; two gaps closed.
Module interaction is now covered. Injecting configuration and then importing a module that reads it is the obvious pairing with
compile_module, and it is not obvious from either feature alone that a declaration reaches module scope, since a module has its own scope and onlyvarlands onglobalThis. Verified and pinned down with tests: aconstis readable from an imported module, avarthroughglobalThis, aconstdefined after the import is readable at call time (the binding resolves when the function runs, not when the module body evaluates), and aRunnablerun on the VM sees it too.The identifier docs overstated what is accepted. The README said the name "must be a valid JavaScript identifier", but
値is a valid JS identifier and this rejects it. The restriction is deliberate, so the docs now give the pattern and the reason.Also checked, no action needed:
const/letpersist across evals even thougheval_codedefaults toasync: true. Load-bearing and non-obvious, already covered by the first test.2**70yields1.1805916207174113e+21through both paths, so this path is not worse than the rest of the gem.define_var(:NaN, 1)silently no-ops, sinceNaNis a non-writable global and this is sloppy mode. That is JS behaviour, unchanged by us.Validation
bundle exec rake— 597 runs, 946 assertions, 0 failures, 1 pre-existing skip. 36 new tests.