From 774e6181122b934047134c9b18b7fc0b9773a86c Mon Sep 17 00:00:00 2001 From: mektpoy Date: Fri, 23 Oct 2026 14:42:40 +0800 Subject: [PATCH] fix(liveshare-protocol): forward only the page's own origin Matching a forwarded route on the pathname alone let any origin whose URL carried `/api/present.host` or `/open-in-app/apps` reach the owner instead of the destination it named. Resolve the request against the page base and forward it only when the target origin is the page's own; every other request reaches the fetch this carrier replaced. --- .../liveshare-protocol/src/viewer-carrier.ts | 11 +++++-- .../tests/viewer-carrier.client.spec.ts | 32 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/experimental/liveshare-protocol/src/viewer-carrier.ts b/packages/experimental/liveshare-protocol/src/viewer-carrier.ts index b7a305c5ba..97683833ab 100644 --- a/packages/experimental/liveshare-protocol/src/viewer-carrier.ts +++ b/packages/experimental/liveshare-protocol/src/viewer-carrier.ts @@ -190,8 +190,15 @@ export function installViewerCarrier(options: ViewerCarrierOptions): void { let pathname: string | undefined let method = 'GET' try { - const pageBase = (globalThis as { document?: { readonly baseURI: string } }).document?.baseURI - pathname = new URL(input instanceof Request ? input.url : String(input), pageBase).pathname + const page = globalThis as { + readonly document?: { readonly baseURI: string } + readonly location?: { readonly origin: string } + } + const pageOrigin = page.location?.origin + const target = new URL(input instanceof Request ? input.url : String(input), page.document?.baseURI) + // A request outside the page's own origin belongs to that origin: its path naming an owner + // route must not divert it from the fetch the page replaced. + pathname = pageOrigin !== undefined && target.origin === pageOrigin ? target.pathname : undefined method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase() } catch { // A target this page base cannot resolve is not one of the forwarded routes. diff --git a/packages/experimental/liveshare-protocol/tests/viewer-carrier.client.spec.ts b/packages/experimental/liveshare-protocol/tests/viewer-carrier.client.spec.ts index 9c428becab..6ae105accf 100644 --- a/packages/experimental/liveshare-protocol/tests/viewer-carrier.client.spec.ts +++ b/packages/experimental/liveshare-protocol/tests/viewer-carrier.client.spec.ts @@ -559,6 +559,36 @@ describe('viewer carrier forwarded page routes', () => { expect(direct).toHaveBeenCalledTimes(3) }) + it('leaves a cross-origin request that names a forwarded route to the page fetch', async () => { + const direct = directFetch() + expect(await (await globalThis.fetch('https://cdn.example/api/present.host')).text()) + .toBe('direct:https://cdn.example/api/present.host') + expect(await (await globalThis.fetch('https://cdn.example/x/open-in-app/apps')).text()) + .toBe('direct:https://cdn.example/x/open-in-app/apps') + expect(direct).toHaveBeenCalledTimes(2) + }) + + it('forwards an absolute request the page makes to its own origin', async () => { + const { posted } = carrier(() => json({ name: 'owner-host' })) + const origin = new URL(document.baseURI).origin + expect(await (await globalThis.fetch(`${origin}/relay/s/abc/api/present.host`)).json()) + .toMatchObject({ name: 'owner-host' }) + expect(posted[0]).toMatchObject({ op: 'call', path: '/api/present.host' }) + }) + + it('leaves a relative route to the page fetch once the page base names another origin', async () => { + const direct = directFetch() + const base = document.createElement('base') + base.href = 'https://cdn.example/' + document.head.append(base) + try { + expect(await (await globalThis.fetch('api/present.host')).text()).toBe('direct:api/present.host') + expect(direct).toHaveBeenCalledTimes(1) + } finally { + base.remove() + } + }) + it('forwards a hit whose page URL carries a query without passing it to the owner', async () => { const { posted } = carrier(() => json({ name: 'owner-host', available: true, fileManager: 'finder' })) expect(await (await globalThis.fetch('/relay/s/abc/api/present.host?x=1#frag')).json()).toMatchObject({ name: 'owner-host' }) @@ -626,7 +656,7 @@ describe('viewer carrier forwarded page routes', () => { it('reads the target and method from a Request object', async () => { const { posted } = carrier(() => json({ apps: ['finder'] })) - const response = await globalThis.fetch(new Request('https://relay.example/open-in-app/apps')) + const response = await globalThis.fetch(new Request(new URL('open-in-app/apps', document.baseURI))) expect(await response.json()).toEqual({ apps: ['finder'] }) expect(posted[0]).toMatchObject({ op: 'call', path: '/open-in-app/apps' }) })