Compare commits

..
Author SHA1 Message Date
jared 997df415dd Merge pull request 'fix(outbox): two tabs no longer overwrite each other's queue (#268)' (#430) from fix/outbox-multi-tab into lotus
CI / Build & Quality Checks (push) Successful in 1m57s
CI / Trigger Desktop Build (push) Waiting to run
CI / Docker image build & smoke test (push) Skipped
CI / Playwright smoke (e2e) (push) Waiting to run
CI / Secret scan (gitleaks) (push) Successful in 7s
2026-10-19 15:31:01 -04:00
jared 7ad2a94e2e Merge pull request 'fix(security): widget consents and shared files cleared on logout; forged shares refused (#266, #267)' (#429) from fix/widget-share-hardening into lotus
CI / Build & Quality Checks (push) Canceled after 0s
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
2026-10-19 15:18:57 -04:00
jared e1168f47bb Merge pull request 'fix(room): one width for every right-hand panel (#263)' (#428) from fix/side-panel-width into lotus
CI / Build & Quality Checks (push) Successful in 1m48s
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:54:18 -04:00
Lotus CIandClaude Opus 5.5 97d2d195c3 fix(outbox): two tabs no longer overwrite each other's queue (#268)
CI / Build & Quality Checks (pull_request) Successful in 1m40s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m4s
OutboxFeature loaded lotus_outbox_v1 once and then saved its own
in-memory list over the key on every change, so a second tab's save
dropped the first tab's unsent messages. persist() now re-reads the
stored list and applies the change (add/remove by txnId) to it.

Fixes #268

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:49:40 -04:00
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 f5a184e428 Merge pull request 'fix(topic): keep URLs, aliases and user IDs intact in room topics (#262)' (#427) from fix/topic-markdown into lotus
CI / Build & Quality Checks (push) Canceled after 0s
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
2026-10-19 14:41:04 -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 ce1354ed93 fix(topic): keep URLs, aliases and user IDs intact in room topics (#262)
CI / Build & Quality Checks (pull_request) Successful in 1m48s
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 12m19s
The topic editor's hand-rolled markdown treated any single _ or * as
emphasis, inside URLs and #alias:server / @user:server too, and built the
plain topic by stripping tags, so the underscores were lost for every
client. It also left " unescaped in link targets.

buildTopicContent() now uses the composer's inline markdown parser (links,
bare URLs and HTML escaping as for messages) and treats _ between two
letters/digits as literal (CommonMark), except inside code, link targets
and URLs. The plain topic drops the markers but keeps links as
"text (url)" and decodes entities.

Fixes #262

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:11:16 -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
17 changed files with 352 additions and 59 deletions
@@ -52,8 +52,7 @@ import { settingsAtom } from '../../../state/settings';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
import { buildTopicContent } from '../../../utils/topicMarkdown';
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
const start = textarea.selectionStart;
@@ -71,30 +70,6 @@ function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholde
textarea.setSelectionRange(cursorStart, cursorEnd);
}
function topicMarkdownToHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/_(.+?)_/g, '<em>$1</em>')
.replace(/~~(.+?)~~/g, '<del>$1</del>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
.replace(/\n/g, '<br>');
}
function buildTopicContent(topic: string): Record<string, string> {
if (!MARKDOWN_PATTERN.test(topic)) return { topic };
const formattedBody = topicMarkdownToHtml(topic);
// Use HTML-stripped text as the plain topic so the header shows clean text, not raw markdown syntax
const plainTopic = formattedBody.replace(/<br>/g, '\n').replace(/<[^>]+>/g, '');
return { topic: plainTopic, format: 'org.matrix.custom.html', formatted_body: formattedBody };
}
type RoomProfileEditProps = {
canEditAvatar: boolean;
canEditName: boolean;
+12 -7
View File
@@ -94,10 +94,15 @@ export function OutboxFeature() {
OFFLINE.has(mx.getSyncState()) ||
(typeof navigator !== 'undefined' && navigator.onLine === false);
const persist = (next: OutboxEntry[]) => {
if (next === entries) return;
// [Gitea #268] Read-modify-write: another tab may have queued or cleared
// entries since we last looked, and saving our own copy over the key
// would drop theirs. Entries are added/removed by txnId, so applying our
// change to the stored list merges cleanly.
const persist = (update: (list: OutboxEntry[]) => OutboxEntry[]) => {
const stored = loadOutbox(userId);
const next = update(stored);
entries = next;
saveOutbox(userId, entries);
if (next !== stored) saveOutbox(userId, next);
};
/**
@@ -123,8 +128,8 @@ export function OutboxFeature() {
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
live.set(txnId, { event, room });
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
persist(
withEntry(entries, {
persist((list) =>
withEntry(list, {
txnId,
roomId: room.roomId,
threadId: event.threadRootId ?? null,
@@ -138,7 +143,7 @@ export function OutboxFeature() {
// SENT, CANCELLED, or the remote echo replaced it (status null).
live.delete(txnId);
autoRetries.delete(txnId);
persist(withoutEntry(entries, txnId));
persist((list) => withoutEntry(list, txnId));
};
/** Re-send network failures, oldest first; a room stops at its first failure. */
@@ -191,7 +196,7 @@ export function OutboxFeature() {
return !!room && isDelivered(room, entry.txnId);
},
);
plan.drop.forEach((entry) => persist(withoutEntry(entries, entry.txnId)));
plan.drop.forEach((entry) => persist((list) => withoutEntry(list, entry.txnId)));
plan.restore.forEach((entry) => {
const room = mx.getRoom(entry.roomId);
if (!room || live.has(entry.txnId)) return;
@@ -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,
};
}
+72
View File
@@ -0,0 +1,72 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildTopicContent } from './topicMarkdown';
test('a URL with underscores is sent verbatim (#262)', () => {
const t = 'Rules: https://wiki.lotusguild.org/en/server_rules_and_faq';
assert.deepEqual(buildTopicContent(t), { topic: t });
});
test('a markdown link keeps its underscored URL in both fields (#262)', () => {
const c = buildTopicContent('[Our wiki](https://wiki.example.org/my_cool_page)');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /href="https:\/\/wiki\.example\.org\/my_cool_page"/);
assert.equal(c.topic, 'Our wiki (https://wiki.example.org/my_cool_page)');
});
test('room aliases and user IDs with underscores are untouched (#262)', () => {
for (const t of [
'Join #my_room_name:matrix.org',
'Ask @some_user_x:matrix.org',
'see snake_case_word',
]) {
assert.deepEqual(buildTopicContent(t), { topic: t });
}
});
test('emphasis still works at word boundaries', () => {
const c = buildTopicContent('this is _really_ *great* and **bold**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<i data-md="_">really<\/i>/);
assert.match(c.formatted_body, /<i data-md="\*">great<\/i>/);
assert.match(c.formatted_body, /<strong data-md="\*\*">bold<\/strong>/);
assert.equal(c.topic, 'this is really great and bold');
});
test('emphasis around an alias keeps the alias intact', () => {
const c = buildTopicContent('**Join #my_room:matrix.org**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<strong data-md="\*\*">Join #my_room:matrix\.org<\/strong>/);
assert.equal(c.topic, 'Join #my_room:matrix.org');
});
test('inline code keeps its underscores and no backslashes appear', () => {
const c = buildTopicContent('run `a_b_c` now');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<code data-md="`">a_b_c<\/code>/);
assert.equal(c.topic, 'run a_b_c now');
});
test('a double quote in a link target cannot break out of the attribute (#262)', () => {
const c = buildTopicContent('[x](https://a" onclick="alert(1))');
assert.ok('formatted_body' in c);
assert.doesNotMatch(c.formatted_body, /" onclick="/);
});
test('HTML in the topic is escaped, and the plain topic shows it as typed', () => {
const c = buildTopicContent('**<b>hi</b> & bye**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /&lt;b&gt;hi&lt;\/b&gt; &amp; bye/);
assert.equal(c.topic, '<b>hi</b> & bye');
});
test('line breaks become <br> and survive in the plain topic', () => {
const c = buildTopicContent('**Rules**\nBe nice');
assert.ok('formatted_body' in c);
assert.equal(c.formatted_body, '<strong data-md="**">Rules</strong><br>Be nice');
assert.equal(c.topic, 'Rules\nBe nice');
});
test('plain text with no markdown is sent as typed', () => {
assert.deepEqual(buildTopicContent("It's <fun> & games"), { topic: "It's <fun> & games" });
});
+63
View File
@@ -0,0 +1,63 @@
import { parseInlineMD } from '../plugins/markdown';
import { sanitizeText } from './sanitize';
/**
* Room topic markdown → `m.room.topic` content (#262).
*
* Uses the composer's inline markdown parser (the same rules as messages: links,
* bare URLs and `"` are handled), with one topic-specific guard: an `_` between
* two letters/digits is literal, as in CommonMark. Topics are full of
* `#room_alias:server`, `@user_name:server` and snake_case paths, which the
* shared parser would otherwise turn into italics and, worse, drop from the
* plain `topic` that every other client shows.
*/
// Spans the guard must not touch: inline code, link targets, bare URLs.
const PROTECTED = /(`[^`\n]*`|\]\([^)\n]*\)|https?:\/\/[^\s<>"]+)/;
const INTRAWORD_UNDERSCORES = /(?<=[\p{L}\p{N}])_+(?=[\p{L}\p{N}])/gu;
const escapeIntrawordUnderscores = (line: string): string =>
line
.split(PROTECTED)
.map((part, i) =>
// split() with one capture group puts the protected spans at odd indexes.
i % 2 === 1 ? part : part.replace(INTRAWORD_UNDERSCORES, (m) => m.replace(/_/g, '\\_')),
)
.join('');
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
};
const decodeEntities = (s: string): string =>
s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => ENTITIES[e]);
/** Readable plain text for `topic`: markers dropped, links kept as "text (url)". */
const htmlToPlain = (html: string): string =>
decodeEntities(
html
.replace(/<a\b[^>]*\bhref="([^"]*)"[^>]*>(.*?)<\/a>/g, (_m, href: string, label: string) => {
const text = label.replace(/<[^>]+>/g, '');
return text === href ? href : `${text} (${href})`;
})
.replace(/<br>/g, '\n')
.replace(/<[^>]+>/g, ''),
);
export type TopicContent =
| { topic: string }
| { topic: string; format: 'org.matrix.custom.html'; formatted_body: string };
export function buildTopicContent(text: string): TopicContent {
const lines = text.split('\n');
const html = lines
.map((line) => parseInlineMD(sanitizeText(escapeIntrawordUnderscores(line))))
.join('<br>');
const plainHtml = lines.map(sanitizeText).join('<br>');
// No markdown at all: send the text as typed, without a formatted body.
if (html === plainHtml) return { topic: text };
return { topic: htmlToPlain(html), format: 'org.matrix.custom.html', formatted_body: html };
}
+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();