Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 14f05a3d5c fix(security): widget consents and shared files cleared on logout; forged shares refused (#266, #267)
CI / Build & Quality Checks (pull_request) Successful in 1m53s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m51s
#266: remembered widget approvals (lotus.widgetConsent.*) survived logout,
so they applied silently to the next account on the device and listed the
previous account's rooms; clearPlaintextCaches now sweeps them. Space
structure, call membership and Lotus room-policy state join
PROTECTED_STATE_TYPES, so no widget can be granted them.

#267: any website could auto-submit a form to /share-target and land the
user in the share picker with attacker-chosen text and files. The worker
now refuses a share whose Origin is another site. (Sec-Fetch-Site, as the
issue suggested, never reaches a service worker: checked in Chromium.)
Origin null/absent, as the OS share sheet sends, stays accepted, and
/share now says the content came from another app or website. Both logout
paths await clearSharedPayload(); the server-forced one also clears the
soundboard clip cache, as #57 intended.

Fixes #266
Fixes #267

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:47:11 -04:00
jared a5471a0576 Merge pull request 'fix(acl): a missing allow_ip_literals means allowed (#261)' (#426) from fix/acl-ip-literals-default into lotus
CI / Build & Quality Checks (push) Successful in 1m49s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 14:21:17 -04:00
Lotus CIandClaude Opus 5.5 adf94d7bc1 fix(acl): a missing allow_ip_literals means allowed (#261)
CI / Build & Quality Checks (pull_request) Successful in 1m54s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m45s
The Server ACL editor treated an absent allow_ip_literals as false, so the
first save in a room with no ACL (or one omitting the field) silently banned
IP-literal servers. The spec default is true. normalizeServerAcl() fills the
spec defaults once and feeds the initial state, the dirty check and the save.
It also stops showing an existing ACL with no allow list as allow-all: per
spec that allows no servers, and the /acl command already keeps it as-is.

The checkbox is driven by onClick (folds has no onChange), now readOnly so
React stops warning about a checked input without onChange.

Fixes #261

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:07:03 -04:00
13 changed files with 204 additions and 26 deletions
@@ -32,24 +32,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css';
import { stopPropagation } from '../../utils/keyboard';
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
import { isValidServerPattern, matchesAnyGlob, normalizeServerAcl } from '../../utils/serverAcl';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { useModalStyle } from '../../hooks/useModalStyle';
// ── Types ─────────────────────────────────────────────────────────────────────
type ServerAclContent = {
allow: string[];
deny: string[];
allow_ip_literals: boolean;
};
const DEFAULT_ACL: ServerAclContent = {
allow: ['*'],
deny: [],
allow_ip_literals: false,
};
// ── Server list sub-component ─────────────────────────────────────────────────
type ServerListProps = {
@@ -185,20 +173,20 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
// Read current ACL from room state
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
const currentAcl = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
const currentAcl = normalizeServerAcl(aclEvent?.getContent());
// Local draft state — initialised from current ACL
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow);
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny);
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
() => currentAcl.allow_ip_literals ?? false,
() => currentAcl.allow_ip_literals,
);
// Track whether there are unsaved changes
const isDirty =
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow) ||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny) ||
allowIpLiterals !== currentAcl.allow_ip_literals;
// Save handler
const [saveState, save] = useAsyncCallback(
@@ -336,7 +324,10 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
id="allow-ip-literals"
checked={allowIpLiterals}
disabled={!canEdit}
onClick={() => setAllowIpLiterals(!allowIpLiterals)}
// folds' Checkbox has no onChange; readOnly tells React the
// click handler controls it (no "checked without onChange").
readOnly
onClick={(e) => setAllowIpLiterals(e.currentTarget.checked)}
size="300"
variant="Primary"
/>
@@ -106,3 +106,19 @@ test('classifyWidgetCapabilities: reading protected state is fine, changing unpr
test('loadWidgetConsent tolerates missing storage', () => {
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
});
test('[#266] space structure, call membership and Lotus room policy can never be granted', () => {
const { auto, ask } = classifyWidgetCapabilities(
[
'm.space.child',
'm.space.parent',
'org.matrix.msc3401.call',
'org.matrix.msc3401.call.member',
'm.call.member',
'io.lotus.voice_limit',
'io.lotus.room_quality',
].map((t) => `org.matrix.msc2762.send.state_event:${t}`),
);
assert.equal(auto.size, 0);
assert.deepEqual(ask, []);
});
+14 -1
View File
@@ -67,6 +67,16 @@ export const PROTECTED_STATE_TYPES: ReadonlySet<string> = new Set([
'm.room.third_party_invite',
'im.vector.modular.widgets',
'm.widget',
// [Gitea #266] Space structure, call membership and Lotus room policy: a
// widget could restructure a space, fake call membership or lift the
// voice-channel limit, so these can't be granted even with consent.
'm.space.child',
'm.space.parent',
'org.matrix.msc3401.call',
'org.matrix.msc3401.call.member',
'm.call.member',
'io.lotus.voice_limit',
'io.lotus.room_quality',
]);
export type WidgetPermissionRequest = {
@@ -137,8 +147,11 @@ export const classifyWidgetCapabilities = (
*/
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
// [Gitea #266] Swept on logout by clearPlaintextCaches, so one account's
// approvals never apply to the next account on the same device.
export const WIDGET_CONSENT_PREFIX = 'lotus.widgetConsent.';
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
`lotus.widgetConsent.${roomId}.${widgetId}`;
`${WIDGET_CONSENT_PREFIX}${roomId}.${widgetId}`;
export const loadWidgetConsent = (
roomId: string,
+6
View File
@@ -33,6 +33,8 @@ import {
} from '../../../client/initMatrix';
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
import { clearPlaintextCaches } from '../../state/plaintextCaches';
import { clearSoundboardClipCache } from '../../utils/soundboardClips';
import { clearSharedPayload } from '../../../swShare';
import { SplashScreen } from '../../components/splash-screen';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
@@ -172,6 +174,10 @@ const useLogoutListener = (mx?: MatrixClient) => {
// (translation, scheduled messages, recent searches/forwards/gifs/
// stickers, nav paths) — wipe them on server-forced logout too.
clearPlaintextCaches(mx?.getUserId() ?? undefined);
// [Gitea #57] As on manual logout: no decrypted clip bytes via blob: URLs.
clearSoundboardClipCache();
// [Gitea #267] As on manual logout: drop files stashed by the share target.
await clearSharedPayload().catch(() => undefined);
// Remove only the session credential keys — NOT settings, drafts, and
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
// window.localStorage.clear() is reserved for the explicit reset path.
+5
View File
@@ -145,6 +145,11 @@ export function Share() {
<Box direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text size="L400">Shared</Text>
{/* [Gitea #267] Not everything that arrives here came from the
user's share sheet; say so before they pick a room. */}
<Text size="T200" priority="300">
This came from another app or website. Check it before you send it.
</Text>
{shared.files.map((f) => (
<Box key={f.name} alignItems="Center" gap="200">
<Icon
+18 -1
View File
@@ -26,7 +26,10 @@ const api = {
: Object.getOwnPropertyDescriptor(target, key),
});
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
const { clearPlaintextCaches, plaintextCachesWiped, WIDGET_CONSENT_KEY_PREFIX } =
await import('./plaintextCaches');
const { WIDGET_CONSENT_PREFIX, widgetConsentKey } =
await import('../features/room/widgets/widgetUtils');
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
store.clear();
@@ -104,3 +107,17 @@ test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
});
test('[#266] clearPlaintextCaches sweeps remembered widget approvals', () => {
assert.equal(WIDGET_CONSENT_KEY_PREFIX, WIDGET_CONSENT_PREFIX, 'prefix kept in sync');
store.clear();
const consent = widgetConsentKey('!room:server', 'widget-1');
store.set(consent, '{"url":"https://pad.example","allowed":[],"denied":[]}');
store.set('lotus.widgetConsentNotAKey', 'kept');
removed.length = 0;
clearPlaintextCaches('@me:server');
assert.ok(removed.includes(consent), 'consent cleared');
assert.ok(store.has('lotus.widgetConsentNotAKey'), 'unrelated key kept');
});
+26
View File
@@ -139,6 +139,31 @@ const clearAccountScopedFlags = (userId?: string): void => {
});
};
/**
* [Gitea #266] Remembered widget approvals (`lotus.widgetConsent.<room>.<widget>`,
* widgetUtils.ts) would otherwise apply silently to the next account on this
* device, and their keys list the previous account's rooms. Kept as a string
* here so logout doesn't pull the widget code into the entry chunk; a test
* checks it matches WIDGET_CONSENT_PREFIX.
*/
export const WIDGET_CONSENT_KEY_PREFIX = 'lotus.widgetConsent.';
const clearWidgetConsents = (): void => {
let keys: string[];
try {
keys = Object.keys(localStorage);
} catch {
return;
}
keys.forEach((key) => {
if (!key.startsWith(WIDGET_CONSENT_KEY_PREFIX)) return;
try {
localStorage.removeItem(key);
} catch {
// best-effort
}
});
};
export const clearPlaintextCaches = (userId?: string): void => {
wiped = true;
clearTranslationCache();
@@ -153,5 +178,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearStatusMessage();
clearCallVolumesAndSyncMeta(userId);
clearAccountScopedFlags(userId);
clearWidgetConsents();
if (userId) clearNavToActivePathStore(userId);
};
+38 -1
View File
@@ -1,6 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isValidServerPattern, globToRegExp, matchesAnyGlob } from './serverAcl';
import {
isValidServerPattern,
globToRegExp,
matchesAnyGlob,
normalizeServerAcl,
} from './serverAcl';
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
for (const v of [
@@ -42,3 +47,35 @@ test('matchesAnyGlob: true if any glob matches (self-ban detection)', () => {
assert.equal(matchesAnyGlob('lotusguild.org', ['*.evil.com']), false);
assert.equal(matchesAnyGlob('lotusguild.org', []), false);
});
test('no ACL event: every server allowed, IP literals allowed (#261)', () => {
assert.deepEqual(normalizeServerAcl(undefined), {
allow: ['*'],
deny: [],
allow_ip_literals: true,
});
});
test('a missing allow_ip_literals defaults to true, per the spec (#261)', () => {
assert.equal(normalizeServerAcl({ allow: ['*'], deny: ['bad.example'] }).allow_ip_literals, true);
});
test('an explicit allow_ip_literals is kept', () => {
assert.equal(
normalizeServerAcl({ allow: ['*'], allow_ip_literals: false }).allow_ip_literals,
false,
);
assert.equal(
normalizeServerAcl({ allow: ['*'], allow_ip_literals: true }).allow_ip_literals,
true,
);
});
test('missing or malformed lists become empty, non-strings are dropped', () => {
assert.deepEqual(normalizeServerAcl({ allow: ['a.example', 3, null] }), {
allow: ['a.example'],
deny: [],
allow_ip_literals: true,
});
assert.deepEqual(normalizeServerAcl({ allow: 'a.example', deny: {} }).allow, []);
});
+26
View File
@@ -50,3 +50,29 @@ export function matchesAnyGlob(domain: string, globs: string[]): boolean {
}
});
}
/**
* `m.room.server_acl` content with the spec's defaults filled in (#261).
* `allow_ip_literals` defaults to **true** when absent; `allow` and `deny`
* default to empty lists, but with no ACL event at all every server is allowed,
* so the editor starts from `allow: ['*']` in that case.
* https://spec.matrix.org/v1.12/client-server-api/#mroomserver_acl
*/
export type ServerAclContent = {
allow: string[];
deny: string[];
allow_ip_literals: boolean;
};
const stringList = (value: unknown): string[] | undefined =>
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : undefined;
export function normalizeServerAcl(content: Record<string, unknown> | undefined): ServerAclContent {
if (!content) return { allow: ['*'], deny: [], allow_ip_literals: true };
return {
allow: stringList(content.allow) ?? [],
deny: stringList(content.deny) ?? [],
allow_ip_literals:
typeof content.allow_ip_literals === 'boolean' ? content.allow_ip_literals : true,
};
}
+4
View File
@@ -9,6 +9,7 @@ import { pushSessionToSW } from '../sw-session';
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
import { clearSharedPayload } from '../swShare';
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
@@ -146,6 +147,9 @@ export const logoutClient = async (mx: MatrixClient) => {
// [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard
// clip bytes stay reachable via live blob: URLs until the reload otherwise.
clearSoundboardClipCache();
// [Gitea #267] Files stashed by the share target stay in Cache Storage until
// /share is opened; a shared photo must not outlive the logout.
await clearSharedPayload().catch(() => undefined);
// Remove only the session credential keys, preserving user preferences and
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
removeFallbackSession();
+6 -1
View File
@@ -1,7 +1,7 @@
/// <reference lib="WebWorker" />
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare';
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
export type {};
@@ -249,6 +249,11 @@ self.addEventListener('fetch', (event: FetchEvent) => {
// share_target). Stash the form and bounce to the in-app /share page.
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length);
// [Gitea #267] A cross-site form posting here is not a share: drop it.
if (!isTrustedShareRequest(event.request.headers, self.location.origin)) {
event.respondWith(Response.redirect(`${base}/`, 303));
return;
}
event.respondWith(
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
);
+12 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { shareDraftText } from './swShare';
import { isTrustedShareRequest, shareDraftText } from './swShare';
const base = { files: [], receivedAt: 0 };
@@ -25,3 +25,14 @@ test('shareDraftText drops a title/url the text already contains', () => {
assert.equal(shareDraftText({ ...base, url: 'https://x.test' }), 'https://x.test');
assert.equal(shareDraftText(base), '');
});
test('[#267] a share another website submitted is refused', () => {
const app = 'https://chat.lotusguild.org';
const h = (origin?: string) => new Headers(origin ? { Origin: origin } : {});
assert.equal(isTrustedShareRequest(h(), app), true, 'no Origin (OS share sheet)');
assert.equal(isTrustedShareRequest(h('null'), app), true, 'opaque origin (OS share sheet)');
assert.equal(isTrustedShareRequest(h(app), app), true, 'the app itself');
assert.equal(isTrustedShareRequest(h('https://evil.example'), app), false);
assert.equal(isTrustedShareRequest(h('https://chat.lotusguild.org.evil.example'), app), false);
assert.equal(isTrustedShareRequest(h('http://chat.lotusguild.org'), app), false, 'other scheme');
});
+21
View File
@@ -20,6 +20,27 @@ export type SharePayload = {
receivedAt: number;
};
/**
* [Gitea #267] Refuse a share that another website submitted: any site could
* otherwise auto-submit a cross-site form here and drop attacker-chosen text
* and files into the "Share to…" picker as if the user had shared them.
*
* The worker can't use `Sec-Fetch-Site`: fetch-metadata headers are added
* after the service worker and never appear on `event.request` (checked in
* Chromium). `Origin` does: a cross-site form carries the site's origin. The
* OS share sheet's POST has no web initiator, so its Origin is `null` or
* absent, and both stay accepted. A page can also produce `Origin: null`
* (sandboxed frame, no-referrer), which is why /share also says the content
* came from elsewhere.
*/
export const isTrustedShareRequest = (
headers: Pick<Headers, 'get'>,
selfOrigin: string,
): boolean => {
const origin = headers.get('Origin');
return origin === null || origin === 'null' || origin === selfOrigin;
};
/** Worker side: stash the POSTed form and answer with a redirect to /share. */
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
const form = await request.formData();