From 7d7a379ce080e63d4cf6beaa15eec42b9f6f1d04 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Tue, 29 Sep 2026 00:09:27 -0400 Subject: [PATCH] feat(desktop): call page on its own loopback origin, opt-in (#43) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop app loads the bundled Element Call page from its own origin (http://localhost:), so the call frame can read the app's storage (login token) and DOM — the hole #43 closed on the web by moving the page to call.chat.lotusguild.org. The desktop's local server can also answer on http://127.0.0.1:: the same server and bundle, but a different origin (and still a secure context). resolveDesktopCallPageUrl loads the bundled page from there when the desktop config sets `desktopCallOrigin`: - only a loopback http origin on the SAME port as the app, no path, query or credentials; - only when the app itself runs on http://localhost (release builds; debug builds on tauri:// keep the same-origin page); - unset (every desktop build until cinny-desktop opts in, together with the server bind, CSP and permission changes it needs): unchanged. The web app is unchanged (elementCallUrl as before). Tested in a simulated desktop app (Tauri bridge stub + the desktop config.json, served on localhost and 127.0.0.1) against a local Synapse + LiveKit, two users: call page from http://127.0.0.1:, parentUrl = the app origin; the frame gets SecurityError on parent.localStorage and parent.document (same-origin control: readable); join, speaking indicator, mic off/on, screenshare start/stop, layout switch and hang-up all work, no page errors — 12/12 in 5 of 6 runs, like the same-origin control (3 of 4; the misses on both sides were the local LiveKit connection). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/hooks/useClientConfig.ts | 9 +++++ src/app/pages/App.tsx | 16 +++++++-- src/app/plugins/call/callPageUrl.test.ts | 43 +++++++++++++++++++++++- src/app/plugins/call/callPageUrl.ts | 40 ++++++++++++++++++++-- 4 files changed, 102 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 4f7276201..c6e0b0acb 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -26,6 +26,15 @@ export type ClientConfig = { */ elementCallUrl?: string; + /** + * [Gitea #43] Desktop only: the loopback origin the desktop app's local + * server also answers on (e.g. "http://127.0.0.1:44548"), to load the + * bundled call page from a different origin than the app + * ("http://localhost:44548"). Set by cinny-desktop together with the server + * and CSP changes it needs; unset keeps the same-origin call page. + */ + desktopCallOrigin?: string; + /** * Absolute https URL of the public web app (e.g. https://chat.lotusguild.org). * The desktop app sets it so it can hand calls it can't make to the browser. diff --git a/src/app/pages/App.tsx b/src/app/pages/App.tsx index fb8912052..75fb86b7e 100644 --- a/src/app/pages/App.tsx +++ b/src/app/pages/App.tsx @@ -36,7 +36,11 @@ import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor'; import { zIndices } from '../styles/zIndex'; import { OIDC_CALLBACK_PATH } from './paths'; import { OidcCallback } from './auth/oidc/OidcCallback'; -import { resolveCallPageUrl, setCallPageUrl } from '../plugins/call/callPageUrl'; +import { + resolveCallPageUrl, + resolveDesktopCallPageUrl, + setCallPageUrl, +} from '../plugins/call/callPageUrl'; // The emoji families (Twemoji when "Twitter emoji" is on, Twemoji flags on // Windows — see SystemEmojiFeature) must sit before the generic family, or the @@ -223,7 +227,15 @@ function App() { > {(clientConfig) => { // [Gitea #43] Idempotent: where the call page is loaded from. - setCallPageUrl(resolveCallPageUrl(clientConfig.elementCallUrl, isTauri())); + setCallPageUrl( + isTauri() + ? resolveDesktopCallPageUrl( + clientConfig.desktopCallOrigin, + window.location.origin, + import.meta.env.BASE_URL, + ) + : resolveCallPageUrl(clientConfig.elementCallUrl, false), + ); return ( diff --git a/src/app/plugins/call/callPageUrl.test.ts b/src/app/plugins/call/callPageUrl.test.ts index 1d932d193..3f4af443a 100644 --- a/src/app/plugins/call/callPageUrl.test.ts +++ b/src/app/plugins/call/callPageUrl.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { resolveCallPageUrl } from './callPageUrl'; +import { resolveCallPageUrl, resolveDesktopCallPageUrl } from './callPageUrl'; const URL_OK = 'https://call.chat.example.org/public/element-call/index.html'; @@ -38,3 +38,44 @@ test('anything else falls back to the bundled page', () => { 'data:text/html,x', ].forEach((v) => assert.equal(resolveCallPageUrl(v, false), undefined, String(v))); }); + +const APP = 'http://localhost:44548'; +const PAGE = '/public/element-call/index.html'; + +test('desktop: the bundled page from the loopback origin on the same port', () => { + assert.equal( + resolveDesktopCallPageUrl('http://127.0.0.1:44548', APP, '/'), + `http://127.0.0.1:44548${PAGE}`, + ); + assert.equal( + resolveDesktopCallPageUrl('http://127.0.0.1:44548/', APP, '/app/'), + `http://127.0.0.1:44548/app${PAGE}`, + ); +}); + +test('desktop: unset or anything but same-port loopback http keeps the same-origin page', () => { + [ + undefined, + '', + 'http://127.0.0.1:44549', + 'http://127.0.0.1', + 'https://127.0.0.1:44548', + 'http://localhost:44548', + 'http://[::1]:44548', + 'http://10.0.0.5:44548', + 'https://call.chat.lotusguild.org', + 'http://127.0.0.1:44548/evil/', + 'http://127.0.0.1:44548/?x=1', + 'http://user:pw@127.0.0.1:44548', + 'not a url', + 42, + ].forEach((v) => assert.equal(resolveDesktopCallPageUrl(v, APP, '/'), undefined, String(v))); +}); + +test('desktop: only when the app itself runs on http://localhost (release builds)', () => { + const v = 'http://127.0.0.1:44548'; + assert.equal(resolveDesktopCallPageUrl(v, 'tauri://localhost', '/'), undefined); + assert.equal(resolveDesktopCallPageUrl(v, 'http://tauri.localhost', '/'), undefined); + assert.equal(resolveDesktopCallPageUrl(v, 'https://chat.lotusguild.org', '/'), undefined); + assert.equal(resolveDesktopCallPageUrl(v, 'http://localhost', '/'), undefined); +}); diff --git a/src/app/plugins/call/callPageUrl.ts b/src/app/plugins/call/callPageUrl.ts index aab13d478..56a2e3ef7 100644 --- a/src/app/plugins/call/callPageUrl.ts +++ b/src/app/plugins/call/callPageUrl.ts @@ -7,9 +7,9 @@ * app loads it from that origin instead, so the call frame can no longer * reach this origin's storage (login token, crypto store) or service worker. * - * Web only: the desktop app keeps its bundled copy (its CSP doesn't allow - * another frame origin, and a network copy could drift from the bundle). - * Anything that isn't an absolute https URL (http only on localhost, for + * Web only: the desktop app keeps its bundled copy (a network copy could + * drift from the bundle); see resolveDesktopCallPageUrl for how it isolates + * it. Anything that isn't an absolute https URL (http only on localhost, for * development) is ignored, so a bad value falls * back to the bundled page instead of breaking calls. */ @@ -28,6 +28,40 @@ export const resolveCallPageUrl = (value: unknown, desktop: boolean): string | u } }; +/** + * [Gitea #43] Desktop: the bundled call page from a second origin. + * + * The desktop app is served by its local server at http://localhost:. + * The same server answers on http://127.0.0.1:, which is a different + * origin (and still a secure context), so loading the bundled call page from + * there cuts the call frame off from the app's storage (login token, crypto + * store) without a network copy that could drift from the bundle. + * + * Only used when cinny-desktop sets `desktopCallOrigin` (it ships the server + * and CSP changes this needs in the same release), only for a loopback http + * origin on the SAME port as the app, and only when the app itself runs on + * http://localhost (release builds). Anything else keeps the same-origin page. + */ +export const resolveDesktopCallPageUrl = ( + value: unknown, + appOrigin: string, + basePath: string, +): string | undefined => { + if (typeof value !== 'string' || value.trim() === '') return undefined; + try { + const app = new URL(appOrigin); + const call = new URL(value); + if (app.protocol !== 'http:' || app.hostname !== 'localhost' || !app.port) return undefined; + if (call.protocol !== 'http:' || call.hostname !== '127.0.0.1') return undefined; + if (call.port !== app.port || call.username || call.password) return undefined; + if (call.pathname !== '/' || call.search || call.hash) return undefined; + const base = basePath.replace(/\/+$/, ''); + return `${call.origin}${base}/public/element-call/index.html`; + } catch { + return undefined; + } +}; + let callPageUrl: string | undefined; export const setCallPageUrl = (url: string | undefined): void => {