mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user