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. 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;`; } /**