Repository navigation
fix(browser): sanitize script results like @enclave-vm/core - #94
frontegg-david merged 2 commits into
Conversation
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (9)
Included review availability: This review used your included allowance. 0 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 3 reviews per hour. 📝 WalkthroughWalkthroughBrowser script results now use configurable sanitization limits and conversions before delivery. Core sanitization recognizes supported built-in values across realms. ChangesScript result sanitization
Priority: ➖ Normal Estimated code review effort: 3 (Moderate) | ~20 minutes Change: Bug fix · Severity of issue fixed: Medium Sequence Diagram(s)sequenceDiagram
participant BrowserEnclave
participant InnerIframe
participant EnclaveCaller
BrowserEnclave->>InnerIframe: Pass sanitization limits in generated config
InnerIframe->>InnerIframe: Sanitize script result
InnerIframe->>EnclaveCaller: Return sanitized result or execution error
Merge Risk: ⚪ Minimal · up to The result-sanitization changes appear mergeable after normal checks; no concrete remaining risk was identified. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 4 functions across 7 files. (2 skipped: 2 unsupported.)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
07635d9 to
d59ec9a
Compare
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The same-realm browser sanitizer can invoke script-mutated intrinsics, and RegExp conversion can execute untrusted accessors.
Review effort: Balanced
Findings: 2
Open (2)
What changed in this PR
Aligns browser result sanitization with core while adding cross-realm built-in recognition.
Changes:
- Adds browser-side result sanitization and configurable limits.
- Recognizes cross-realm Date, Map, Set, and RegExp values in core.
- Adds regression tests and documentation.
| File | Description |
|---|---|
libs/core/src/value-sanitizer.ts |
Adds cross-realm built-in detection. |
libs/core/src/__tests__/enclave.result-sanitization.spec.ts |
Tests cross-realm sanitization. |
libs/browser/src/types.ts |
Exposes sanitization options. |
libs/browser/src/browser-enclave.ts |
Applies option overrides. |
libs/browser/src/adapters/inner-iframe-bootstrap.ts |
Sanitizes iframe results. |
libs/browser/e2e/results.spec.ts |
Tests browser result behavior. |
libs/browser/e2e/helpers.ts |
Supports new test options. |
docs/enclave/core-libraries/enclave-browser/overview.mdx |
Documents result conversions. |
docs/enclave/core-libraries/enclave-browser/configuration.mdx |
Documents sanitization limits. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
BrowserEnclave returned script results through
JSON.parse(JSON.stringify(result)) and dropped anything that failed:
a Date became an ISO string, a circular result was a successful
undefined, __proto__ keys survived, and maxSanitizeDepth and
maxSanitizeProperties were read into the config but never used.
The inner iframe now copies the result with a port of core's
sanitizeValue: same depth and property limits, '[Circular]' for an
object seen twice, __proto__ and constructor keys dropped, Date kept,
BigInt to string, Map to object, Set to array, RegExp to string, Error
to { name, message }. A function, a symbol or a limit overrun fails the
run. The value is posted as structured data, so NaN, Infinity and
undefined arrive as in core. New maxSanitizeDepth and
maxSanitizeProperties options override the security level's limits,
as in core.
Core recognized Dates, Maps, Sets and RegExps with instanceof, which
is false for values built in the sandbox realm, so a script's Date came
back from the double VM as {}. It now checks the builtin's internal
slot with the captured builtins (no property of the value is read), so
both packages return a Date. Values whose prototype is null or an
Object.prototype skip those checks (each throws on a miss), which keeps
sanitizing 1,000 plain objects at 0.36 ms instead of 14 ms (0.77 ms
before this change). The single VM still returns {} for a Date:
its membrane hands the sanitizer a proxy, which the check refuses.
Fixes #89
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up. Browser: the inner iframe's result sanitizer called WeakSet has/add and the Map and Set iterators' next() as they were after the script ran, and those prototypes are not frozen, so a script could make its result look circular or hide Map entries. It now captures them at bootstrap and calls them through Reflect.apply, like its other intrinsics. Core: RegExp.prototype.toString is generic and reads source and flags off the value, so a RegExp from the sandbox realm with its own accessors for them ran those accessors on the host. The sanitizer now builds /source/flags from the captured source getter and the per-flag getters, which read the internal slot. The browser does the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d59ec9a to
08a240a
Compare
With the double VM off, a script's `new Date(0)` still came back as {}.
The single VM's membrane wraps every Date a script constructs in a
secure proxy, and a proxy has no [[DateValue]] slot, so the sanitizer's
builtin check (#94) refused it and copied it as a plain object.
The membrane now exports unwrapSecureProxy (from its existing
proxy-to-target map; not part of the package API), and sanitizeValue
takes an unwrap option. The single VM passes it for the script result:
the sanitizer recognizes and reads builtins (Date, Map, Set, RegExp)
from the unwrapped target with the captured host builtins and returns
a new host Date. Everything else is still read through the proxy, and
nothing unwrapped is handed back to the sandbox.
Tool arguments were not affected (Dates already arrive as ISO strings,
or as Dates with the direct bridge).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With the double VM off, a script's `new Date(0)` still came back as {}.
The single VM's membrane wraps every Date a script constructs in a
secure proxy, and a proxy has no [[DateValue]] slot, so the sanitizer's
builtin check (#94) refused it and copied it as a plain object.
The membrane now exports unwrapSecureProxy (from its existing
proxy-to-target map; not part of the package API), and sanitizeValue
takes an unwrap option. The single VM passes it for the script result:
the sanitizer recognizes and reads builtins (Date, Map, Set, RegExp)
from the unwrapped target with the captured host builtins and returns
a new host Date. Everything else is still read through the proxy, and
nothing unwrapped is handed back to the sandbox.
Tool arguments were not affected (Dates already arrive as ISO strings,
or as Dates with the direct bridge).
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Stack 3/10:
main← #92 ← #93 ← this ← #95 ← #96 ← #98 ← #99 ← #100 ← #101 ← #102Fixes #89.
Validation
Reproduced, plus one thing the issue's table hides.
libs/browser/src/adapters/inner-iframe-bootstrap.ts) returnedJSON.parse(JSON.stringify(result)), andcatch { safeResult = undefined }:success: truewithvalue: undefined;Datebecame an ISO string;__proto__keys survived;maxSanitizeDepth/maxSanitizePropertieswere put in the config but never read.{ date: {} }fornew Date(0). That's not intended conversion.sanitizeValue(libs/core/src/value-sanitizer.ts) means to keep Dates (allowDates: true), but it checksvalue instanceof Dateagainst the host'sDate. That's false for a Date built in the sandbox realm, so the value fell through to the plain-object branch. Maps, Sets and RegExps from scripts were affected the same way.Fix
Browser: the inner iframe copies the result with a port of core's
sanitizeValue:'[Circular]'for an object seen twice;__proto__/constructorkeys dropped;Datekept;BigInt→ string,Map→ object,Set→ array,RegExp→ string,Error→{ name, message }.A function, a symbol or a limit overrun fails the run. The copy is posted as structured data, so
NaN,Infinityandundefinedarrive as they do in core. NewmaxSanitizeDepth/maxSanitizePropertiesoptions override the level's limits, as in core.Core:
sanitizeValuerecognizes Dates, Maps, Sets and RegExps by internal slot. It calls the captured builtin (Date.prototype.getTime, thesize/sourcegetters) throughReflect.apply, which works across realms and reads no property of the value. A Proxy has no slot, so it's copied as a plain object without its traps being called (before, it threw). Values whose prototype isnullor anObject.prototypeskip these checks, because each check throws on a miss. With that fast path, sanitizing 1,000 plain objects takes 0.36 ms; without it, 14 ms; onmain, 0.77 ms.Behavior changes
{ date: new Date(0), nan: NaN, inf: Infinity, undef: undefined }{ date: {}, nan: NaN, inf: Infinity, undef: undefined }{ date: Date(0), nan: NaN, inf: Infinity, undef: undefined }{ date: "1970-…Z", nan: null, inf: null }a.self = a{ name: 'a', self: '[Circular]' }success: true,value: undefinedmaxSanitizeProperties/ deeper thanmaxSanitizeDepthScript result exceeds maximum …)Also in core, tool arguments or results holding a sandbox
Date,Map,SetorRegExpnow convert as documented instead of becoming{}.Not changed: core's single VM (
doubleVm: { enabled: false }) still returns{}for a Date. Its membrane hands the sanitizer a proxy, which the slot check refuses. Fixed on top of this stack in #98.Red → green
libs/browser/e2e/results.spec.ts(new, 7 tests)libs/core/src/__tests__/enclave.result-sanitization.spec.ts(new, 4 tests)Review follow-ups (08a240a)
WeakSethas/add and the Map/Set iterators'next()as they were after the script ran (Copilot). They're now captured at bootstrap and called throughReflect.apply. Test: avalidate: falsescript that replaces them before returning gets the right copy (fails before).RegExp.prototype.toStringreadsource/flagsoff the value (Copilot), so a sandbox RegExp's own accessors ran on the host./source/flagsis now built from the capturedsourcegetter and the per-flag getters, which read the internal slots. Test: a foreign RegExp with its ownsource/flagsaccessors, which are not called (fails before). The browser builds the string the same way (there a script can't define accessors anyway:Object.definePropertyis blocked in the iframe).Verification
jest -c libs/core/jest.config.ts --testPathIgnorePatterns /perf/: 63 suites, 2341 passed, 1 skippednx run browser:test-e2e: 79 passed🤖 Generated with Claude Code
Summary by CodeRabbit