Skip to content

Commit 8e53750

Browse files
committed
fix(sandbox): provide btoa and atob to sandbox tasks
docx 9.15 encodes base64 through the global btoa/atob, so in the task isolate Packer.toBase64String and patchDocument's base64 output (which worked on 9.7.1) and password protection threw ReferenceError. The worker now bridges btoa/atob to Node's native WHATWG implementations, like the text codecs, and the bare verification context mirrors them.
1 parent 694df92 commit 8e53750

4 files changed

Lines changed: 57 additions & 8 deletions

File tree

‎apps/sim/lib/execution/isolated-vm-worker.cjs‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,17 @@ async function executeTask(request, executionId) {
740740
releaseables.push(textDecodeCallback)
741741
await jail.set('__textDecode', textDecodeCallback)
742742

743+
// btoa / atob delegate to Node's native WHATWG implementations, so
744+
// libraries that encode base64 through them (docx's Packer.toBase64String,
745+
// document protection hashes, data-URI images) behave as in a browser.
746+
const btoaCallback = new ivm.Callback((data) => btoa(data))
747+
releaseables.push(btoaCallback)
748+
await jail.set('__btoa', btoaCallback)
749+
750+
const atobCallback = new ivm.Callback((data) => atob(data))
751+
releaseables.push(atobCallback)
752+
await jail.set('__atob', atobCallback)
753+
743754
// Delegate timers to Node's real timer heap via ivm.Reference (the pattern
744755
// recommended by laverdet/isolated-vm#136). Host-side bookkeeping was
745756
// hoisted to the function scope above so the finally can always sweep.
@@ -890,6 +901,8 @@ async function executeTask(request, executionId) {
890901
const __error = globalThis.__error;
891902
const __textEncode = globalThis.__textEncode;
892903
const __textDecode = globalThis.__textDecode;
904+
const __btoa = globalThis.__btoa;
905+
const __atob = globalThis.__atob;
893906
const __setTimeoutRef = globalThis.__setTimeoutRef;
894907
const __clearTimeoutRef = globalThis.__clearTimeoutRef;
895908
const __setIntervalRef = globalThis.__setIntervalRef;
@@ -927,6 +940,9 @@ async function executeTask(request, executionId) {
927940
}
928941
};
929942
943+
globalThis.btoa = function btoa(data) { return __btoa(String(data)); };
944+
globalThis.atob = function atob(data) { return __atob(String(data)); };
945+
930946
// setTimeout / setInterval delegate to Node's real timer heap via
931947
// ivm.Reference. The \`ms\` arg is honored (host clamps to the script
932948
// timeout window); \`clearTimeout\` / \`clearInterval\` actually cancel.
@@ -1039,7 +1055,7 @@ async function executeTask(request, executionId) {
10391055
const undefined_globals = [
10401056
'Isolate', 'Context', 'Script', 'Module', 'Callback', 'Reference',
10411057
'ExternalCopy', '__dirname', '__filename', '__brokerRef',
1042-
'__log', '__error', '__textEncode', '__textDecode',
1058+
'__log', '__error', '__textEncode', '__textDecode', '__btoa', '__atob',
10431059
'__setTimeoutRef', '__clearTimeoutRef', '__setIntervalRef'
10441060
];
10451061
for (const name of undefined_globals) {

‎apps/sim/lib/execution/sandbox/bundles/isolate-polyfills.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
* Beyond aliasing `global -> globalThis` for UMD-style fallbacks inside the
1111
* bundles, this file only answers the one name the bundler can leave dangling
1212
* (see below). All other runtime surface (`console`, `TextEncoder`,
13-
* `TextDecoder`, timers) is installed by the worker via `ivm.Callback` /
14-
* `ivm.Reference` bridges to Node's native implementations — no hand-rolled
15-
* polyfill logic lives in the isolate.
13+
* `TextDecoder`, `btoa`, `atob`, timers) is installed by the worker via
14+
* `ivm.Callback` / `ivm.Reference` bridges to Node's native implementations —
15+
* no hand-rolled polyfill logic lives in the isolate.
1616
*/
1717

1818
const g: typeof globalThis & {

‎apps/sim/lib/execution/sandbox/bundles/verify.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,37 @@ describe('sandbox bundles', () => {
4949
expect(await zip.file('word/document.xml')?.async('string')).toContain('R&D <ok>')
5050
})
5151

52+
it('docx encodes and decodes base64 through the btoa/atob the worker provides', async () => {
53+
const context = createBareSandboxContext()
54+
evaluateSandboxBundle(
55+
readFileSync(new URL('./docx.cjs', import.meta.url), 'utf-8'),
56+
'docx',
57+
context
58+
)
59+
const pngBase64 =
60+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=='
61+
62+
const packed: unknown = await vm.runInContext(
63+
`(async () => {
64+
const docx = globalThis.__bundles.docx;
65+
const doc = new docx.Document({
66+
protection: { type: 'readOnly', password: 'secret' },
67+
sections: [{ children: [new docx.Paragraph({ children: [
68+
new docx.ImageRun({ type: 'png', data: 'data:image/png;base64,${pngBase64}', transformation: { width: 10, height: 10 } }),
69+
] })] }],
70+
});
71+
return docx.Packer.toBase64String(doc);
72+
})()`,
73+
context
74+
)
75+
76+
const zip = await JSZip.loadAsync(packed as string, { base64: true })
77+
expect(await zip.file('word/settings.xml')?.async('string')).toMatch(
78+
/<w:documentProtection[^>]*w:hash="[^"]+"/
79+
)
80+
expect(zip.file('word/media/image1.png')).not.toBeNull()
81+
})
82+
5283
it('pdf-lib evaluates in a bare context and exposes the pdf-generate surface', () => {
5384
const pdfLib = loadCheckedInBundle('pdf-lib')
5485
expect(typeof pdfLib.PDFDocument).toBe('function')

‎apps/sim/lib/execution/sandbox/bundles/verify.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import vm from 'node:vm'
22
import type { SandboxBundleName } from '@/lib/execution/sandbox/types'
33

44
/**
5-
* A context shaped like the isolated-vm worker's: timers, `console`, and the
6-
* text codecs, but no `require`, `process`, `Buffer`, `atob`, or `btoa` of its
7-
* own. Code that reaches for any other Node or browser global fails here the
8-
* same way it fails inside the isolate.
5+
* A context shaped like the isolated-vm worker's task mode: timers, `console`,
6+
* the text codecs, and `btoa`/`atob`, but no `require`, `process`, or `Buffer`
7+
* of its own. Code that reaches for any other Node or browser global fails here
8+
* the same way it fails inside the isolate.
99
*/
1010
export function createBareSandboxContext(): vm.Context {
1111
const context: Record<string, unknown> = {
@@ -17,6 +17,8 @@ export function createBareSandboxContext(): vm.Context {
1717
console,
1818
TextEncoder,
1919
TextDecoder,
20+
btoa,
21+
atob,
2022
}
2123
context.globalThis = context
2224
return vm.createContext(context)

0 commit comments

Comments
 (0)