mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +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>
109 lines
3.0 KiB
TypeScript
109 lines
3.0 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import {
|
|
PLUGIN_KILL_LIST_ENTRY_LIMIT,
|
|
PLUGIN_KILL_LIST_FUTURE_SKEW_MS,
|
|
findKilledPlugin,
|
|
isPluginKillListTooFarInFuture,
|
|
killedPluginKeys,
|
|
pluginKillListSchema,
|
|
type PluginKillList
|
|
} from './plugin-kill-list'
|
|
|
|
function entry(pluginKey = 'community.unsafe'): Record<string, unknown> {
|
|
return {
|
|
pluginKey,
|
|
reason: 'Known malicious release',
|
|
advisoryUrl: 'https://orca.example/security/unsafe'
|
|
}
|
|
}
|
|
|
|
describe('pluginKillListSchema', () => {
|
|
it('parses a strict versioned revocation document', () => {
|
|
const parsed = pluginKillListSchema.parse({
|
|
version: 1,
|
|
generatedAt: '2026-07-12T20:00:00Z',
|
|
plugins: [entry()]
|
|
})
|
|
|
|
expect(findKilledPlugin(parsed, 'community.unsafe')).toEqual(entry())
|
|
expect(killedPluginKeys(parsed)).toEqual(new Set(['community.unsafe']))
|
|
})
|
|
|
|
it.each([
|
|
{
|
|
version: 1,
|
|
generatedAt: '2026-07-12T20:00:00Z',
|
|
plugins: [],
|
|
unexpected: true
|
|
},
|
|
{
|
|
version: 1,
|
|
generatedAt: '2026-07-12T20:00:00Z',
|
|
plugins: [{ ...entry(), unexpected: true }]
|
|
},
|
|
{
|
|
version: 1,
|
|
generatedAt: 'not-a-date',
|
|
plugins: []
|
|
},
|
|
{
|
|
version: 1,
|
|
generatedAt: '2026-07-12T20:00:00Z',
|
|
plugins: [{ ...entry(), pluginKey: 'unsafe' }]
|
|
},
|
|
{
|
|
version: 1,
|
|
generatedAt: '2026-07-12T20:00:00Z',
|
|
plugins: [{ ...entry(), advisoryUrl: 'http://orca.example/advisory' }]
|
|
}
|
|
])('rejects malformed or untrusted fields', (killList) => {
|
|
expect(pluginKillListSchema.safeParse(killList).success).toBe(false)
|
|
})
|
|
|
|
it('rejects duplicate killed plugin identities', () => {
|
|
const parsed = pluginKillListSchema.safeParse({
|
|
version: 1,
|
|
generatedAt: '2026-07-12T20:00:00Z',
|
|
plugins: [entry(), entry()]
|
|
})
|
|
|
|
expect(parsed.success).toBe(false)
|
|
if (!parsed.success) {
|
|
expect(parsed.error.issues).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ message: 'duplicate killed plugin: community.unsafe' })
|
|
])
|
|
)
|
|
}
|
|
})
|
|
|
|
it('caps the revocation document size by entry count', () => {
|
|
const plugins = Array.from({ length: PLUGIN_KILL_LIST_ENTRY_LIMIT + 1 }, (_, index) =>
|
|
entry(`publisher.plugin-${index}`)
|
|
)
|
|
expect(
|
|
pluginKillListSchema.safeParse({
|
|
version: 1,
|
|
generatedAt: '2026-07-12T20:00:00Z',
|
|
plugins
|
|
}).success
|
|
).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)
|
|
})
|
|
})
|