Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 ed494d2ae5 fix(room): one width for every right-hand panel (#263)
CI / Build & Quality Checks (pull_request) Successful in 1m43s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m12s
Members and bookmarks were 266px, the gallery 320, the threads list 340,
a thread and widgets 360. Swapping panels resized the room view, so the
header icons slid sideways (74px for members -> threads) and a second
click on the same spot opened Widgets instead of closing the panel. All
six now use SIDE_PANEL_WIDTH (340px, styles/sidePanel.ts).

Fixes #263

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:14:26 -04:00
20 changed files with 56 additions and 220 deletions
@@ -1,10 +1,11 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds'; import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
// Mirrors MembersDrawer: a 266px side panel on desktop that becomes a // Mirrors MembersDrawer: a side panel (shared width) on desktop that becomes a
// full-screen fixed panel on narrow viewports — the app's canonical drawer. // full-screen fixed panel on narrow viewports — the app's canonical drawer.
export const BookmarksPanel = style({ export const BookmarksPanel = style({
width: toRem(266), width: SIDE_PANEL_WIDTH,
'@media': { '@media': {
'(max-width: 750px)': { '(max-width: 750px)': {
position: 'fixed', position: 'fixed',
@@ -32,12 +32,24 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css'; import { SequenceCardStyle } from '../common-settings/styles.css';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { isValidServerPattern, matchesAnyGlob, normalizeServerAcl } from '../../utils/serverAcl'; import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
import { MobileTouchTarget } from '../../styles/mobile.css'; import { MobileTouchTarget } from '../../styles/mobile.css';
import { useModalStyle } from '../../hooks/useModalStyle'; import { useModalStyle } from '../../hooks/useModalStyle';
// ── Types ───────────────────────────────────────────────────────────────────── // ── 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 ───────────────────────────────────────────────── // ── Server list sub-component ─────────────────────────────────────────────────
type ServerListProps = { type ServerListProps = {
@@ -173,20 +185,20 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
// Read current ACL from room state // Read current ACL from room state
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl); const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
const currentAcl = normalizeServerAcl(aclEvent?.getContent()); const currentAcl = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
// Local draft state — initialised from current ACL // Local draft state — initialised from current ACL
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow); const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny); const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>( const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
() => currentAcl.allow_ip_literals, () => currentAcl.allow_ip_literals ?? false,
); );
// Track whether there are unsaved changes // Track whether there are unsaved changes
const isDirty = const isDirty =
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow) || JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny) || JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
allowIpLiterals !== currentAcl.allow_ip_literals; allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
// Save handler // Save handler
const [saveState, save] = useAsyncCallback( const [saveState, save] = useAsyncCallback(
@@ -324,10 +336,7 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
id="allow-ip-literals" id="allow-ip-literals"
checked={allowIpLiterals} checked={allowIpLiterals}
disabled={!canEdit} disabled={!canEdit}
// folds' Checkbox has no onChange; readOnly tells React the onClick={() => setAllowIpLiterals(!allowIpLiterals)}
// click handler controls it (no "checked without onChange").
readOnly
onClick={(e) => setAllowIpLiterals(e.currentTarget.checked)}
size="300" size="300"
variant="Primary" variant="Primary"
/> />
+5 -5
View File
@@ -1,13 +1,13 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; import { color, config, toRem } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
// Right-side drawer DOCKED into the room layout row (a flex sibling of the // Right-side drawer DOCKED into the room layout row (a flex sibling of the
// timeline), exactly like MembersDrawer — not a floating overlay. 320px is a // timeline), exactly like MembersDrawer — not a floating overlay. Same width as
// little wider than the 266px member/bookmark drawers because it hosts a media // every other right-hand panel (styles/sidePanel.ts, #263). On narrow viewports
// grid. On narrow viewports it becomes a full-screen fixed panel, matching the // it becomes a full-screen fixed panel, matching the app's other drawers.
// app's other drawers.
export const MediaGalleryDrawer = style({ export const MediaGalleryDrawer = style({
width: toRem(320), width: SIDE_PANEL_WIDTH,
overflow: 'hidden', overflow: 'hidden',
'@media': { '@media': {
'(max-width: 750px)': { '(max-width: 750px)': {
+3 -2
View File
@@ -1,8 +1,9 @@
import { keyframes, style } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css';
import { config, toRem } from 'folds'; import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
export const MembersDrawer = style({ export const MembersDrawer = style({
width: toRem(266), width: SIDE_PANEL_WIDTH,
'@media': { '@media': {
'(max-width: 750px)': { '(max-width: 750px)': {
position: 'fixed', position: 'fixed',
@@ -1,8 +1,9 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds'; import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
export const ThreadPanel = style({ export const ThreadPanel = style({
width: toRem(360), width: SIDE_PANEL_WIDTH,
'@media': { '@media': {
'(max-width: 750px)': { '(max-width: 750px)': {
position: 'fixed', position: 'fixed',
@@ -1,8 +1,9 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; import { color, config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
export const ThreadsListPanel = style({ export const ThreadsListPanel = style({
width: toRem(340), width: SIDE_PANEL_WIDTH,
'@media': { '@media': {
'(max-width: 750px)': { '(max-width: 750px)': {
position: 'fixed', position: 'fixed',
@@ -1,8 +1,9 @@
import { style } from '@vanilla-extract/css'; import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds'; import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
export const WidgetsPanel = style({ export const WidgetsPanel = style({
width: toRem(360), width: SIDE_PANEL_WIDTH,
'@media': { '@media': {
'(max-width: 750px)': { '(max-width: 750px)': {
position: 'fixed', position: 'fixed',
@@ -106,19 +106,3 @@ test('classifyWidgetCapabilities: reading protected state is fine, changing unpr
test('loadWidgetConsent tolerates missing storage', () => { test('loadWidgetConsent tolerates missing storage', () => {
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined); 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, []);
});
+1 -14
View File
@@ -67,16 +67,6 @@ export const PROTECTED_STATE_TYPES: ReadonlySet<string> = new Set([
'm.room.third_party_invite', 'm.room.third_party_invite',
'im.vector.modular.widgets', 'im.vector.modular.widgets',
'm.widget', '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 = { export type WidgetPermissionRequest = {
@@ -147,11 +137,8 @@ export const classifyWidgetCapabilities = (
*/ */
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] }; 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 => export const widgetConsentKey = (roomId: string, widgetId: string): string =>
`${WIDGET_CONSENT_PREFIX}${roomId}.${widgetId}`; `lotus.widgetConsent.${roomId}.${widgetId}`;
export const loadWidgetConsent = ( export const loadWidgetConsent = (
roomId: string, roomId: string,
-6
View File
@@ -33,8 +33,6 @@ import {
} from '../../../client/initMatrix'; } from '../../../client/initMatrix';
import { deleteSearchCacheDatabase } from '../../utils/searchCache'; import { deleteSearchCacheDatabase } from '../../utils/searchCache';
import { clearPlaintextCaches } from '../../state/plaintextCaches'; import { clearPlaintextCaches } from '../../state/plaintextCaches';
import { clearSoundboardClipCache } from '../../utils/soundboardClips';
import { clearSharedPayload } from '../../../swShare';
import { SplashScreen } from '../../components/splash-screen'; import { SplashScreen } from '../../components/splash-screen';
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
import { CapabilitiesProvider } from '../../hooks/useCapabilities'; import { CapabilitiesProvider } from '../../hooks/useCapabilities';
@@ -174,10 +172,6 @@ const useLogoutListener = (mx?: MatrixClient) => {
// (translation, scheduled messages, recent searches/forwards/gifs/ // (translation, scheduled messages, recent searches/forwards/gifs/
// stickers, nav paths) — wipe them on server-forced logout too. // stickers, nav paths) — wipe them on server-forced logout too.
clearPlaintextCaches(mx?.getUserId() ?? undefined); 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 // Remove only the session credential keys — NOT settings, drafts, and
// other preferences (N98). The SDK's IndexedDB stores are cleared above; // other preferences (N98). The SDK's IndexedDB stores are cleared above;
// window.localStorage.clear() is reserved for the explicit reset path. // window.localStorage.clear() is reserved for the explicit reset path.
-5
View File
@@ -145,11 +145,6 @@ export function Share() {
<Box direction="Column" gap="400"> <Box direction="Column" gap="400">
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
<Text size="L400">Shared</Text> <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) => ( {shared.files.map((f) => (
<Box key={f.name} alignItems="Center" gap="200"> <Box key={f.name} alignItems="Center" gap="200">
<Icon <Icon
+1 -18
View File
@@ -26,10 +26,7 @@ const api = {
: Object.getOwnPropertyDescriptor(target, key), : Object.getOwnPropertyDescriptor(target, key),
}); });
const { clearPlaintextCaches, plaintextCachesWiped, WIDGET_CONSENT_KEY_PREFIX } = const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
await import('./plaintextCaches');
const { WIDGET_CONSENT_PREFIX, widgetConsentKey } =
await import('../features/room/widgets/widgetUtils');
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => { test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
store.clear(); store.clear();
@@ -107,17 +104,3 @@ 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(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads'); 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,31 +139,6 @@ 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 => { export const clearPlaintextCaches = (userId?: string): void => {
wiped = true; wiped = true;
clearTranslationCache(); clearTranslationCache();
@@ -178,6 +153,5 @@ export const clearPlaintextCaches = (userId?: string): void => {
clearStatusMessage(); clearStatusMessage();
clearCallVolumesAndSyncMeta(userId); clearCallVolumesAndSyncMeta(userId);
clearAccountScopedFlags(userId); clearAccountScopedFlags(userId);
clearWidgetConsents();
if (userId) clearNavToActivePathStore(userId); if (userId) clearNavToActivePathStore(userId);
}; };
+9
View File
@@ -0,0 +1,9 @@
import { toRem } from 'folds';
/**
* One width for every right-hand room panel (members, bookmarks, threads, a
* thread, media gallery, widgets). Swapping one panel for another must not
* resize the room view: when the widths differed, the header icons slid
* sideways and a second click on the same spot hit a different button (#263).
*/
export const SIDE_PANEL_WIDTH = toRem(340);
+1 -38
View File
@@ -1,11 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import { isValidServerPattern, globToRegExp, matchesAnyGlob } from './serverAcl';
isValidServerPattern,
globToRegExp,
matchesAnyGlob,
normalizeServerAcl,
} from './serverAcl';
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => { test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
for (const v of [ for (const v of [
@@ -47,35 +42,3 @@ test('matchesAnyGlob: true if any glob matches (self-ban detection)', () => {
assert.equal(matchesAnyGlob('lotusguild.org', ['*.evil.com']), false); assert.equal(matchesAnyGlob('lotusguild.org', ['*.evil.com']), false);
assert.equal(matchesAnyGlob('lotusguild.org', []), 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,29 +50,3 @@ 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,7 +9,6 @@ import { pushSessionToSW } from '../sw-session';
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache'; import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
import { clearPlaintextCaches } from '../app/state/plaintextCaches'; import { clearPlaintextCaches } from '../app/state/plaintextCaches';
import { clearSoundboardClipCache } from '../app/utils/soundboardClips'; import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
import { clearSharedPayload } from '../swShare';
// Thrown when the local IndexedDB has a higher schema version than this SDK expects. // 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). // This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
@@ -147,9 +146,6 @@ export const logoutClient = async (mx: MatrixClient) => {
// [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard // [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard
// clip bytes stay reachable via live blob: URLs until the reload otherwise. // clip bytes stay reachable via live blob: URLs until the reload otherwise.
clearSoundboardClipCache(); 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 // Remove only the session credential keys, preserving user preferences and
// unsent drafts (N98). The factory-reset path is clearLoginData() below. // unsent drafts (N98). The factory-reset path is clearLoginData() below.
removeFallbackSession(); removeFallbackSession();
+1 -6
View File
@@ -1,7 +1,7 @@
/// <reference lib="WebWorker" /> /// <reference lib="WebWorker" />
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching'; import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare'; import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply'; import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
export type {}; export type {};
@@ -249,11 +249,6 @@ self.addEventListener('fetch', (event: FetchEvent) => {
// share_target). Stash the form and bounce to the in-app /share page. // share_target). Stash the form and bounce to the in-app /share page.
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) { if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length); 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( event.respondWith(
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)), stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
); );
+1 -12
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { isTrustedShareRequest, shareDraftText } from './swShare'; import { shareDraftText } from './swShare';
const base = { files: [], receivedAt: 0 }; const base = { files: [], receivedAt: 0 };
@@ -25,14 +25,3 @@ 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, url: 'https://x.test' }), 'https://x.test');
assert.equal(shareDraftText(base), ''); 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,27 +20,6 @@ export type SharePayload = {
receivedAt: number; 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. */ /** Worker side: stash the POSTed form and answer with a redirect to /share. */
export async function stashSharedForm(request: Request, base: string): Promise<Response> { export async function stashSharedForm(request: Request, base: string): Promise<Response> {
const form = await request.formData(); const form = await request.formData();