From df8734f41267721c195191c2b44aca86c93af623 Mon Sep 17 00:00:00 2001 From: Jacob Zeitz Date: Fri, 30 Oct 2026 16:37:42 +0100 Subject: [PATCH] feat(plugins): input in a plugin frame counts as activity for the idle sign-out Input inside an iframe never reaches the host window, so the idle timeout signed users out while they worked in a plugin page, dialog or panel. The sandbox runtime now reports trusted input in its frame (the guard's own events, focus left out) through a MessagePort it hands the host with sandbox-ready, before any plugin code runs, at most once per 15 s. The host bridge passes it to a small notifier the session timeout guard counts like its own window's input, so it reaches the other tabs too. --- .../__tests__/session-timeout-guard.test.tsx | 27 +++++ components/session/session-timeout-guard.tsx | 16 ++- .../plugin-host-bridge-origin.test.ts | 52 +++++++++ .../plugin-sandbox-runtime-activity.test.ts | 101 ++++++++++++++++++ lib/plugin-sandbox/host-bridge.ts | 25 +++++ lib/plugin-sandbox/protocol.ts | 5 + lib/plugin-sandbox/runtime.tsx | 57 +++++++++- lib/session-policy/frame-activity.ts | 30 ++++++ 8 files changed, 305 insertions(+), 8 deletions(-) create mode 100644 lib/__tests__/plugin-sandbox-runtime-activity.test.ts create mode 100644 lib/session-policy/frame-activity.ts diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx index 90b040cd3..cad5348f1 100644 --- a/components/session/__tests__/session-timeout-guard.test.tsx +++ b/components/session/__tests__/session-timeout-guard.test.tsx @@ -9,6 +9,7 @@ import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; import { consumeSessionEnd } from '@/lib/session-policy/sign-out-reason'; import { LAST_ACTIVITY_KEY } from '@/lib/session-policy/activity-channel'; +import { reportFrameActivity } from '@/lib/session-policy/frame-activity'; /** BroadcastChannel between "tabs" of one test. */ class FakeBroadcastChannel { @@ -142,6 +143,32 @@ describe('session timeout guard', () => { expect(localStorage.getItem(LAST_ACTIVITY_KEY)).toBe(String(START + 10 * SECOND)); }); + it('counts input a plugin frame reports like input here, and tells the other tabs', async () => { + await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 })); + const otherTab = new FakeBroadcastChannel('bulwark-session-activity'); + const heard = vi.fn(); + otherTab.onmessage = (event) => heard((event.data as { at: number }).at); + + await advance(20 * SECOND); + act(() => { reportFrameActivity(); }); + expect(heard).toHaveBeenCalledWith(START + 20 * SECOND); + await advance(29 * SECOND); + expect(screen.queryByText('idle_title')).toBeNull(); + await advance(1 * SECOND); + expect(screen.getByText('idle_title')).toBeTruthy(); + }); + + it('does not end the idle warning on input a plugin frame reports', async () => { + await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 })); + + await advance(40 * SECOND); + expect(screen.getByText('idle_title')).toBeTruthy(); + act(() => { reportFrameActivity(); }); + expect(screen.getByText('idle_title')).toBeTruthy(); + await advance(20 * SECOND); + expect(logoutAll).toHaveBeenCalled(); + }); + it('saves the drafts, then signs every account out with the reason, at zero', async () => { await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 })); const order: string[] = []; diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx index f8a5e6006..08b0dd983 100644 --- a/components/session/session-timeout-guard.tsx +++ b/components/session/session-timeout-guard.tsx @@ -9,6 +9,7 @@ import type { PolicySubject } from "@/lib/admin/policy-scopes"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { flushComposers } from "@/lib/compose-flush-registry"; import { openActivityChannel, readLastActivity, type ActivityChannel } from "@/lib/session-policy/activity-channel"; +import { onFrameActivity } from "@/lib/session-policy/frame-activity"; import { NO_SESSION_LIMITS, sameLimits, @@ -28,10 +29,12 @@ const ACTIVITY_THROTTLE_MS = 1000; /** * Bubbling input events, caught on the way down. Scrolling and focus moves * inside the page don't count: the page scrolls and refocuses on its own. - * Input inside a frame (the office editor, plugin panels) never reaches this - * window and doesn't count either: a focused frame left alone would keep the - * session open forever. Users working there see the warning, which sits - * above every overlay, and choose to stay. + * Input inside a frame never reaches this window. Plugin frames report the + * same events from inside (lib/session-policy/frame-activity.ts); other + * frames (the office editor) don't count, and neither does a frame merely + * having the focus: a focused frame left alone would keep the session open + * forever. Users working there see the warning, which sits above every + * overlay, and choose to stay. */ const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel"] as const; @@ -189,7 +192,7 @@ export function SessionTimeoutGuard() { } }, [active]); - // Activity in this tab and the others. + // Activity in this tab (its plugin frames too) and the others. useEffect(() => { const channel = openActivityChannel((at) => timerRef.current?.remoteActivity(at)); channelRef.current = channel; @@ -214,11 +217,14 @@ export function SessionTimeoutGuard() { if (document.visibilityState === "visible") timerRef.current?.check(); }; for (const event of ACTIVITY_EVENTS) window.addEventListener(event, onActivity, { capture: true, passive: true }); + // Input in plugin frames, which never reaches this window. + const offFrames = onFrameActivity(onActivity); // The window coming back into focus, not focus moving between elements. window.addEventListener("focus", onActivity); document.addEventListener("visibilitychange", onVisibility); return () => { for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, onActivity, { capture: true }); + offFrames(); window.removeEventListener("focus", onActivity); document.removeEventListener("visibilitychange", onVisibility); channel.close(); diff --git a/lib/__tests__/plugin-host-bridge-origin.test.ts b/lib/__tests__/plugin-host-bridge-origin.test.ts index 92e86b232..e2c0ab252 100644 --- a/lib/__tests__/plugin-host-bridge-origin.test.ts +++ b/lib/__tests__/plugin-host-bridge-origin.test.ts @@ -7,6 +7,7 @@ vi.mock('../plugin-sandbox/host-api', () => ({ import { createBackgroundInstance, createSlotInstance } from '../plugin-sandbox/host-bridge'; import { dispatchApiCall } from '../plugin-sandbox/host-api'; +import { onFrameActivity } from '../session-policy/frame-activity'; function plugin(): InstalledPlugin { return { @@ -380,3 +381,54 @@ describe('calls before the background frame has booted', () => { expect(inst.closed).toBe(true); }); }); + +describe('input in a frame', () => { + /** sandbox-ready as the runtime posts it, with its activity port. */ + function ready(inst: { iframe: HTMLIFrameElement }, port: MessagePort): void { + const ev = new MessageEvent('message', { data: { type: 'sandbox-ready' }, origin: 'null', source: inst.iframe.contentWindow }); + Object.defineProperty(ev, 'ports', { value: [port] }); + window.dispatchEvent(ev); + } + const delivered = () => new Promise((r) => setTimeout(r, 20)); + const channels: MessageChannel[] = []; + afterEach(() => { + for (const c of channels.splice(0)) { c.port1.close(); c.port2.close(); } + }); + function channel(): MessageChannel { + const c = new MessageChannel(); + channels.push(c); + return c; + } + + it('counts for the idle timeout what the runtime reports through its port', async () => { + const heard = vi.fn(); + const off = onFrameActivity(heard); + try { + const inst = createSlotInstance({ + plugin: plugin(), slot: 'app-page', code: '', locale: 'en', tier: 'untrusted', extraProps: {}, + hostContainer: document.body, onResize: () => {}, + }); + instances.push(inst); + const runtime = channel(); + ready(inst, runtime.port2); + runtime.port1.postMessage(null); + await delivered(); + expect(heard).toHaveBeenCalledTimes(1); + + // A second sandbox-ready is not the runtime's: its port is ignored. + const forged = channel(); + ready(inst, forged.port2); + forged.port1.postMessage(null); + await delivered(); + expect(heard).toHaveBeenCalledTimes(1); + + // Nor does a frame that is gone count. + inst.destroy(); + runtime.port1.postMessage(null); + await delivered(); + expect(heard).toHaveBeenCalledTimes(1); + } finally { + off(); + } + }); +}); diff --git a/lib/__tests__/plugin-sandbox-runtime-activity.test.ts b/lib/__tests__/plugin-sandbox-runtime-activity.test.ts new file mode 100644 index 000000000..70e1ccceb --- /dev/null +++ b/lib/__tests__/plugin-sandbox-runtime-activity.test.ts @@ -0,0 +1,101 @@ +/** + * Input inside a plugin frame never reaches the host window, so the idle + * timeout signed users out while they worked in a plugin page or dialog. The + * runtime reports trusted input in its frame through a port it hands the + * host with 'sandbox-ready', before any plugin code runs. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup, fireEvent } from '@testing-library/react'; + +const HOST_ORIGIN = window.location.origin; + +let hostWindow: { postMessage: ReturnType }; +const realParent = Object.getOwnPropertyDescriptor(window, 'parent'); +const ports: MessagePort[] = []; + +beforeEach(() => { + // The runtime keeps its host binding and boot flag at module scope. + vi.resetModules(); + hostWindow = { postMessage: vi.fn() }; + Object.defineProperty(window, 'parent', { configurable: true, get: () => hostWindow as unknown as Window }); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + for (const port of ports.splice(0)) port.close(); + if (realParent) Object.defineProperty(window, 'parent', realParent); +}); + +/** + * Mount the runtime; returns the input listeners it put on the window and + * the reports that reach the host's end of the port. jsdom can't make a + * trusted event, so trusted input calls the listeners directly. + */ +async function mountRuntime() { + const added = new Map(); + const addEventListener = window.addEventListener.bind(window); + vi.spyOn(window, 'addEventListener').mockImplementation((type, listener, options) => { + if (type !== 'message') added.set(type, listener as EventListener); + addEventListener(type, listener, options); + }); + const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime'); + render(React.createElement(SandboxRuntime)); + + const [msg, target, transfer] = hostWindow.postMessage.mock.calls[0]; + expect(msg).toEqual({ type: 'sandbox-ready' }); + expect(target).toBe(HOST_ORIGIN); + const port = (transfer as MessagePort[])[0]; + ports.push(port); + const reports = vi.fn(); + port.onmessage = reports; + + const trusted = (type: string) => added.get(type)?.({ type, isTrusted: true } as unknown as Event); + return { added, reports, trusted }; +} + +const delivered = () => new Promise((resolve) => setTimeout(resolve, 20)); + +describe('plugin sandbox runtime activity reports', () => { + it('listens for the input the host window counts, and not for focus', async () => { + const { added } = await mountRuntime(); + expect([...added.keys()].sort()).toEqual(['keydown', 'pointerdown', 'pointermove', 'touchstart', 'wheel']); + }); + + it('reports trusted input to the host through the port', async () => { + const { reports, trusted } = await mountRuntime(); + trusted('keydown'); + await delivered(); + expect(reports).toHaveBeenCalledTimes(1); + // Nothing goes to the window a plugin can post to as well. + expect(hostWindow.postMessage).toHaveBeenCalledTimes(1); + }); + + it('reports at most once per ACTIVITY_REPORT_MS', async () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(1_000_000); + const { reports, trusted } = await mountRuntime(); + const { ACTIVITY_REPORT_MS } = await import('@/lib/plugin-sandbox/runtime'); + + trusted('pointerdown'); + now.mockReturnValue(1_000_000 + ACTIVITY_REPORT_MS - 1); + trusted('pointermove'); + trusted('wheel'); + await delivered(); + expect(reports).toHaveBeenCalledTimes(1); + + now.mockReturnValue(1_000_000 + ACTIVITY_REPORT_MS); + trusted('touchstart'); + await delivered(); + expect(reports).toHaveBeenCalledTimes(2); + }); + + it('does not report input a script dispatched, or focus', async () => { + const { reports } = await mountRuntime(); + fireEvent.keyDown(window, { key: 'a' }); + fireEvent.pointerDown(document.body); + fireEvent.focus(window); + await delivered(); + expect(reports).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 1d0e7d67c..8f1ec89cd 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -20,6 +20,7 @@ import { resolveDateLocale } from '../utils'; import { tablesFor } from './plugin-i18n'; import { getEffectiveTimeZone } from '../timezone'; import { getEffectiveLocale } from '@/i18n/detect-locale'; +import { reportFrameActivity } from '@/lib/session-policy/frame-activity'; import { useSettingsStore, type DateLocale } from '@/stores/settings-store'; import type { SandboxToHost, HostToSandbox, InitMsg, InitPayload, StreamChunkMsg, StreamEndMsg, StreamProgressMsg, @@ -298,6 +299,10 @@ export class SandboxInstance { private rejectInit!: (err: Error) => void; private listener: (ev: MessageEvent) => void; private destroyed = false; + /** The frame has said sandbox-ready. */ + private ready = false; + /** The runtime's activity reports (see ReadyMsg). */ + private activityPort: MessagePort | null = null; /** The frame has answered init-done: its hooks and slots are in place. */ private initialized = false; @@ -463,6 +468,11 @@ export class SandboxInstance { switch (msg.type) { case 'sandbox-ready': + // Only the first one is the runtime's, posted before any plugin code + // ran; the plugin could post another, with a port of its own. + if (this.ready) return; + this.ready = true; + this.listenForActivity(ev.ports?.[0]); this.resolveReady(); return; @@ -561,6 +571,19 @@ export class SandboxInstance { } } + /** + * Input in the frame counts for the idle timeout like input in the host + * window. The runtime throttles its reports; the session timeout guard + * throttles again and decides what counts. + */ + private listenForActivity(port: MessagePort | undefined): void { + if (!port) return; + this.activityPort = port; + port.onmessage = () => { + if (!this.destroyed) reportFrameActivity(); + }; + } + // ─── Public ───────────────────────────────────────────────── /** Dispatch a hook handler inside the sandbox; resolves with its return value. */ @@ -698,6 +721,8 @@ export class SandboxInstance { this.rejectInit(new Error('sandbox destroyed')); liveInstances.delete(this); window.removeEventListener('message', this.listener); + this.activityPort?.close(); + this.activityPort = null; this.iframe.remove(); for (const { reject } of this.pendingHookInvokes.values()) { reject(new Error('sandbox destroyed')); diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index c947991a9..979be4e36 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -107,6 +107,11 @@ export type InitPayload = BackgroundInit | SlotInit; // ─── Sandbox → Host messages ───────────────────────────────── +/** + * The runtime is listening. Carries in `event.ports[0]` the port the runtime + * reports user input in the frame through, for the host's idle timeout; a + * message on it means "the user did something here just now". + */ export interface ReadyMsg { type: 'sandbox-ready'; } export interface InitDoneMsg { diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index e857a6ecf..3f6030c5e 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -164,9 +164,57 @@ function applyHostFeatures(next: HostFeaturesView | undefined): void { const pendingCallbacks = new Map void; reject: (err: Error) => void }>(); const hookHandlers: Record unknown> = {}; -function sendToHost(msg: SandboxToHost): void { +function sendToHost(msg: SandboxToHost, transfer?: Transferable[]): void { if (!parentWindow || !parentOrigin) return; - parentWindow.postMessage(msg, parentOrigin); + if (transfer) parentWindow.postMessage(msg, parentOrigin, transfer); + else parentWindow.postMessage(msg, parentOrigin); +} + +// ─── Activity reports ──────────────────────────────────────── + +/** + * Input that counts as the user being here for the host's idle timeout: the + * host window's own list (components/session/session-timeout-guard.tsx), + * which input in this frame never reaches. Focus doesn't count there and + * doesn't here: a focused frame left alone would keep the session open. + */ +const ACTIVITY_EVENTS = ['pointerdown', 'pointermove', 'keydown', 'touchstart', 'wheel'] as const; + +/** + * At most one report this often. The idle limit is whole minutes, so a + * report up to 15 s late costs at most 15 s of it, while a user working in + * the frame sends no more than four messages a minute. + */ +export const ACTIVITY_REPORT_MS = 15_000; + +let reportActivity: (() => void) | null = null; +let lastActivityReport = -Infinity; + +function onUserInput(event: Event): void { + // A plugin can dispatch events, but not trusted ones. + if (!event.isTrusted || !reportActivity) return; + const at = Date.now(); + if (at - lastActivityReport < ACTIVITY_REPORT_MS) return; + lastActivityReport = at; + reportActivity(); +} + +/** + * The port reports go through, handed to the host with 'sandbox-ready', + * before any plugin code runs. The plugin shares this frame and can post to + * the host as the runtime does, but it never sees this port, and the host + * counts only what comes through it. `postMessage` is bound now, so a + * plugin patching MessagePort.prototype later doesn't get hold of the port. + * (A privileged, same-origin plugin can reach the host page anyway.) + */ +function openActivityPort(): MessagePort | null { + if (typeof MessageChannel === 'undefined') return null; + const channel = new MessageChannel(); + const post = channel.port1.postMessage.bind(channel.port1); + reportActivity = () => { + try { post(null); } catch { /* host gone */ } + }; + return channel.port2; } // ─── Host binding ──────────────────────────────────────────── @@ -1465,16 +1513,19 @@ export function startSandboxRuntime(): () => void { () => !!parentWindow, ); window.addEventListener('message', handleHostMessage); + for (const event of ACTIVITY_EVENTS) window.addEventListener(event, onUserInput, { capture: true, passive: true }); // Initial ping, targeted at the host origin so it can only land on the // page that framed us. Guard at module scope so React strict mode's // double-invoke doesn't re-post (and so a re-post can't race with the // parent's init reply). if (!readyPosted) { readyPosted = true; - sendToHost({ type: 'sandbox-ready' }); + const activityPort = openActivityPort(); + sendToHost({ type: 'sandbox-ready' }, activityPort ? [activityPort] : undefined); } return () => { window.removeEventListener('message', handleHostMessage); + for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, onUserInput, { capture: true }); }; } diff --git a/lib/session-policy/frame-activity.ts b/lib/session-policy/frame-activity.ts new file mode 100644 index 000000000..1e1581952 --- /dev/null +++ b/lib/session-policy/frame-activity.ts @@ -0,0 +1,30 @@ +/** + * User input inside plugin frames, for the idle timeout. Input in an iframe + * never reaches the host window, so the session timeout guard can't see it; + * the plugin sandbox runtime reports trusted input from its frame instead + * (lib/plugin-sandbox/runtime.tsx), the host bridge passes it on here, and + * the guard counts it like input in its own window. + */ + +type Listener = () => void; + +const listeners = new Set(); + +/** Someone used a plugin frame just now. */ +export function reportFrameActivity(): void { + for (const listener of listeners) { + try { + listener(); + } catch { + /* a listener's own error */ + } + } +} + +/** Calls `listener` on every report; returns the unsubscribe. */ +export function onFrameActivity(listener: Listener): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}