fix(plugins): close four trust-boundary holes in the plugin system (#11232)

* fix(plugins): close trust-boundary holes in the plugin system

Move five security decisions to their chokepoints rather than leaving them
enumerated at individual call sites.

- Kill-list revocation reaches content packs: PluginContentPackRegistry now
  takes an isKilled predicate and intersects it with any caller-supplied
  approval, so a killed plugin's VM recipes can no longer reach
  spawn(..., { shell: true }) through either reconcile() call site.
- Bound kill-list generatedAt to a 24h future skew at the parse chokepoint.
  A far-future timestamp previously made every genuine later list look
  "older" and disabled revocation permanently, persisted across restarts.
- Protect the whole auto.components.settings.Plugin* translation subtree
  instead of an enumerated prefix list, so language packs cannot forge the
  consent provenance badge or rewrite install-error security copy.
- Resolve manifest panel icons by own-key only; "constructor"/"__proto__"
  previously yielded non-component prototype members that crashed the
  right sidebar to its error boundary.
- Give panel liveness frames a reserved control budget so a panel that
  saturates its action budget can still answer the watchdog.

Co-authored-by: Orca <help@stably.ai>

* fix(plugins): keep the kill-list future bound off the cache read path

The schema-level generatedAt bound re-judged the on-disk cache against the
device clock at every launch, so a client whose clock ran behind the last
genuine publication discarded its whole cached kill list and started with
zero revocations. Move the bound to the two fetch chokepoints instead.

Co-authored-by: Orca <help@stably.ai>

* fix(plugins): remove the reserved-lane starvation window and the revocation TOCTOU

Review follow-ups on the trust-boundary fixes:

- The reserved liveness lane had a per-window count equal to the ping
  interval, so a panel's own pong-shaped traffic could spend it and drop
  the next genuine reply — reintroducing the starvation the lane exists to
  prevent. The lane is now size-bounded only; rate stays bounded because
  every pong is also charged to the data budget.
- Only schema-valid pongs take the lane now, so near-miss pong-shaped junk
  cannot drain it. readPanelPongId replaces the zod parse on this
  guest-controlled path (a rejected safeParse allocates an issue list, ~90x
  the accepted-path cost) and is pinned to the schema by a parity test.
- Re-read the kill list inside approveAtomically: approvedKeys is snapshotted
  before an awaited verification phase, so a plugin killed during that wait
  could still publish VM recipes and language packs.
- Assert the curated icon resolves to FileText; the old equality also passed
  when both sides fell back to Plug.

Co-authored-by: Orca <help@stably.ai>

* fix(plugins): match zod's safe-integer bound in the pong reader

readPanelPongId used Number.isInteger, but zod's .int() rejects anything
above 2**53-1, so pingIds like 1e100 took the reserved lane the schema
would have refused. The parity test never probed that boundary.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-07-28 17:49:20 -07:00
committed by GitHub
co-authored by Orca
parent 3a67186623
commit 5c59c84c7a
17 changed files with 527 additions and 38 deletions
@@ -1,6 +1,7 @@
import { FileText, Plug } from 'lucide-react'
import { describe, expect, it } from 'vitest'
import type { ActivePluginPanel } from '@/store/plugin-panels'
import { getPluginPanelActivityItems } from './plugin-panel-activity-items'
import { getPluginPanelActivityItems, resolvePluginPanelIcon } from './plugin-panel-activity-items'
const panel: ActivePluginPanel = {
id: 'dashboard',
@@ -10,6 +11,30 @@ const panel: ActivePluginPanel = {
pluginName: 'Demo'
}
describe('resolvePluginPanelIcon', () => {
it('resolves a curated icon name in both lucide naming styles', () => {
const dashed = resolvePluginPanelIcon('file-text')
// Without this, the equality below also passes when both sides fall back.
expect(dashed).toBe(FileText)
expect(resolvePluginPanelIcon('FileText')).toBe(dashed)
})
it.each(['constructor', '__proto__', 'toString', 'hasOwnProperty'])(
'falls back to Plug for the prototype member %s',
(iconName) => {
expect(resolvePluginPanelIcon(iconName)).toBe(Plug)
}
)
it('keeps a hostile manifest icon renderable by the activity bar', () => {
// Object / Object.prototype are not valid React element types: rendering
// either throws past the right-sidebar boundary and blanks the whole rail.
const item = getPluginPanelActivityItems([{ ...panel, icon: 'constructor' }])[0]!
expect(item.icon).not.toBe(Object)
expect(item.icon).toBe(Plug)
})
})
describe('getPluginPanelActivityItems', () => {
it('projects watchdog failure into host-owned activity chrome', () => {
expect(
@@ -70,7 +70,11 @@ export function resolvePluginPanelIcon(iconName: string | undefined): PluginPane
}
// Accept both lucide naming styles ('file-text' and 'FileText').
const normalized = iconName.replaceAll('-', '').toLowerCase()
return PLUGIN_PANEL_ICONS[normalized] ?? Plug
// Own-key only: a manifest icon named `constructor` must not resolve to an
// inherited member and crash the sidebar with a non-component "icon".
return Object.hasOwn(PLUGIN_PANEL_ICONS, normalized)
? (PLUGIN_PANEL_ICONS[normalized] ?? Plug)
: Plug
}
/** Maps active plugin panel contributions onto right-sidebar activity items. */
@@ -223,26 +223,52 @@ describe('createPanelBridgeMessageHandler', () => {
expect(panelWindow.postMessage).not.toHaveBeenCalled()
})
it('charges pong and invalid guest traffic before parsing either message', () => {
it('charges every guest frame, pongs included, to the data budget', () => {
const panelWindow = createFakePanelWindow()
const admit = vi.fn<PanelMessageBudget['admit']>().mockReturnValue(null)
const controlAdmit = vi.fn<PanelMessageBudget['admit']>().mockReturnValue(null)
const onPong = vi.fn()
const handler = createPanelBridgeMessageHandler({
sessionToken: SESSION_TOKEN,
getPanelWindow: () => panelWindow,
callPanelAction: vi.fn(),
onPong,
budget: { maxBytes: 1024, admit }
budget: { maxBytes: 1024, admit },
controlBudget: { maxBytes: 1024, admit: controlAdmit }
})
handler(messageEvent({ type: 'invalid-hostile-message' }, panelWindow))
handler(messageEvent({ type: 'orca-panel-pong', pingId: 7 }, panelWindow))
// The pong spends data budget too, so the reserved lane grants liveness
// without also granting a free channel for unmetered host work.
expect(admit).toHaveBeenCalledTimes(2)
expect(controlAdmit).toHaveBeenCalledTimes(1)
expect(onPong).toHaveBeenCalledWith(7)
})
it('does not accept a pong refused by the rate budget', () => {
it('charges a near-miss pong to the data budget only, sparing the reserved lane', () => {
const panelWindow = createFakePanelWindow()
const admit = vi.fn<PanelMessageBudget['admit']>().mockReturnValue(null)
const controlAdmit = vi.fn<PanelMessageBudget['admit']>().mockReturnValue(null)
const handler = createPanelBridgeMessageHandler({
sessionToken: SESSION_TOKEN,
getPanelWindow: () => panelWindow,
callPanelAction: vi.fn(),
onPong: vi.fn(),
budget: { maxBytes: 1024, admit },
controlBudget: { maxBytes: 1024, admit: controlAdmit }
})
handler(messageEvent({ type: 'orca-panel-pong', pingId: -1 }, panelWindow))
handler(messageEvent({ type: 'orca-panel-pong', pingId: 'seven' }, panelWindow))
handler(messageEvent({ type: 'orca-panel-pong' }, panelWindow))
expect(admit).toHaveBeenCalledTimes(3)
expect(controlAdmit).not.toHaveBeenCalled()
})
it('keeps answering the watchdog while the data budget is saturated', () => {
const panelWindow = createFakePanelWindow()
const onPong = vi.fn()
const handler = createPanelBridgeMessageHandler({
@@ -255,6 +281,50 @@ describe('createPanelBridgeMessageHandler', () => {
handler(messageEvent({ type: 'orca-panel-pong', pingId: 7 }, panelWindow))
expect(onPong).toHaveBeenCalledWith(7)
})
it('never lets a panel starve its own watchdog with self-sent pong traffic', () => {
const panelWindow = createFakePanelWindow()
const onPong = vi.fn()
let clock = 0
const handler = createPanelBridgeMessageHandler({
sessionToken: SESSION_TOKEN,
getPanelWindow: () => panelWindow,
callPanelAction: vi.fn(),
onPong,
now: () => clock
})
// A hostile panel floods unsolicited pongs, then the real watchdog reply
// for this window arrives. Any per-window count on the reserved lane would
// have been spent by the flood and would drop pingId 99.
for (let i = 0; i < 500; i += 1) {
handler(messageEvent({ type: 'orca-panel-pong', pingId: i }, panelWindow))
clock += 1
}
handler(messageEvent({ type: 'orca-panel-pong', pingId: 99 }, panelWindow))
expect(onPong).toHaveBeenLastCalledWith(99)
})
it('refuses an oversized frame on the reserved lane', () => {
const panelWindow = createFakePanelWindow()
const onPong = vi.fn()
const handler = createPanelBridgeMessageHandler({
sessionToken: SESSION_TOKEN,
getPanelWindow: () => panelWindow,
callPanelAction: vi.fn(),
onPong
})
handler(
messageEvent(
{ type: 'orca-panel-pong', pingId: 7, padding: 'x'.repeat(4 * 1024) },
panelWindow
)
)
expect(onPong).not.toHaveBeenCalled()
})
})
@@ -1,12 +1,14 @@
import {
PANEL_ACTION_RESULT_TYPE,
PANEL_CONTROL_MESSAGE_MAX_BYTES,
looksLikePanelActionRequest,
looksLikePanelPong,
parsePanelActionRequest,
readPanelPongId,
type PluginPanelActionOutcome,
type PluginPanelActionResultMessage
} from '../../../../shared/plugins/plugin-panel-bridge'
import {
createPanelControlMessageBudget,
createPanelMessageBudget,
structuredCloneMessageBytes,
type PanelMessageBudget
@@ -39,6 +41,8 @@ export type PanelBridgeHostOptions = {
onPong?: (pingId: number) => void
/** Injectable for tests; defaults to the shared per-plugin budget. */
budget?: PanelMessageBudget
/** Reserved liveness budget; defaults to the shared control-frame budget. */
controlBudget?: PanelMessageBudget
now?: () => number
}
@@ -65,6 +69,7 @@ export function createPanelBridgeMessageHandler(
options: PanelBridgeHostOptions
): (event: MessageEvent) => void {
const budget = options.budget ?? createPanelMessageBudget()
const controlBudget = options.controlBudget ?? createPanelControlMessageBudget()
const now = options.now ?? (() => Date.now())
return (event: MessageEvent): void => {
const panelWindow = options.getPanelWindow()
@@ -83,6 +88,28 @@ export function createPanelBridgeMessageHandler(
// concrete origin, so anything stricter would silently drop the reply.
requestingWindow.postMessage(message, '*')
}
// A valid pong is the one frame the host must never lose: it takes a
// reserved lane so a panel saturating its data budget can still prove it
// is alive. Only schema-valid pongs qualify, so near-miss pong-shaped junk
// cannot drain the lane the real reply needs — it falls through to the
// data budget below like any other malformed frame.
const pongId = readPanelPongId(event.data)
if (pongId !== null) {
const timestamp = now()
// One walk, capped at the smaller lane bound, serves both budgets: a
// pong above that cap is refused here anyway.
const pongBytes = structuredCloneMessageBytes(
event.data,
controlBudget.maxBytes ?? PANEL_CONTROL_MESSAGE_MAX_BYTES
)
// Charged to both: the data budget still meters this traffic, while a
// refusal there cannot by itself silence liveness.
budget.admit(timestamp, pongBytes)
if (!controlBudget.admit(timestamp, pongBytes)) {
options.onPong?.(pongId)
}
return
}
// Budgets run before parsing: a flood of malformed junk must not buy
// free schema-validation CPU either.
const refusal = budget.admit(now(), structuredCloneMessageBytes(event.data, budget.maxBytes))
@@ -111,10 +138,6 @@ export function createPanelBridgeMessageHandler(
}
return
}
if (looksLikePanelPong(event.data)) {
options.onPong?.((event.data as { pingId: number }).pingId)
return
}
if (!looksLikePanelActionRequest(event.data)) {
return
}