Skip to content

Instantly share code, notes, and snippets.

@tobiashochguertel
Last active August 14, 2026 23:32
Show Gist options
  • Select an option

  • Save tobiashochguertel/eb70fbd57f5c7bdea7b4381d3bfdfec1 to your computer and use it in GitHub Desktop.

Select an option

Save tobiashochguertel/eb70fbd57f5c7bdea7b4381d3bfdfec1 to your computer and use it in GitHub Desktop.
JSZip Xray test — does JSZip handle cross-realm Blobs in Firefox userscript sandbox?

JSZip Xray Test — Findings & Conclusion

Goal

Determine whether JSZip can handle cross-realm Blobs in a Firefox userscript sandbox (@sandbox JavaScript) without hitting the Xray boundary error:

Error: Accessing TypedArray data over Xrays is slow, and forbidden in order to
encourage performant code. To copy TypedArrays across origin boundaries,
consider using Components.utils.cloneInto().

Background

The gemini-conversation-exporter userscript added a "ZIP bundle" download strategy that fetches generated files via pageWindow.fetch() and bundles them into a ZIP archive using fflate.

On Firefox (Tampermonkey with @sandbox JavaScript), the fetch response's ArrayBuffer lives in the page realm. Firefox's Xray security model blocks userscript-realm code from accessing TypedArrays/ArrayBuffers from the page realm.

What was tested

This gist contains a minimal test userscript that:

  1. Fetches a binary file (favicon.ico) via GM_xmlhttpRequest (extension realm, avoids Xray on fetch)
  2. Gets a Blob from the response
  3. Passes the Blob directly to zip.file("favicon.ico", blob) — JSZip's documented Blob input API
  4. Calls zip.generateAsync({ type: "blob" })

Approaches tried in the main project

