release(v4.6.1): bind globalThis.fetch in browser-receiver-sensitive call sites

Browsers' Window.fetch is a WebIDL bound operation; storing it as
this.fetchImpl / this.fetchFn and calling via the instance receiver
threw "Illegal invocation" on the first request. Bind once at
construction in InboxClient, LongPollBridge, and SseBridge. Reported
by Prism (multi-device E2EE terminal), blocking every browser
consumer of the v4.6 transport stack on inbox.start() / bridge.connect().

WsBridge unaffected (uses WebSocket). Node/Bun fetch tolerates a free
receiver, so the bug never surfaced server-side — added regression
tests that install a strict-receiver globalThis.fetch to catch the
issue without an actual browser harness.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 23:00:58 +02:00
parent 2c400d7094
commit 8746571d2a
31 changed files with 243 additions and 28 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/cli",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/cli.ts",
"bin": {

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/core",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/crypto-web",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/dashboard",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/files",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/inbox-server",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/inbox",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -52,7 +52,14 @@ export class InboxClient {
private readonly fetchImpl: typeof fetch;
constructor(private readonly options: InboxClientOptions) {
this.fetchImpl = options.fetch ?? globalThis.fetch;
// Bind once. The browser's `globalThis.fetch` is a WebIDL bound
// operation that throws "Illegal invocation" when called as a method
// on another object (which is what `this.fetchImpl(...)` does).
// Node/Bun fetch tolerates a free receiver, but binding is harmless.
// A consumer-supplied `options.fetch` is bound to the global too —
// a fetch that requires a specific receiver must bind itself.
const f = options.fetch ?? globalThis.fetch;
this.fetchImpl = f.bind(globalThis);
}
/**

View File

@@ -281,3 +281,48 @@ describe('tamper detection', () => {
expect(result.received).toBe(0);
});
});
describe('InboxClient — default fetch is bound to globalThis', () => {
// Regression: browsers' `fetch` is a WebIDL bound operation that throws
// "Illegal invocation" when called as a method on another object. The
// class stores `fetchImpl` and calls `this.fetchImpl(...)`, which strips
// the Window receiver. Constructor must `bind(globalThis)`.
test('default path passes globalThis as `this` (no Illegal invocation)', async () => {
const realFetch = globalThis.fetch;
let observedReceiver: unknown = 'unset';
function strictFetch(this: unknown, _input: unknown, _init?: unknown): Promise<Response> {
observedReceiver = this;
if (this !== globalThis) {
throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation");
}
return Promise.resolve(
new Response('{}', {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
writable: true,
value: strictFetch,
});
try {
const id = await makeIdentity();
const client = new InboxClient({
baseUrl: 'http://example.invalid',
crypto,
signingPrivateKey: id.signingPrivateKey,
// No `fetch` override on purpose — this exercises the default path.
});
await client.register({ address: 'whoever', signingKey: id.signingPublicKey });
expect(observedReceiver).toBe(globalThis);
} finally {
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
writable: true,
value: realFetch,
});
}
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/key-transparency",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/keychain",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/observability",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/observer",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/proto",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/recovery",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/sdk",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/server",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/storage-encrypted",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/storage-indexeddb",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/storage-postgres",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/storage-sqlite",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/streams",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/transfer",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/transport-bridge",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -51,7 +51,10 @@ export class LongPollBridge implements BridgeTransport {
private loopPromise: Promise<void> | null = null;
constructor(private readonly options: LongPollBridgeOptions) {
this.fetchFn = options.fetch ?? globalThis.fetch;
// Bind to globalThis: browser `fetch` is a WebIDL bound operation
// and throws "Illegal invocation" when called as `this.fetchFn(...)`.
const f = options.fetch ?? globalThis.fetch;
this.fetchFn = f.bind(globalThis);
this.cursor = options.startCursor ?? 0;
}

View File

@@ -58,7 +58,10 @@ export class SseBridge implements BridgeTransport {
console.warn('[shade-bridge:sse]', err.message);
constructor(private readonly options: SseBridgeOptions) {
this.fetchFn = options.fetch ?? globalThis.fetch;
// Bind to globalThis: browser `fetch` is a WebIDL bound operation
// and throws "Illegal invocation" when called as `this.fetchFn(...)`.
const f = options.fetch ?? globalThis.fetch;
this.fetchFn = f.bind(globalThis);
this.cursor = options.startCursor ?? 0;
}

View File

@@ -510,3 +510,104 @@ describe('Bridge cursor resume', () => {
await sseB.disconnect();
});
});
describe('Bridges — default fetch is bound to globalThis', () => {
// Regression: browsers' `fetch` is a WebIDL bound operation that throws
// "Illegal invocation" when called via `this.fetchFn(...)`. Constructors
// for LongPollBridge / SseBridge must `bind(globalThis)`.
function installStrictFetch(): { restore: () => void; getReceiver: () => unknown } {
const realFetch = globalThis.fetch;
let observedReceiver: unknown = 'unset';
function strictFetch(this: unknown, _input: unknown, _init?: unknown): Promise<Response> {
observedReceiver = this;
if (this !== globalThis) {
throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation");
}
return Promise.resolve(
new Response('{"blobs":[]}', {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
}
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
writable: true,
value: strictFetch,
});
return {
restore: () =>
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
writable: true,
value: realFetch,
}),
getReceiver: () => observedReceiver,
};
}
test('LongPollBridge default path passes globalThis as `this`', async () => {
const { restore, getReceiver } = installStrictFetch();
try {
const id = await generateIdentityKeyPair(crypto);
const bridge = new LongPollBridge({
baseUrl: 'http://example.invalid',
auth: { crypto, signingPrivateKey: id.signingPrivateKey, address: 'foo' },
pollTimeoutMs: 100,
requestTimeoutMs: 200,
errorBackoffMs: 50,
disableLoop: true,
});
await bridge.connect({ onMessage: () => {} });
expect(getReceiver()).toBe(globalThis);
await bridge.disconnect();
} finally {
restore();
}
});
test('SseBridge default path passes globalThis as `this`', async () => {
// For SSE we only need to know the very first fetch was bound; the
// 200-with-empty-stream response will let openOnce return cleanly,
// and disableAutoReconnect prevents an infinite reconnect loop.
const realFetch = globalThis.fetch;
let observedReceiver: unknown = 'unset';
function strictFetch(this: unknown, _input: unknown, _init?: unknown): Promise<Response> {
observedReceiver = this;
if (this !== globalThis) {
throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation");
}
// Empty SSE-shaped body: stream closes immediately.
return Promise.resolve(
new Response('', {
status: 200,
headers: { 'content-type': 'text/event-stream' },
}),
);
}
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
writable: true,
value: strictFetch,
});
try {
const id = await generateIdentityKeyPair(crypto);
const bridge = new SseBridge({
baseUrl: 'http://example.invalid',
auth: { crypto, signingPrivateKey: id.signingPrivateKey, address: 'foo' },
initialBackoffMs: 50,
maxBackoffMs: 100,
disableAutoReconnect: true,
});
await bridge.connect({ onMessage: () => {} });
expect(observedReceiver).toBe(globalThis);
await bridge.disconnect();
} finally {
Object.defineProperty(globalThis, 'fetch', {
configurable: true,
writable: true,
value: realFetch,
});
}
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/transport-webrtc",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/transport",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",

View File

@@ -1,6 +1,6 @@
{
"name": "@shade/widgets",
"version": "4.6.0",
"version": "4.6.1",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",