[core] rewrite the worker, OPFS and BroadcastChannel interceptors (#108)

* [core] rewrite the worker, OPFS and BroadcastChannel interceptors

Moves the worker surface off the hand-rolled traps and onto the IDL
interceptors from the base PR.

- `shared/broadcastchannel.ts` is new: the channel name was not scoped
  per origin, so two sites proxied through scramjet could see each
  other's messages.
- `shared/opfs.ts` scopes the origin-private file system per site
  origin rather than per proxy origin.
- `worker/importScripts.ts` and `shared/worker.ts` resolve script URLs
  against the site's origin.

Tests: `opfs` and `broadcastchannel-scope`, the latter covering the
cross-origin delivery the unscoped name allowed.


* review and remove comments

---------
This commit is contained in:
velzie authored and GitHub committed 2026-09-22 11:58:16 -04:00
1 parent 310e211bc7
commit 060552a44c
6 files changed
+603 -84

No files matched your search

@@ -0,0 +1,30 @@
import { ScramjetClient } from "@client/index";
import { Constructor, Type } from "@client/webidl";
import { String_indexOf, String_substring } from "@/shared/snapshot";
export const enabled = (client: ScramjetClient, self: Self) =>
"BroadcastChannel" in self;
/**
* https://html.spec.whatwg.org/multipage/web-messaging.html#broadcastchannel
*/
export default function (client: ScramjetClient) {
client.Intercept(class extends BroadcastChannel {
@Constructor("DOMString")
static konstructor(name: string) {
return new this(`${client.scopeOrigin}@${name}`);
}
@Type("DOMString")
get name(): string {
const name = super.name;
// split on the *first* "@": an origin cannot contain one, so the
// first is always the separator and a name the page put an "@" in -
// including one that looks like it is already scoped - comes back
// intact. A name with no "@" at all is not one of ours and is
// answered as-is rather than mangled.
return String_substring(name, String_indexOf(name, "@") + 1);
}
});
}
@@ -1,27 +1,42 @@
import { ScramjetClient } from "@client/index";
import { Object_defineProperty } from "@/shared/snapshot";
import { encodeURIComponent } from "@/shared/snapshot";
import { Arguments, Returns, Type } from "@client/webidl";
export const enabled = (client: ScramjetClient, self: Self) =>
"StorageManager" in self && "FileSystemHandle" in self;
export default function (client: ScramjetClient) {
client.Proxy("StorageManager.prototype.getDirectory", {
apply(ctx) {
const rootPromise = ctx.call();
ctx.return(
(async () => {
const root = await rootPromise;
const directory = await root.getDirectoryHandle(
`${client.url.origin.replace(/\/|\s|\./g, "-")}`,
{
create: true,
}
);
Object_defineProperty(directory, "name", {
value: "",
writable: false,
});
const scopeName = () => encodeURIComponent(client.scopeOrigin);
return directory;
})()
);
},
client.Intercept(class extends StorageManager {
@Arguments()
@Returns("Promise<FileSystemDirectoryHandle>")
async getDirectory(): Promise<FileSystemDirectoryHandle> {
const root = await super.getDirectory();
// through the native rather than off the handle. `FileSystemDirectory
// Handle.prototype.getDirectoryHandle` is page-writable, and it is
// the only thing between the page and the *unscoped* root it was
// just handed: replacing it with `function () { return this; }` made
// `getDirectory()` resolve to the real origin root, and from there
// every other proxied site's tree is a `for await` away
const directory = await new client.native.FileSystemDirectoryHandle(
root
).getDirectoryHandle(scopeName(), { create: true });
client.box.scopedOpfsRoots.add(directory);
return directory;
}
});
client.Intercept(class extends FileSystemHandle {
@Type("USVString")
// eslint-disable-next-line scramjet-core/intercept-brand-check -- membership is itself the brand check: impossible to forge
get name(): string {
// the scoped directory stands in for the origin's root, and a root's
// name is the empty string.
if (client.box.scopedOpfsRoots.has(this)) return "";
return super.name;
}
});
}
@@ -1,68 +1,108 @@
import { ScramjetClient } from "@client/index";
import { Arguments, Constructor, Returns, Type } from "@client/webidl";
import { readWorkerOptions, readWorkletOptions } from "@client/helpers";
import { String_indexOf, String_substring } from "@/shared/snapshot";
export default function (client: ScramjetClient, _self: GlobalThis) {
client.Proxy("Worker", {
construct(ctx) {
ctx.args[0] = client.rewriteUrl(ctx.args[0], {
destination: "worker",
isModule: ctx.args[1]?.type === "module",
});
export default function (client: ScramjetClient, self: Self) {
const scoped = (name: string) => `${client.scopeOrigin}@${name}`;
const worker = ctx.call();
// const conn = new BareMuxConnection();
/**
* The `credentials` signal `rewriteUrl` stamps onto the proxy URL, which the
* service worker reads back for `Sec-Fetch-Storage-Access`. Only "include"
* is carried: `fetch/headers.ts` treats an absent value as the default the
* destination implies, and for these three that default is what the other
* two enum members mean.
*/
const credentialsOption = (credentials: RequestCredentials) =>
credentials === "include" ? "include" : undefined;
// (async () => {
// const port = await conn.getInnerPort();
// new client.native.Worker(worker).postMessage(
// {
// $scramjet$type: "baremuxinit",
// port,
// },
// [port]
// );
// })();
},
// A shared worker is the one scoped name the page can read back from the
// inside: `SharedWorkerGlobalScope.name` is the string the constructor was
// given, so without this a site reads `https://site.example@myworker`.
if ("SharedWorkerGlobalScope" in self) {
client.Intercept(class extends SharedWorkerGlobalScope {
@Type("DOMString")
get name(): string {
const name = super.name;
// the origin cannot contain an "@", so the first one is the
// separator and a name the page put an "@" in survives intact
return String_substring(name, String_indexOf(name, "@") + 1);
}
});
}
client.Intercept(class extends Worker {
@Constructor("USVString", "optional WorkerOptions")
static konstructor(scriptURL: string, options?: WorkerOptions) {
const init = readWorkerOptions(options);
return new this(
client.rewriteUrl(scriptURL, {
destination: "worker",
isModule: init.type === "module",
credentials: credentialsOption(init.credentials),
}),
init
);
}
});
// sharedworkers can only be constructed from window
client.Proxy("SharedWorker", {
construct(ctx) {
const isModule =
typeof ctx.args[1] === "object" && ctx.args[1]?.type === "module";
ctx.args[0] = client.rewriteUrl(ctx.args[0], {
destination: "sharedworker",
isModule,
});
if (ctx.args[1] && typeof ctx.args[1] === "string")
ctx.args[1] = `${client.url.origin}@${ctx.args[1]}`;
if (ctx.args[1] && typeof ctx.args[1] === "object") {
if (ctx.args[1].name) {
ctx.args[1].name = `${client.url.origin}@${ctx.args[1].name}`;
if ("SharedWorker" in self)
client.Intercept(class extends SharedWorker {
@Constructor("USVString", "optional (DOMString or WorkerOptions)")
static konstructor(scriptURL: string, options?: string | WorkerOptions) {
if (typeof options === "string") {
return new this(
client.rewriteUrl(scriptURL, { destination: "sharedworker" }),
scoped(options)
);
}
const init = readWorkerOptions(options);
return new this(
client.rewriteUrl(scriptURL, {
destination: "sharedworker",
isModule: init.type === "module",
credentials: credentialsOption(init.credentials),
}),
{ ...init, name: scoped(init.name!) }
);
}
});
const worker = ctx.call();
// const conn = new BareMuxConnection();
if ("Worklet" in self)
client.Intercept(class extends Worklet {
@Arguments("USVString", "optional WorkletOptions")
@Returns("Promise<undefined>")
async addModule(
moduleURL: string,
options?: WorkletOptions
): Promise<void> {
const init = readWorkletOptions(options);
// (async () => {
// const port = await conn.getInnerPort();
// new client.native.MessagePort(worker.port).postMessage(
// {
// $scramjet$type: "baremuxinit",
// port,
// },
// [port]
// );
// })();
},
});
client.Proxy("Worklet.prototype.addModule", {
apply(ctx) {
if (ctx.args[0]) ctx.args[0] = client.rewriteUrl(ctx.args[0]);
},
});
// a worklet's script is always a module script - there is no
// classic form to opt into - so the URL has to say so, or the
// service worker treats it as a classic script and neither the
// request's mode and credentials nor the rewrite it eventually
// gets are the ones a module is owed.
//
// `destination` is deliberately left to the browser, which
// reports `audioworklet` / `paintworklet`. Naming `script` here
// would route the body through the module rewriter, and the
// result references the `$scramjet$*` helpers - which a
// `WorkletGlobalScope` does not have and cannot be given, so it
// would turn a worklet that merely leaks URLs into one that
// throws on load.
return super.addModule(
client.rewriteUrl(moduleURL, {
isModule: true,
credentials: credentialsOption(init.credentials),
}),
init
);
}
});
}
@@ -1,13 +1,54 @@
import { ScramjetClient } from "@client/index";
import { GlobalScope, ScramjetClient } from "@client/index";
import { String } from "@/shared/snapshot";
import { Arguments, Returns, idlUSVString } from "@client/webidl";
export default function (client: ScramjetClient) {
client.Proxy("importScripts", {
apply(ctx) {
for (const i in ctx.args) {
const url = String(ctx.args[i]);
ctx.args[i] = client.rewriteUrl(url);
/**
* The string one `urls` entry contributes.
*
* The spec's first step is the get trusted type compliant string algorithm,
* which reads a TrustedScriptURL's data out of an internal slot rather than
* through its `toString` - which a page can replace. On an engine that has
* trusted types the declared union has already sorted the two cases out: a
* string arrived converted to a USVString, and a non-string is a real
* TrustedScriptURL. On an engine that has none there is nothing to
* disambiguate against, so the union degrades to a passthrough and the
* USVString conversion has to happen here instead.
*/
const scriptURLData = (url: TrustedScriptURL | string): string => {
if (typeof url === "string") return url;
if (client.box.ctors.TrustedScriptURL) {
return String(new client.native.TrustedScriptURL(url).toString());
}
return idlUSVString(url);
};
// https://html.spec.whatwg.org/multipage/workers.html#dom-workerglobalscope-importscripts
client.Intercept(class extends GlobalScope {
@Arguments("(TrustedScriptURL or USVString)... urls")
@Returns("undefined")
static importScripts(...urls: (TrustedScriptURL | string)[]): void {
// every URL is resolved before any of them is fetched, which is the
// spec's own order: it builds the whole urlRecords list, throwing a
// "SyntaxError" DOMException for the first failure, and only then
// starts fetching. rewriting inside the fetch loop would import the
// first few scripts and *then* throw
const rewritten: string[] = [];
for (let i = 0; i < urls.length; i++) {
// a URL that does not parse comes back untouched, so the native
// still throws the SyntaxError, and about the page's own string
rewritten[i] = client.rewriteUrl(scriptURLData(urls[i]));
}
},
// TODO: the NetworkError for a script that would not load says "The
// script at '<url>' failed to load", and by then the URL is the
// rewritten one. It cannot be repaired here for the reason above,
// and a DOMException's `message` is readonly, so it wants to be
// fixed where the failure is produced rather than where it surfaces.
// (The SyntaxError for an unparseable URL is already clean: such a
// URL reaches the native exactly as the page wrote it.)
new client.native.window(this).importScripts(...rewritten);
}
});
}
@@ -0,0 +1,226 @@
import { basicTest, multiFrameTest } from "../../testcommon.ts";
/* eslint-disable quotes -- browser snippets are clearer as template literals */
// A BroadcastChannel is matched on the tuple (storage key, name), and every
// proxied document shares the one real storage key - so the name is the only
// thing keeping two sites' channels apart, and it has to carry the origin.
//
// https://html.spec.whatwg.org/multipage/web-messaging.html#broadcastchannel
//
// `llm/broadcastchannel.ts` covers the envelope and the delivery semantics.
// This file covers only the scoping: that the prefix is invisible to the page,
// and that it lands on the origin the *site* has rather than the one the
// browser sees.
//
// The about:blank cases are the sharp edge. Such a document has no origin of
// its own - it inherits its creator's - while its own URL serializes to the
// opaque "null". Scoping on `client.url.origin` would therefore file every
// about:blank frame on every site under one shared "null" namespace, which is
// the same cross-site channel one level down. `ScramjetClient.siteOrigin`
// answers with the creator's origin, recorded once when the client was
// constructed, and these hold that in place.
const LOAD = `const load = (f) => new Promise((r) => { f.onload = r; setTimeout(r, 1000); });`;
export default [
// The prefix is an implementation detail and must not be observable. An
// origin cannot contain "@", which is what lets the un-scoping split on the
// first one and leave a name the page put an "@" in intact.
basicTest({
name: "bcscope-name-round-trips",
js: `
for (const name of ["plain", "with@at", "@leading", "trailing@", "a@b@c", ""]) {
const bc = new BroadcastChannel(name);
assertEqual(bc.name, name, "name round-trips: " + JSON.stringify(name));
bc.close();
}
// and a name that looks like a scoped one must not be un-scoped twice
const spoof = new BroadcastChannel(location.origin + "@spoof");
assertEqual(
spoof.name,
location.origin + "@spoof",
"a name that already looks scoped survives"
);
spoof.close();
`,
}),
// Two channels of the same name in one document still find each other -
// the scoping must be a prefix both sides agree on, not a per-instance one.
basicTest({
name: "bcscope-same-document-still-connects",
js: `
const name = "bcscope-same-doc-" + Math.random();
const a = new BroadcastChannel(name);
const b = new BroadcastChannel(name);
b.addEventListener("message", (event) => {
assertEqual(event.data, "hello", "the message arrives unwrapped");
a.close();
b.close();
pass();
});
a.postMessage("hello");
`,
autoPass: false,
}),
// An about:blank frame inherits its embedder's origin, so its channels are
// its embedder's channels. The channel is constructed through the child's
// own interface object, so it is the child's client doing the scoping.
basicTest({
name: "bcscope-about-blank-inherits-embedder",
js: `
${LOAD}
const name = "bcscope-inherit-" + Math.random();
const mine = new BroadcastChannel(name);
mine.addEventListener("message", (event) => {
assertEqual(event.data, "from-about-blank", "the message crossed into the embedder");
mine.close();
pass();
});
const f = document.createElement("iframe");
document.body.appendChild(f);
await load(f);
const theirs = new f.contentWindow.BroadcastChannel(name);
theirs.postMessage("from-about-blank");
theirs.close();
`,
autoPass: false,
}),
// Inheritance is recorded once per client, from its creator, rather than
// walked at read time - so a chain of about:blank documents has to resolve
// through each link's own captured answer. Two deep is enough to tell the
// two designs apart: the inner frame's creator is itself a document with no
// origin of its own.
basicTest({
name: "bcscope-nested-about-blank-inherits",
js: `
${LOAD}
const name = "bcscope-nested-" + Math.random();
const mine = new BroadcastChannel(name);
mine.addEventListener("message", (event) => {
assertEqual(event.data, "from-two-deep", "the message reached the top document");
mine.close();
pass();
});
const outer = document.createElement("iframe");
document.body.appendChild(outer);
await load(outer);
const inner = outer.contentDocument.createElement("iframe");
outer.contentDocument.body.appendChild(inner);
await load(inner);
assertEqual(
inner.contentWindow.location.href,
"about:blank",
"the inner frame really is about:blank too"
);
const theirs = new inner.contentWindow.BroadcastChannel(name);
theirs.postMessage("from-two-deep");
theirs.close();
`,
autoPass: false,
}),
// The direction `llm/broadcastchannel.ts` doesn't cover: the embedder
// posts, and the cross-origin child must not hear it.
multiFrameTest({
name: "bcscope-cross-origin-child-cannot-receive",
root: {
js: () => `
setTimeout(() => {
const bc = new BroadcastChannel("bcscope-xorigin-down");
bc.postMessage("should-not-cross");
bc.close();
}, 100);
`,
subframes: [
{
originid: "cross",
id: "listener",
js: () => `
const bc = new BroadcastChannel("bcscope-xorigin-down");
let leaked = false;
bc.addEventListener("message", () => {
leaked = true;
});
setTimeout(() => {
bc.close();
if (leaked) {
fail("a message from another origin reached this frame");
} else {
pass();
}
}, 600);
`,
},
],
},
}),
// The case the inheritance walk exists for: an about:blank frame inside
// site A and one inside site B. Neither has an origin of its own, so
// scoping on their own URLs files both under "null" and reopens exactly the
// channel this is all meant to close - one level further down, where it is
// much less likely to be noticed.
multiFrameTest({
name: "bcscope-about-blank-frames-do-not-cross-origins",
root: {
js: () => `
${LOAD}
// multiFrameTest serves a frame's script raw rather than through
// runTest, so the async scope and the failure path are explicit
(async () => {
const f = document.createElement("iframe");
document.body.appendChild(f);
await load(f);
const bc = new f.contentWindow.BroadcastChannel("bcscope-blank-xorigin");
let leaked = false;
bc.addEventListener("message", () => {
leaked = true;
});
setTimeout(() => {
bc.close();
if (leaked) {
fail(
"an about:blank frame heard another site's about:blank frame"
);
} else {
pass();
}
}, 900);
})().catch((error) => fail(error && error.message));
`,
subframes: [
{
originid: "cross",
id: "blanksender",
js: () => `
${LOAD}
(async () => {
const f = document.createElement("iframe");
document.body.appendChild(f);
await load(f);
// after the root has had time to install its listener
await new Promise((r) => setTimeout(r, 400));
const bc = new f.contentWindow.BroadcastChannel(
"bcscope-blank-xorigin"
);
bc.postMessage("should-not-cross");
bc.close();
})().catch((error) => fail(error && error.message));
`,
},
],
},
}),
];
@@ -0,0 +1,167 @@
import { basicTest, serverTest } from "../../testcommon.ts";
/* eslint-disable quotes -- browser snippets are clearer as template literals */
// OPFS is per-origin storage, and under a proxy every site shares the proxy's
// one origin. Scramjet hands each site a subdirectory of the real root instead,
// which has to be indistinguishable from the root itself:
//
// - its `name` is `""`, and `name` is a *prototype* getter, so the stand-in
// cannot carry an own `name` without becoming detectable
// - the directory it picks has to be injective in the origin, or two sites
// share a tree
//
// The handle is also structured-cloneable, so which realm reads `name` off it
// need not be the realm that asked for it.
const differential = (name: string, js: string) =>
basicTest({
name: `opfs-${name}`,
js: `
const snapshot = async () => {
try {
return { value: await (${js}) };
} catch (error) {
return { error: error && error.name, message: error && error.message };
}
};
assertConsistent(${JSON.stringify(name)}, await snapshot());
`,
});
// --- the handle can outlive the realm that asked for it ---------------------
const crossRealm = serverTest({
name: "opfs-scoped-root-name-across-realms",
autoPass: true,
js: `
const load = (f) => new Promise((r) => { f.onload = r; setTimeout(r, 4000); });
const f = document.createElement("iframe");
f.src = "/frame.html";
document.body.appendChild(f);
await load(f);
// asked for in the frame's realm...
const root = await f.contentWindow.navigator.storage.getDirectory();
assertEqual(root.name, "", "read back in the frame's own realm");
// ...and read through this realm's getter, which is a different client
const outerGetter = Object.getOwnPropertyDescriptor(FileSystemHandle.prototype, "name").get;
assertEqual(outerGetter.call(root), "", "read through the parent realm's getter");
// and the reverse direction
const ours = await navigator.storage.getDirectory();
const innerGetter = Object.getOwnPropertyDescriptor(
f.contentWindow.FileSystemHandle.prototype, "name"
).get;
assertEqual(innerGetter.call(ours), "", "our root read through the frame's getter");
`,
start: async (server) => {
server.on("request", (req, res) => {
if (res.headersSent) return;
const path = (req.url || "/").split("?")[0];
if (path === "/" || path === "/script.js") return;
if (path === "/frame.html") {
res.writeHead(200, { "Content-Type": "text/html" });
res.end("<!DOCTYPE html><html><body>frame</body></html>");
return;
}
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("nf");
});
},
});
export default [
// --- the stand-in root has to look like a root --------------------------
differential(
"root-name",
`navigator.storage.getDirectory().then((r) => r.name)`
),
differential(
"root-name-is-not-an-own-property",
`navigator.storage.getDirectory().then((r) =>
Object.getOwnPropertyDescriptor(r, "name") === null ||
Object.getOwnPropertyDescriptor(r, "name") === undefined
)`
),
differential(
"root-name-comes-off-the-prototype",
`navigator.storage.getDirectory().then((r) => {
// deleting an own data property would expose it; there is nothing to delete
const before = r.name;
delete r.name;
return before + "|" + r.name + "|" + Object.keys(r).length;
})`
),
differential(
"root-kind-and-prototype",
`navigator.storage.getDirectory().then((r) =>
r.kind + "|" +
(Object.getPrototypeOf(r) === FileSystemDirectoryHandle.prototype) + "|" +
(r instanceof FileSystemHandle)
)`
),
// --- a name getter still has to reject a forgery ------------------------
differential(
"name-getter-brand-checks",
`Promise.resolve().then(() => {
const fake = Object.create(FileSystemHandle.prototype);
try {
fake.name;
return "no throw";
} catch (e) {
return e.constructor.name;
}
})`
),
// --- the scoped directory has to be stable and usable -------------------
basicTest({
name: "opfs-round-trip-persists-across-calls",
js: `
const a = await navigator.storage.getDirectory();
const fh = await a.getFileHandle("probe.txt", { create: true });
const w = await fh.createWritable();
await w.write("stored");
await w.close();
// a second getDirectory() has to land in the same tree, or the scope
// name is not a stable function of the origin
const b = await navigator.storage.getDirectory();
const again = await b.getFileHandle("probe.txt");
assertEqual(await (await again.getFile()).text(), "stored", "the file survives a second getDirectory()");
const names = [];
for await (const key of b.keys()) names.push(key);
assertEqual(names.join(","), "probe.txt", "the scoped root lists only this site's entries");
await b.removeEntry("probe.txt");
`,
}),
basicTest({
name: "opfs-resolve-treats-the-scoped-root-as-the-root",
js: `
const root = await navigator.storage.getDirectory();
const dir = await root.getDirectoryHandle("sub", { create: true });
const fh = await dir.getFileHandle("f.txt", { create: true });
try {
assertEqual((await root.resolve(fh)).join("/"), "sub/f.txt", "resolve is relative to the stand-in root");
assertEqual((await root.resolve(root)).length, 0, "the root resolves to itself as an empty path");
} finally {
await root.removeEntry("sub", { recursive: true });
}
`,
}),
crossRealm,
];