Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/bridge-resolve-origin.md
Original file line number Diff line number Diff line change
@@ -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.
43 changes: 42 additions & 1 deletion __tests__/components/ConsoleWebView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
143 changes: 117 additions & 26 deletions __tests__/lib/shell/bridge-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
}

Expand Down Expand Up @@ -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();
});

Expand All @@ -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", () => {
Expand Down Expand Up @@ -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();
});
Expand All @@ -199,19 +204,24 @@ 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" });
});

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", () => {
Expand All @@ -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 = <T extends object>(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);("</script>\u2028end';
const script = buildResolveScript({ id: "d1.1", ok: true, value }, SERVER)!;
expect(script).not.toContain("</script>");
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";

Expand Down
6 changes: 4 additions & 2 deletions components/shell/ConsoleWebView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -548,10 +548,12 @@ export const ConsoleWebView = forwardRef<ConsoleWebViewHandle, ConsoleWebViewPro
// it is gone, and the reply would run in an excursion page or in a
// new document.
if (excursionEpochRef.current !== epoch || excursionRef.current.active) return;
webviewRef.current?.injectJavaScript(buildResolveScript(result));
// Only a page on the origin the handler trusted is answered.
const script = buildResolveScript(result, serverUrl);
if (script) webviewRef.current?.injectJavaScript(script);
});
},
[handleMessage]
[handleMessage, serverUrl]
);

const onShouldStartLoadWithRequest = useCallback(
Expand Down
6 changes: 5 additions & 1 deletion docs/HYBRID-SHELL.md
Original file line number Diff line number Diff line change
Expand Up @@ -662,7 +662,11 @@ Security:
`..` segment (raw or percent-encoded), backslash or control character, so it
cannot resolve outside `<server>/_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
Expand Down
5 changes: 4 additions & 1 deletion lib/shell/bridge/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down
22 changes: 19 additions & 3 deletions lib/shell/bridge/scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;`;
}

/**
Expand Down
Loading