Files
cinny/src/swShare.ts
T
Lotus CIandClaude Opus 5.5 14f05a3d5c
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
fix(security): widget consents and shared files cleared on logout; forged shares refused (#266, #267)
#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

110 lines
4.2 KiB
TypeScript

/**
* [Gitea #155] Web Share Target plumbing shared by the service worker and the
* app. Android's share sheet POSTs a multipart form to /share-target (see
* public/manifest.json); the worker stashes the payload in the Cache API and
* redirects to the in-app /share page, which reads it back, lets the user
* pick a room and pre-attaches the files to that room's composer.
*/
export const SHARE_TARGET_PATH = '/share-target';
export const SHARE_PAGE_PATH = '/share';
export const SHARE_CACHE = 'lotus-share-target';
export const SHARE_PAYLOAD_KEY = '/share-target/payload';
export const shareFileKey = (index: number) => `/share-target/file/${index}`;
export type SharePayload = {
title?: string;
text?: string;
url?: string;
files: { name: string; type: string; size: 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. */
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
const form = await request.formData();
const files = form.getAll('files').filter((f): f is File => f instanceof File);
const str = (k: string) => {
const v = form.get(k);
return typeof v === 'string' && v.trim() ? v : undefined;
};
const payload: SharePayload = {
title: str('title'),
text: str('text'),
url: str('url'),
files: files.map((f) => ({ name: f.name, type: f.type, size: f.size })),
receivedAt: Date.now(),
};
const cache = await caches.open(SHARE_CACHE);
await Promise.all([...(await cache.keys()).map((k) => cache.delete(k))]);
await cache.put(
SHARE_PAYLOAD_KEY,
new Response(JSON.stringify(payload), { headers: { 'Content-Type': 'application/json' } }),
);
await Promise.all(
files.map((f, i) =>
cache.put(
shareFileKey(i),
new Response(f, {
headers: { 'Content-Type': f.type || 'application/octet-stream' },
}),
),
),
);
return Response.redirect(`${base}${SHARE_PAGE_PATH}`, 303);
}
/** The share sheet's title/text/url as one draft; title and url are dropped when the text already carries them. */
export const shareDraftText = (p: SharePayload): string => {
const text = p.text?.trim() ?? '';
const keep = (s: string | undefined) => !!s && s.trim() !== '' && !text.includes(s.trim());
return [keep(p.title) ? p.title : '', text, keep(p.url) ? p.url : ''].filter(Boolean).join('\n');
};
/** App side: read what the worker stashed (undefined when there is nothing). */
export async function readSharedPayload(): Promise<
{ payload: SharePayload; files: File[] } | undefined
> {
if (typeof caches === 'undefined') return undefined;
const cache = await caches.open(SHARE_CACHE);
const res = await cache.match(SHARE_PAYLOAD_KEY);
if (!res) return undefined;
const payload = (await res.json()) as SharePayload;
const files = await Promise.all(
payload.files.map(async (meta, i) => {
const r = await cache.match(shareFileKey(i));
const blob = r ? await r.blob() : new Blob();
return new File([blob], meta.name || `shared-${i + 1}`, {
type: meta.type || blob.type,
});
}),
);
return { payload, files };
}
export async function clearSharedPayload(): Promise<void> {
if (typeof caches === 'undefined') return;
await caches.delete(SHARE_CACHE);
}