diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 5d95f542b..f9a388d66 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -464,7 +464,7 @@ export const adminApi = { pluginBrowse: () => apiClient.get('/admin/plugins/registry').then(r => r.data), pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data), pluginInstall: (id: string, version?: string) => apiClient.post('/admin/plugins/install', { id, version }).then(r => r.data), - pluginActivate: (id: string) => apiClient.post(`/admin/plugins/${id}/activate`).then(r => r.data), + pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data), pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), pluginUpdate: (id: string) => apiClient.post(`/admin/plugins/${id}/update`).then(r => r.data), pluginUninstall: (id: string, deleteData: boolean) => apiClient.post(`/admin/plugins/${id}/uninstall`, { deleteData }).then(r => r.data), diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 9da7ec5c0..f030adc83 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -157,7 +157,9 @@ export default function AdminPluginsPanel() { const [detailFor, setDetailFor] = useState(null) const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null) const [confirmUninstall, setConfirmUninstall] = useState(null) - const [consentUpdate, setConsentUpdate] = useState<{ plugin: PluginRow; version: string; newPermissions: string[]; newEgress: string[] } | null>(null) + // A QUEUE, not one slot: "Update All" can produce several re-consent prompts — + // each must be shown, not silently overwritten by the last one. + const [consentQueue, setConsentQueue] = useState>([]) const [menu, setMenu] = useState(null) // Toolbar state. @@ -209,12 +211,32 @@ export default function AdminPluginsPanel() { const restart = (id: string) => act(id, async () => { await adminApi.pluginDeactivate(id); await adminApi.pluginActivate(id) }, t('admin.plugins.restarted')) const installedIds = new Set(plugins.map(p => p.id)) + // Enable/disable a plugin. Re-enabling one whose update widened its permissions + // must NOT grant them silently — the server answers 409 CONSENT_REQUIRED and we + // route through the same consent dialog the Update button uses. + const toggle = (p: PluginRow) => { + if (busy === p.id) return + if (p.enabled === 1) { void act(p.id, () => adminApi.pluginDeactivate(p.id), t('admin.plugins.deactivated')); return } + setBusy(p.id); setMenu(null) + adminApi.pluginActivate(p.id) + .then(() => toast.success(t('admin.plugins.activated'))) + .catch((e: { response?: { status?: number; data?: { code?: string; error?: string; newPermissions?: string[]; newEgress?: string[] } } }) => { + const d = e?.response?.data + if (e?.response?.status === 409 && d?.code === 'CONSENT_REQUIRED') { + setConsentQueue(qq => [...qq, { plugin: p, version: latest[p.id] ?? p.version ?? '', newPermissions: d.newPermissions ?? [], newEgress: d.newEgress ?? [] }]) + } else { + toast.error(d?.error || t('admin.plugins.actionError')) + } + }) + .finally(() => { setBusy(null); refresh() }) + } + const runUpdate = (p: PluginRow) => { setBusy(p.id); setMenu(null) adminApi.pluginUpdate(p.id) .then((r: { version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }) => { if (r.activated || (r.newPermissions.length === 0 && r.newEgress.length === 0)) toast.success(t('admin.plugins.updated')) - else setConsentUpdate({ plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }) + else setConsentQueue(qq => [...qq, { plugin: p, version: r.version, newPermissions: r.newPermissions, newEgress: r.newEgress }]) }) .catch(e => toast.error((e as { response?: { data?: { error?: string } } })?.response?.data?.error || t('admin.plugins.actionError'))) .finally(() => { setBusy(null); refresh() }) @@ -380,8 +402,7 @@ export default function AdminPluginsPanel() { ) : shownInstalled.map(p => ( busy !== p.id && act(p.id, () => p.enabled === 1 ? adminApi.pluginDeactivate(p.id) : adminApi.pluginActivate(p.id), - p.enabled === 1 ? t('admin.plugins.deactivated') : t('admin.plugins.activated'))} + onToggle={() => toggle(p)} onUpdate={() => runUpdate(p)} onRestart={() => restart(p.id)} onErrors={() => openErrors(p.id)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> ))} @@ -430,14 +451,15 @@ export default function AdminPluginsPanel() { message={t('admin.plugins.uninstallBody')} /> - {consentUpdate && ( + {consentQueue[0] && ( { - const c = consentUpdate; setConsentUpdate(null) - await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id), t('admin.plugins.updated')) + const c = consentQueue[0]; setConsentQueue(qq => qq.slice(1)) + // consent:true — the ONLY path that may widen a plugin's granted rights. + await act(c.plugin.id, () => adminApi.pluginActivate(c.plugin.id, true), t('admin.plugins.updated')) }} - onLater={() => { setConsentUpdate(null); toast.success(t('admin.plugins.updateKeptOff')) }} + onLater={() => { setConsentQueue(qq => qq.slice(1)); toast.success(t('admin.plugins.updateKeptOff')) }} /> )} diff --git a/client/src/components/Plugins/PluginFrame.tsx b/client/src/components/Plugins/PluginFrame.tsx index 4be5a32a4..38cdd957e 100644 --- a/client/src/components/Plugins/PluginFrame.tsx +++ b/client/src/components/Plugins/PluginFrame.tsx @@ -35,6 +35,11 @@ type Inbound = export default function PluginFrame({ pluginId, tripId = null, className, title }: PluginFrameProps) { const frameRef = useRef(null) + // A sandboxed frame may navigate ITSELF (connect-src can't stop that). If the + // plugin's document navigates away, its window identity still matches our + // iframe, so we'd keep answering trek:invoke/context to the new (possibly + // attacker) origin. Track loads: after the initial document, refuse the bridge. + const loadsRef = useRef(0) const { locale } = useTranslation() const navigate = useNavigate() const toast = useToast() @@ -59,6 +64,9 @@ export default function PluginFrame({ pluginId, tripId = null, className, title const onMessage = async (ev: MessageEvent) => { // The ONLY trusted identity: the message came from OUR iframe's window. if (ev.source !== frame.contentWindow) return + // …AND that window still holds the original plugin document (loaded once). + // A 2nd load means the frame navigated elsewhere — stop bridging to it. + if (loadsRef.current > 1) return const msg = ev.data as Inbound if (!msg || typeof msg !== 'object') return @@ -104,6 +112,7 @@ export default function PluginFrame({ pluginId, tripId = null, className, title