From 10cf73500288ac926150a2b69f6f1145d702a4dd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:21:03 +0000 Subject: [PATCH 1/2] fix(shell): bridge answers follow the shell's origin rule A call's answer is spliced only into a page on the configured server's origin, the same rule as the shell's other page scripts that carry data: the resolve script checks window.location.origin against the server's origin (default ports dropped) before it calls the resolve global, and any other page gets nothing. An unusable server URL builds no script. Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- __tests__/components/ConsoleWebView.test.tsx | 43 +++++- __tests__/lib/shell/bridge-scripts.test.ts | 143 +++++++++++++++---- components/shell/ConsoleWebView.tsx | 6 +- docs/HYBRID-SHELL.md | 6 +- lib/shell/bridge/protocol.ts | 5 +- lib/shell/bridge/scripts.ts | 22 ++- 6 files changed, 191 insertions(+), 34 deletions(-) diff --git a/__tests__/components/ConsoleWebView.test.tsx b/__tests__/components/ConsoleWebView.test.tsx index 4248cd2..4e4d581 100644 --- a/__tests__/components/ConsoleWebView.test.tsx +++ b/__tests__/components/ConsoleWebView.test.tsx @@ -10,7 +10,7 @@ import { } from "~/components/shell/ConsoleWebView"; import { READY_TIMEOUT_MS } from "~/lib/shell/console-load"; import { EXCURSION_MAX_NAVIGATIONS, EXCURSION_TIMEOUT_MS } from "~/lib/shell/sign-in-excursion"; -import { CONNECTION_REMOVED_EVENT } from "~/lib/shell/bridge/protocol"; +import { BRIDGE_RESOLVE_GLOBAL, CONNECTION_REMOVED_EVENT } from "~/lib/shell/bridge/protocol"; const mockWebView = { props: null as any, @@ -132,6 +132,22 @@ const consoleReady = () => postEvent("ready"); const navigatedTo = (route: string) => expect.stringContaining(`${SERVER}/_console${route}`); +/** Run an injected answer in a page on `origin`: what its resolve global was called with. */ +function answersSettledOn(script: string, origin: string): unknown[] { + const settled: unknown[] = []; + new Function("window", script)({ + location: { origin }, + [BRIDGE_RESOLVE_GLOBAL]: (result: unknown) => settled.push(result), + }); + return settled; +} + +/** The script of the last injection. */ +const lastInjected = (): string => { + const calls = mockWebView.injectJavaScript.mock.calls; + return calls[calls.length - 1][0]; +}; + beforeEach(() => { mockIsConnected = true; mockWebView.props = null; @@ -354,6 +370,22 @@ describe("ConsoleWebView", () => { ); }); + it("delivers an answer only to a page on the configured server's origin", async () => { + renderConsole({ serverUrl: `${SERVER}/os` }); + await act(async () => { + mockWebView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: "call", id: "c1", method: "getPushToken" }), + url: `${SERVER}/os/_console/home`, + }, + }); + }); + expect(mockWebView.injectJavaScript).toHaveBeenCalledTimes(1); + const script = lastInjected(); + expect(answersSettledOn(script, SERVER)).toEqual([{ id: "c1", ok: true, value: null }]); + expect(answersSettledOn(script, "https://login.idp.example")).toEqual([]); + }); + describe("an answer whose document is gone", () => { /** A `scan()` from the loaded page, left open until the test settles it. */ function openScan() { @@ -403,6 +435,10 @@ describe("ConsoleWebView", () => { expect(mockWebView.injectJavaScript).toHaveBeenCalledWith( expect.stringContaining('"id":"d1.1","ok":true,"value":"4006381333931"') ); + // The page is still on the server's origin, so the answer reaches it. + expect(answersSettledOn(lastInjected(), SERVER)).toEqual([ + { id: "d1.1", ok: true, value: "4006381333931" }, + ]); }); it("is dropped when the WebView was re-created meanwhile", async () => { @@ -747,6 +783,11 @@ describe("ConsoleWebView — saved connections", () => { expect(mockWebView.injectJavaScript).toHaveBeenCalledWith( expect.stringContaining('"id":"c1","ok":true,"value":null') ); + // The answer is for a page on the new connection's origin only. + expect(answersSettledOn(lastInjected(), OTHER)).toEqual([ + { id: "c1", ok: true, value: null }, + ]); + expect(answersSettledOn(lastInjected(), SERVER)).toEqual([]); }); it("gives the new host's bootstrap the new origin", () => { diff --git a/__tests__/lib/shell/bridge-scripts.test.ts b/__tests__/lib/shell/bridge-scripts.test.ts index 0541402..0450ab7 100644 --- a/__tests__/lib/shell/bridge-scripts.test.ts +++ b/__tests__/lib/shell/bridge-scripts.test.ts @@ -8,6 +8,7 @@ import { } from "~/lib/shell/bridge/scripts"; import { BRIDGE_PROTOCOL_VERSION, + BRIDGE_RESOLVE_GLOBAL, CONNECTION_REMOVED_EVENT, NAVIGATE_EVENT, PUSH_TOKEN_CHANGED_EVENT, @@ -40,7 +41,10 @@ function makePage(origin = "https://acme.example.com") { documentElement: { style: { setProperty: (k: string, v: string) => (cssVars[k] = v) } }, addEventListener: (name: string, fn: () => void) => (listeners[name] = fn), }; - const run = (script: string) => new Function("window", "document", script)(window, document); + const run = (script: string | null) => { + if (script === null) throw new Error("no script"); + return new Function("window", "document", script)(window, document); + }; return { window, posted, cssVars, listeners, run }; } @@ -119,7 +123,7 @@ describe("bootstrap script", () => { expect(page.posted).toEqual([ { type: "call", id: expect.any(String), method: "haptic", args: { kind: "success" } }, ]); - page.run(buildResolveScript({ id: idOf(page.posted[0]), ok: true, value: null })); + page.run(buildResolveScript({ id: idOf(page.posted[0]), ok: true, value: null }, SERVER_URL)); await expect(pending).resolves.toBeNull(); }); @@ -135,25 +139,26 @@ describe("bootstrap script", () => { }); it("settles no call of a later document with an answer meant for an earlier one", async () => { - // A scan stays open while the page reloads, recovers or goes to a sign-in - // host; each new document numbers its calls from 1 again. + // A scan stays open while the page reloads, recovers, or leaves and comes + // back; each new document numbers its calls from 1 again. const before = makePage(); before.run(buildBootstrapScript(info, SERVER_URL)); void before.window.ObjectStackNative.scan(); - const staleAnswer = buildResolveScript({ id: idOf(before.posted[0]), ok: true, value: "CODE" }); - - for (const origin of ["https://acme.example.com", "https://login.idp.example"]) { - const after = makePage(origin); - after.run(buildBootstrapScript(info, SERVER_URL)); - let settled = false; - const pending = after.window.ObjectStackNative.getPushToken().then(() => (settled = true)); - after.run(staleAnswer); - await Promise.resolve(); - expect(settled).toBe(false); - after.run(buildResolveScript({ id: idOf(after.posted[0]), ok: true, value: null })); - await pending; - expect(settled).toBe(true); - } + const staleAnswer = buildResolveScript( + { id: idOf(before.posted[0]), ok: true, value: "CODE" }, + SERVER_URL + ); + + const after = makePage(); + after.run(buildBootstrapScript(info, SERVER_URL)); + let settled = false; + const pending = after.window.ObjectStackNative.getPushToken().then(() => (settled = true)); + after.run(staleAnswer); + await Promise.resolve(); + expect(settled).toBe(false); + after.run(buildResolveScript({ id: idOf(after.posted[0]), ok: true, value: null }, SERVER_URL)); + await pending; + expect(settled).toBe(true); }); it("posts scan calls with the caller's options, or none", () => { @@ -188,8 +193,8 @@ describe("bootstrap script", () => { const read = page.window.ObjectStackNative.scan(); const cancelled = page.window.ObjectStackNative.scan(); const [readId, cancelId] = page.posted.map(idOf); - page.run(buildResolveScript({ id: readId, ok: true, value: "4006381333931" })); - page.run(buildResolveScript({ id: cancelId, ok: true, value: null })); + page.run(buildResolveScript({ id: readId, ok: true, value: "4006381333931" }, SERVER_URL)); + page.run(buildResolveScript({ id: cancelId, ok: true, value: null }, SERVER_URL)); await expect(read).resolves.toBe("4006381333931"); await expect(cancelled).resolves.toBeNull(); }); @@ -199,11 +204,14 @@ describe("bootstrap script", () => { page.run(buildBootstrapScript(info, SERVER_URL)); const pending = page.window.ObjectStackNative.navigate("bad"); page.run( - buildResolveScript({ - id: idOf(page.posted[0]), - ok: false, - error: { code: "bad_args", message: "nope" }, - }) + buildResolveScript( + { + id: idOf(page.posted[0]), + ok: false, + error: { code: "bad_args", message: "nope" }, + }, + SERVER_URL + ) ); await expect(pending).rejects.toMatchObject({ message: "nope", code: "bad_args" }); }); @@ -211,7 +219,9 @@ describe("bootstrap script", () => { it("ignores results for unknown calls", () => { const page = makePage(); page.run(buildBootstrapScript(info, SERVER_URL)); - expect(() => page.run(buildResolveScript({ id: "c99", ok: true, value: 1 }))).not.toThrow(); + expect(() => + page.run(buildResolveScript({ id: "c99", ok: true, value: 1 }, SERVER_URL)) + ).not.toThrow(); }); it("posts events", () => { @@ -234,6 +244,87 @@ describe("bootstrap script", () => { }); }); +describe("resolve script", () => { + const SERVER = "https://acme.example.com"; + const RESULT = { id: "d1.1", ok: true as const, value: "4006381333931" }; + + /** A fake page window on `origin` whose resolve global is a spy. */ + function makeResolvePage(origin = SERVER) { + const resolve = jest.fn(); + const window = { location: { origin }, [BRIDGE_RESOLVE_GLOBAL]: resolve }; + const run = (script: string | null) => { + if (script === null) throw new Error("no script"); + return new Function("window", script)(window); + }; + return { resolve, run }; + } + + it("settles the call on a page of the configured server's origin", () => { + const page = makeResolvePage(); + page.run(buildResolveScript(RESULT, `${SERVER}/os`)); + expect(page.resolve).toHaveBeenCalledTimes(1); + expect(page.resolve).toHaveBeenCalledWith(RESULT); + }); + + it("compares the page origin with the default port dropped", () => { + const page = makeResolvePage(SERVER); + page.run(buildResolveScript(RESULT, `${SERVER}:443/os`)); + expect(page.resolve).toHaveBeenCalledWith(RESULT); + }); + + it.each([ + "https://login.idp.example", + "http://acme.example.com", + "https://acme.example.com:8443", + "https://other.acme.example.com", + "null", + ])("calls no resolve global on a page on another origin (%s)", (origin) => { + const page = makeResolvePage(origin); + page.run(buildResolveScript(RESULT, SERVER)); + expect(page.resolve).not.toHaveBeenCalled(); + }); + + it("reads nothing but the origin of a page on another origin", () => { + const reads: string[] = []; + const record = (prefix: string, target: T): T => + new Proxy(target, { + get(obj, key, receiver) { + reads.push(`${prefix}${String(key)}`); + return Reflect.get(obj, key, receiver); + }, + }); + const resolve = jest.fn(); + const window = record("", { + location: record("location.", { origin: "https://login.idp.example" }), + [BRIDGE_RESOLVE_GLOBAL]: resolve, + }); + new Function("window", buildResolveScript(RESULT, SERVER)!)(window); + expect(new Set(reads)).toEqual(new Set(["location", "location.origin"])); + expect(resolve).not.toHaveBeenCalled(); + }); + + it("does nothing on a page of the server's origin that has no resolve global", () => { + expect(() => + new Function("window", buildResolveScript(RESULT, SERVER)!)({ location: { origin: SERVER } }) + ).not.toThrow(); + }); + + it("builds no script for an unusable server URL", () => { + expect(buildResolveScript(RESULT, "not a url")).toBeNull(); + expect(buildResolveScript(RESULT, "")).toBeNull(); + }); + + it("splices the result as escaped JSON", () => { + const value = '");alert(1);("\u2028end'; + const script = buildResolveScript({ id: "d1.1", ok: true, value }, SERVER)!; + expect(script).not.toContain(""); + expect(script).not.toContain("\u2028"); + const page = makeResolvePage(); + page.run(script); + expect(page.resolve).toHaveBeenCalledWith({ id: "d1.1", ok: true, value }); + }); +}); + describe("navigate script", () => { const SERVER = "https://acme.example.com"; diff --git a/components/shell/ConsoleWebView.tsx b/components/shell/ConsoleWebView.tsx index 9c5cbea..0e30ba1 100644 --- a/components/shell/ConsoleWebView.tsx +++ b/components/shell/ConsoleWebView.tsx @@ -548,10 +548,12 @@ export const ConsoleWebView = forwardRef/_console/`. `consoleUrl` applies the same check before building any URL. -- Results are spliced into the page as escaped JSON (`toScriptLiteral`). +- Results are spliced into the page as escaped JSON (`toScriptLiteral`), and + only into a page on the configured server's origin (default ports ignored), + the shell's origin rule for every page script that carries data + (`buildResolveScript` in `bridge/scripts.ts`). Any other page in the main + frame gets nothing, and no global of it is called. Adding a method: add it to `BRIDGE_METHODS` (it becomes a capability of the same name), handle it in `bridge/handler.ts`, expose it in the bootstrap diff --git a/lib/shell/bridge/protocol.ts b/lib/shell/bridge/protocol.ts index 4576591..121dbb6 100644 --- a/lib/shell/bridge/protocol.ts +++ b/lib/shell/bridge/protocol.ts @@ -17,7 +17,10 @@ export const BRIDGE_PROTOCOL_VERSION = 1; /** The global the Console reads. */ export const BRIDGE_GLOBAL = "ObjectStackNative"; -/** The global the shell calls to settle a pending call. */ +/** + * The global the shell calls to settle a pending call — only in a page on the + * configured server's origin. + */ export const BRIDGE_RESOLVE_GLOBAL = "__objectstackNativeResolve"; /** diff --git a/lib/shell/bridge/scripts.ts b/lib/shell/bridge/scripts.ts index 632f1e6..c664699 100644 --- a/lib/shell/bridge/scripts.ts +++ b/lib/shell/bridge/scripts.ts @@ -113,9 +113,25 @@ export function buildBootstrapScript(info: BridgeHostInfo, serverUrl: string): s true;`; } -/** Snippet that settles one pending call in the page. */ -export function buildResolveScript(result: BridgeResult): string { - return `window.${BRIDGE_RESOLVE_GLOBAL} && window.${BRIDGE_RESOLVE_GLOBAL}(${toScriptLiteral(result)}); true;`; +/** + * Snippet that settles one pending call in the page, or `null` when there is + * no server origin to deliver to. + * + * The answer goes only to a page on the configured server's origin (default + * ports ignored) — the same pages the handler answers, and the shell's rule + * for every page script that carries data. Whatever else is in the main frame + * gets nothing: the script reads only `window.location.origin` there and calls + * no global. + */ +export function buildResolveScript(result: BridgeResult, serverUrl: string): string | null { + const origin = originOf(serverUrl); + if (origin === null) return null; + return `(function () { + if (!window.location || window.location.origin !== ${toScriptLiteral(origin)}) return; + if (typeof window.${BRIDGE_RESOLVE_GLOBAL} !== "function") return; + window.${BRIDGE_RESOLVE_GLOBAL}(${toScriptLiteral(result)}); +})(); +true;`; } /** From 5028e6fe2a90649424f7786d1f5bafd7e2580a4d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 01:23:28 +0000 Subject: [PATCH 2/2] chore(changeset): bridge answers follow the shell's origin rule Claude-Session: https://claude.ai/code/session_01QSeKYnW1Xf6HDSWz1eGAqv Co-authored-by: Claude --- .changeset/bridge-resolve-origin.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/bridge-resolve-origin.md diff --git a/.changeset/bridge-resolve-origin.md b/.changeset/bridge-resolve-origin.md new file mode 100644 index 0000000..1a1e513 --- /dev/null +++ b/.changeset/bridge-resolve-origin.md @@ -0,0 +1,13 @@ +--- +"mobile": patch +--- + +Clause-②: no +Metadata-protocol: none + +Bridge answers follow the same origin rule as the shell's other page scripts. +The answer to a `window.ObjectStackNative` call is delivered only to a page on +the configured server's origin, default ports ignored; any other page in the +main frame gets nothing. + +No native module and no new permission: an over-the-air update is enough.