Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 885ec7c82e fix(a11y): axe findings: dialog names, aria-pressed, labels, avatar alt, placeholder contrast (#278)
CI / Build & Quality Checks (pull_request) Successful in 1m47s
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 12m18s
- Modal500 takes a label: Room/Space Settings ("<name> settings") and
  Settings dialogs have accessible names.
- aria-selected on plain buttons (inbox filter chips, date/time picker
  chips, verification/usage/notification menu items) -> aria-pressed; the
  Permissions power chips open a menu -> aria-haspopup + aria-expanded.
- Labels tied to the room pack name input and the Export From/To dates.
- UserAvatar defaults to alt="" (decorative next to a name / in a
  labelled button) unless a caller passes one.
- Composer placeholder: Slate's inline opacity 0.333 overrode the #222
  AA-contrast class (2.66:1); it is dropped so the class (0.75) applies.
- e2e/a11y.spec.ts also audits Room Settings → Permissions, Emojis &
  Stickers and Export, checks the dialog name, and audits the inbox.

Fixes #278

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:35:59 -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
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
27 changed files with 197 additions and 32 deletions
+19 -1
View File
@@ -118,8 +118,26 @@ test.describe('accessibility', () => {
test('room settings', async ({ page }) => {
await page.getByRole('button', { name: 'More Options' }).first().click();
await page.getByText('Room Settings', { exact: true }).click();
await expect(page.getByRole('dialog').first()).toBeVisible();
// [Gitea #278] The dialog has an accessible name.
await expect(page.getByRole('dialog', { name: 'A11y Room settings' })).toBeVisible();
await auditPage(page, 'room settings');
// [Gitea #278] aria-selected on the power chips, unlabelled pack name and
// export date inputs were only on these pages.
// One page at a time, in order.
for (const name of ['Permissions', 'Emojis & Stickers', 'Export']) {
// eslint-disable-next-line no-await-in-loop
await page.getByRole('dialog').getByText(name, { exact: true }).first().click();
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(1000);
// eslint-disable-next-line no-await-in-loop
await auditPage(page, `room settings: ${name}`);
}
});
test('inbox invites', async ({ page }) => {
await page.goto('/inbox/invites/');
await expect(page.getByRole('button', { name: /Known|Unknown|Spam/ }).first()).toBeVisible();
await auditPage(page, 'inbox invites');
});
});
});
+2 -2
View File
@@ -82,7 +82,7 @@ export function ManualVerificationMethodSwitcher({
<MenuItem
size="300"
variant="Surface"
aria-selected={value === ManualVerificationMethod.RecoveryPassphrase}
aria-pressed={value === ManualVerificationMethod.RecoveryPassphrase}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
>
@@ -93,7 +93,7 @@ export function ManualVerificationMethodSwitcher({
<MenuItem
size="300"
variant="Surface"
aria-selected={value === ManualVerificationMethod.RecoveryKey}
aria-pressed={value === ManualVerificationMethod.RecoveryKey}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
>
+4 -1
View File
@@ -7,8 +7,10 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
type Modal500Props = {
requestClose: () => void;
children: ReactNode;
/** [Gitea #278] The dialog's accessible name (role="dialog" needs one). */
label: string;
};
export function Modal500({ requestClose, children }: Modal500Props) {
export function Modal500({ requestClose, children, label }: Modal500Props) {
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -26,6 +28,7 @@ export function Modal500({ requestClose, children }: Modal500Props) {
variant="Background"
role="dialog"
aria-modal="true"
aria-label={label}
// On mobile expand to fill the viewport. On desktop fall back to the
// folds `size="500"` width (~50rem) — overriding maxWidth here would
// squish the two-pane settings layout.
+8 -1
View File
@@ -117,7 +117,14 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => (
<span {...attributes} className={css.EditorPlaceholderContainer}>
<span
{...attributes}
// [Gitea #278] Slate puts an inline `opacity: 0.333` on the placeholder,
// which beat EditorPlaceholderContainer's AA-contrast opacity (#222):
// measured 2.66:1. Drop it so the class decides.
style={{ ...attributes.style, opacity: undefined }}
className={css.EditorPlaceholderContainer}
>
{/* Inner component to style the actual text position and appearance */}
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
{children}
@@ -39,7 +39,7 @@ export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
key={getUsageStr(usage)}
size="300"
variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
aria-selected={isSelected(usage)}
aria-pressed={isSelected(usage)}
radii="300"
onClick={() => onChange(usage)}
>
+3 -3
View File
@@ -69,7 +69,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedDay === day}
aria-pressed={selectedDay === day}
onClick={() => handleDay(day)}
disabled={
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
@@ -90,7 +90,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedMonth === month}
aria-pressed={selectedMonth === month}
onClick={() => handleMonth(month)}
disabled={
(selectedYear === minYear && month < minMonth) ||
@@ -115,7 +115,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedYear === year}
aria-pressed={selectedYear === year}
onClick={() => handleYear(year)}
>
<Text size="T300">{year}</Text>
+5 -5
View File
@@ -71,7 +71,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={hour === selectedHour ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-selected={hour === selectedHour}
aria-pressed={hour === selectedHour}
onClick={() => handleHour(hour)}
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
>
@@ -90,7 +90,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={hour === selectedHour ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-selected={hour === selectedHour}
aria-pressed={hour === selectedHour}
onClick={() => handleHour(hour)}
disabled={
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
@@ -109,7 +109,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={minute === selectedMinute ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-selected={minute === selectedMinute}
aria-pressed={minute === selectedMinute}
onClick={() => handleMinute(minute)}
disabled={
(minDay && hour24 === minHour24 && minute < minMinute) ||
@@ -127,7 +127,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={!selectedPM}
aria-pressed={!selectedPM}
onClick={() => handlePeriod(false)}
disabled={minDay && minPM}
>
@@ -138,7 +138,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-selected={selectedPM}
aria-pressed={selectedPM}
onClick={() => handlePeriod(true)}
disabled={maxDay && !maxPM}
>
@@ -33,7 +33,9 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
<AvatarImage
className={classNames(css.UserAvatar, className)}
src={src}
alt={alt}
// [Gitea #278] An avatar sits next to the person's name or inside a
// labelled button, so without an explicit alt it is decorative.
alt={alt ?? ''}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
@@ -109,8 +109,11 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
alignItems="End"
>
<Box direction="Column" gap="100" grow="Yes">
<Text size="L400">Name</Text>
<Text as="label" htmlFor="room-pack-name" size="L400">
Name
</Text>
<Input
id="room-pack-name"
name="nameInput"
required
size="400"
@@ -136,7 +136,8 @@ export function PermissionGroups({
outlined={powerChanges}
fill="Soft"
radii="Pill"
aria-selected={opened}
aria-haspopup="menu"
aria-expanded={opened}
disabled={!canEdit || applyingChanges}
after={
powerChanges && (
@@ -200,7 +201,8 @@ export function PermissionGroups({
outlined={powerChanges}
fill="Soft"
radii="Pill"
aria-selected={opened}
aria-haspopup="menu"
aria-expanded={opened}
disabled={!canEdit || applyingChanges}
after={
powerChanges && (
@@ -353,8 +353,11 @@ ${msgRows}
>
<Box gap="400" wrap="Wrap">
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text size="T300">From</Text>
<Text as="label" htmlFor="export-from-date" size="T300">
From
</Text>
<Input
id="export-from-date"
type="date"
variant="Secondary"
size="400"
@@ -364,8 +367,11 @@ ${msgRows}
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text size="T300">To</Text>
<Text as="label" htmlFor="export-to-date" size="T300">
To
</Text>
<Input
id="export-to-date"
type="date"
variant="Secondary"
size="400"
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
if (!room) return null;
return (
<Modal500 requestClose={closeSettings}>
<Modal500 requestClose={closeSettings} label={`${room.name} settings`}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<RoomSettings initialPage={page} requestClose={closeSettings} />
@@ -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,
@@ -98,7 +98,7 @@ export function NotificationModeSwitcher({ pushRule, onChange }: NotificationMod
key={mode}
size="300"
variant="Surface"
aria-selected={mode === selectedMode}
aria-pressed={mode === selectedMode}
radii="300"
onClick={() => handleSelect(mode)}
>
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
if (!room) return null;
return (
<Modal500 requestClose={closeSettings}>
<Modal500 requestClose={closeSettings} label={`${room.name} space settings`}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<SpaceSettings initialPage={page} requestClose={closeSettings} />
+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.
+3 -3
View File
@@ -369,7 +369,7 @@ function InviteFilters({
<Box gap="200">
<Chip
variant={isKnown ? 'Success' : 'Surface'}
aria-selected={isKnown}
aria-pressed={isKnown}
outlined={!isKnown}
onClick={() => onFilter(InviteFilter.Known)}
before={isKnown && <Icon size="100" src={Icons.Check} />}
@@ -385,7 +385,7 @@ function InviteFilters({
</Chip>
<Chip
variant={isUnknown ? 'Warning' : 'Surface'}
aria-selected={isUnknown}
aria-pressed={isUnknown}
outlined={!isUnknown}
onClick={() => onFilter(InviteFilter.Unknown)}
before={isUnknown && <Icon size="100" src={Icons.Check} />}
@@ -401,7 +401,7 @@ function InviteFilters({
</Chip>
<Chip
variant={isSpam ? 'Critical' : 'Surface'}
aria-selected={isSpam}
aria-pressed={isSpam}
outlined={!isSpam}
onClick={() => onFilter(InviteFilter.Spam)}
before={isSpam && <Icon size="100" src={Icons.Check} />}
+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
+1 -1
View File
@@ -217,7 +217,7 @@ export function SettingsTab() {
</SidebarItemTooltip>
<PresencePicker />
{settingsOpen && (
<Modal500 requestClose={() => setSettingsOpen(false)}>
<Modal500 requestClose={() => setSettingsOpen(false)} label="Settings">
<Settings initialPage={initialPage} requestClose={() => setSettingsOpen(false)} />
</Modal500>
)}
@@ -82,7 +82,7 @@ function UnverifiedIndicator() {
</SidebarItem>
)}
{settings && (
<Modal500 requestClose={closeSettings}>
<Modal500 requestClose={closeSettings} label="Settings">
<Settings initialPage={SettingsPages.DevicesPage} requestClose={closeSettings} />
</Modal500>
)}
+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);
};
+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();