mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 08:02:38 +00:00
* 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>
159 lines
6.0 KiB
TypeScript
159 lines
6.0 KiB
TypeScript
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type { PluginKillList } from '../../shared/plugins/plugin-kill-list'
|
|
import { fetchPluginKillList, PluginKillListService } from './plugin-kill-list-service'
|
|
import type { PluginKillListStore } from './plugin-kill-list-store'
|
|
|
|
const roots: string[] = []
|
|
|
|
async function tempRoot(): Promise<string> {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-plugin-kill-list-'))
|
|
roots.push(root)
|
|
return root
|
|
}
|
|
|
|
function killList(date = '2026-07-12T20:00:00Z'): PluginKillList {
|
|
return {
|
|
version: 1,
|
|
generatedAt: date,
|
|
plugins: [{ pluginKey: 'community.unsafe', reason: 'Malware advisory' }]
|
|
}
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.useRealTimers()
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
|
})
|
|
|
|
describe('PluginKillListService', () => {
|
|
it('loads cached revocations before any network refresh', async () => {
|
|
const root = await tempRoot()
|
|
const first = new PluginKillListService({
|
|
pluginsDataDir: root,
|
|
fetcher: async () => killList()
|
|
})
|
|
await first.refresh()
|
|
const fetcher = vi.fn(async () => killList())
|
|
const restarted = new PluginKillListService({ pluginsDataDir: root, fetcher })
|
|
|
|
await restarted.initialize()
|
|
|
|
expect(restarted.reason('community.unsafe')).toBe('Malware advisory')
|
|
expect(fetcher).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('publishes valid refreshes and notifies runtime reconciliation', async () => {
|
|
const service = new PluginKillListService({
|
|
pluginsDataDir: await tempRoot(),
|
|
fetcher: async () => killList()
|
|
})
|
|
const changed = vi.fn()
|
|
service.onChanged(changed)
|
|
|
|
await service.refresh()
|
|
|
|
expect(service.find('community.unsafe')).toMatchObject({ reason: 'Malware advisory' })
|
|
expect(changed).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('starts with no revocations after a corrupt cache and accepts a valid refresh', async () => {
|
|
const store = {
|
|
read: vi.fn().mockRejectedValue(new Error('invalid JSON')),
|
|
write: vi.fn().mockResolvedValue(undefined)
|
|
} as unknown as PluginKillListStore
|
|
const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
const service = new PluginKillListService({
|
|
pluginsDataDir: await tempRoot(),
|
|
store,
|
|
fetcher: async () => killList()
|
|
})
|
|
|
|
await expect(service.initialize()).resolves.toBeUndefined()
|
|
expect(service.snapshot()).toBeNull()
|
|
await expect(service.refresh()).resolves.toEqual(killList())
|
|
expect(service.reason('community.unsafe')).toBe('Malware advisory')
|
|
expect(warning).toHaveBeenCalledWith(
|
|
'[plugins] ignoring invalid cached plugin safety list:',
|
|
expect.any(Error)
|
|
)
|
|
})
|
|
|
|
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>>()
|
|
.mockResolvedValueOnce(killList('2026-07-12T20:00:00Z'))
|
|
.mockResolvedValueOnce(killList('2026-07-11T20:00:00Z'))
|
|
const service = new PluginKillListService({ pluginsDataDir: await tempRoot(), fetcher })
|
|
await service.refresh()
|
|
|
|
await expect(service.refresh()).rejects.toThrow('older snapshot')
|
|
expect(service.snapshot()?.generatedAt).toBe('2026-07-12T20:00:00Z')
|
|
})
|
|
})
|
|
|
|
describe('fetchPluginKillList', () => {
|
|
it('validates a bounded HTTPS response body', async () => {
|
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(
|
|
new Response(JSON.stringify(killList()), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' }
|
|
})
|
|
)
|
|
|
|
await expect(fetchPluginKillList(fetcher)).resolves.toEqual(killList())
|
|
})
|
|
|
|
it('rejects non-success responses', async () => {
|
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response('no', { status: 503 }))
|
|
await expect(fetchPluginKillList(fetcher)).rejects.toThrow('HTTP 503')
|
|
})
|
|
})
|