Approach Result Why
res.arrayBuffer() Xray error ArrayBuffer is in page realm
FileReader.readAsArrayBuffer(blob) Xray error FileReader creates ArrayBuffer in page realm (greasemonkey#2034)
new Response(blob).arrayBuffer() Xray error Blob is still from page realm, Response inherits it
FileReader.readAsDataURL(blob)atob()new Uint8Array() Works for fetch String primitives are never Xrayed; decoding happens in userscript realm
new TextEncoder().encode(str) Xray error TextEncoder is a page-realm object; returned Uint8Array is in page realm
Manual UTF-8 encoder → new Uint8Array(bytes) Works for text Avoids TextEncoder; creates Uint8Array in userscript realm

Test script approaches

Version Approach Result
v0.1–0.2 unsafeWindow.fetch + @grant none unsafeWindow is not defined
v0.3 unsafeWindow.fetch + @grant unsafeWindow Permission denied to access property "body" (DOM Xray)
v0.4 unsafeWindow.fetch + auto-run, no button Permission denied to access property "body" (fetch Promise Xray)
v0.5 GM_xmlhttpRequest + JSZip Blob input Hangs silently — JSZip's generateAsync() never resolves

Findings

JSZip does NOT fix the Xray issue

JSZip accepts Blob inputs (zip.file("name", blob)), but internally it still needs to read the Blob's binary data — via arrayBuffer(), FileReader, or similar APIs. When running in Firefox's @sandbox JavaScript mode, this internal read hits the same Xray boundary.

The zip.generateAsync() Promise hangs silently — the Xray error is swallowed inside JSZip's internal Promise chain, so no error is reported to the caller.

The Xray boundary is the root cause, not the ZIP library

The core problem is Firefox's Xray security model blocking TypedArray/ArrayBuffer access across realms. Any ZIP library (JSZip, fflate, zip.js) that needs to read binary data from a page-realm Blob will hit this boundary.

Whack-a-mole: each workaround only fixes one API

The @sandbox JavaScript mode creates a realm boundary that affects every Web API returning or accepting TypedArrays/DOM objects:

API Xray issue? Workaround
fetch().arrayBuffer() Yes base64 data URL + atob()
new TextEncoder().encode() Yes Manual UTF-8 encoder
new Blob([uint8array]) Likely Unknown
URL.createObjectURL(blob) Likely Unknown
document.createElement("a") Yes unsafeWindow.document
link.click() Likely Unknown

Each workaround only fixes one API — the next Xray issue appears at the next boundary. This whack-a-mole approach is unsustainable.

Conclusion

The real fix: @sandbox raw (implemented in v0.7.8)

Switching @sandbox from JavaScript to raw eliminates ALL Xray issues at the root.

@sandbox raw runs the userscript in the page context (MAIN_WORLD), so there is no realm boundary to cross. TextEncoder, Blob, URL.createObjectURL, fetch.arrayBuffer(), document.createElement() — all work natively without any workarounds.

This is Tampermonkey's default mode and the mode most userscripts use. The JavaScript sandbox was originally added to bypass Gemini's CSP, but raw mode also bypasses CSP (Tampermonkey injects the script in a way that circumvents page CSP).

Verified working in production — v0.7.8 successfully exports ZIP bundles containing:

  • Markdown and JSON text files (via TextEncoder.encode())
  • Binary .docx files (via fetch.arrayBuffer())
  • ZIP creation (via fflate.zipSync())
  • Download trigger (via Blob + URL.createObjectURL() + document.createElement("a"))

Why not stay in @sandbox JavaScript?

The JavaScript sandbox mode runs the userscript in Firefox's USERSCRIPT_WORLD — a separate realm from the page. While this provides isolation, it means every Web API call crosses the Xray boundary. The only way to share data between realms is via:

  • String primitives (never Xrayed) — works but requires manual encoding/decoding
  • Components.utils.cloneInto() — Firefox-specific, not available in all userscript managers
  • wrappedJSObject — Firefox-specific, bypasses Xray wrapping but is non-standard

None of these are cross-browser solutions. @sandbox raw is the only approach that works universally across Chrome, Firefox, and Edge without per-API workarounds.

Tradeoff of @sandbox raw

The tradeoff is reduced isolation — page scripts could theoretically interfere with the userscript. In practice, this is rarely an issue for export tools that only read page data and create downloads. Most popular userscripts use raw mode.

Version history

Version Approach Outcome
v0.7.4 base64 fetch workaround Fixed fetch Xray, but TextEncoder broke
v0.7.7 Manual UTF-8 encoder Fixed TextEncoder, but Blob/URL/DOM would break next
v0.7.8 @sandbox raw All Xray issues eliminated at the root

References

// ==UserScript==
// @name JSZip Xray Test
// @namespace test
// @version 0.5
// @description Tests whether JSZip can handle cross-realm Blobs in Firefox userscript sandbox
// @match https://gemini.google.com/*
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @connect gemini.google.com
// @require https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js
// @sandbox JavaScript
// @downloadURL https://gist.githubusercontent.com/tobiashochguertel/eb70fbd57f5c7bdea7b4381d3bfdfec1/raw/jszip-xray-test.user.js
// @updateURL https://gist.githubusercontent.com/tobiashochguertel/eb70fbd57f5c7bdea7b4381d3bfdfec1/raw/jszip-xray-test.user.js
// @icon https://www.google.com/favicon.ico
// ==/UserScript==
(async function () {
"use strict";
console.log("[JSZip Xray Test] starting...");
try {
// 1. Fetch using GM_xmlhttpRequest (extension realm, no Xray issues)
console.log("[JSZip Xray Test] fetching via GM_xmlhttpRequest...");
const blob = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: "https://gemini.google.com/favicon.ico",
responseType: "blob",
onload: (resp) => resolve(resp.response),
onerror: reject,
ontimeout: () => reject(new Error("timeout")),
});
});
console.log("[JSZip Xray Test] blob size:", blob.size, "type:", blob.type);
// 2. Pass Blob directly to JSZip
console.log("[JSZip Xray Test] creating JSZip instance...");
const zip = new JSZip();
zip.file("favicon.ico", blob);
zip.file("readme.txt", "Test file from JSZip Xray test.\n");
// 3. Generate zip
console.log("[JSZip Xray Test] generating zip...");
const zipBlob = await zip.generateAsync({ type: "blob" });
console.log("[JSZip Xray Test] zip blob size:", zipBlob.size);
// 4. Download via GM_download
const url = URL.createObjectURL(zipBlob);
console.log("[JSZip Xray Test] object URL:", url);
console.log("[JSZip Xray Test] SUCCESS: JSZip handled Blob without Xray error");
console.log("[JSZip Xray Test] (download skipped — check zipBlob.size above for success)");
URL.revokeObjectURL(url);
} catch (err) {
console.error("[JSZip Xray Test] FAILED:", err);
console.error("[JSZip Xray Test] stack:", err.stack);
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment