Merge pull request 'Desktop: call page on its own loopback origin, opt-in (#43)' (#252) from desktop-call-origin into lotus
CI / Build & Quality Checks (push) Canceled after 8s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s

Merge pull request #252: desktop call page on its own loopback origin, opt-in (#43)
This commit was merged in pull request #252.
This commit is contained in:
2026-09-29 09:34:48 -04:00
4 changed files with 102 additions and 6 deletions
+9
View File
@@ -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.
+14 -2
View File
@@ -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 (
<ClientConfigProvider value={clientConfig}>
<QueryClientProvider client={queryClient}>
+42 -1
View File
@@ -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);
});
+37 -3
View File
@@ -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:<port>.
* The same server answers on http://127.0.0.1:<port>, 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 => {