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.
This commit is contained in:
Jacob Zeitz
2026-10-30 16:37:42 +01:00
parent dee2c07ce9
commit df8734f412
8 changed files with 305 additions and 8 deletions
@@ -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[] = [];
+11 -5
View File
@@ -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();
@@ -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();
}
});
});
@@ -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<typeof vi.fn> };
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<string, EventListener>();
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();
});
});
+25
View File
@@ -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'));
+5
View File
@@ -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 {
+54 -3
View File
@@ -164,9 +164,57 @@ function applyHostFeatures(next: HostFeaturesView | undefined): void {
const pendingCallbacks = new Map<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
const hookHandlers: Record<string, (...args: unknown[]) => 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 });
};
}
+30
View File
@@ -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<Listener>();
/** 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);
};
}