mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
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:
@@ -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[] = [];
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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'));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user