Files
orca/src/main/plugins/plugin-kill-list-service.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

149 lines
4.6 KiB
TypeScript

import {
findKilledPlugin,
isPluginKillListTooFarInFuture,
pluginKillListSchema,
type PluginKillList,
type PluginKillListEntry
} from '../../shared/plugins/plugin-kill-list'
import { PluginKillListStore } from './plugin-kill-list-store'
export const PLUGIN_KILL_LIST_URL = 'https://onorca.dev/plugins/kill-list.json'
const PLUGIN_KILL_LIST_DOWNLOAD_LIMIT = 4 * 1024 * 1024
type PluginKillListFetcher = () => Promise<PluginKillList>
export class PluginKillListService {
private readonly store: PluginKillListStore
private readonly fetcher: PluginKillListFetcher
private readonly listeners = new Set<() => void>()
private currentList: PluginKillList | null = null
private loadPromise: Promise<void> | null = null
private refreshChain: Promise<PluginKillList> = Promise.resolve({
version: 1,
generatedAt: '1970-01-01T00:00:00Z',
plugins: []
})
constructor(options: {
pluginsDataDir: string
store?: PluginKillListStore
fetcher?: PluginKillListFetcher
}) {
this.store = options.store ?? new PluginKillListStore(options.pluginsDataDir)
this.fetcher = options.fetcher ?? (() => fetchPluginKillList())
}
async initialize(): Promise<void> {
this.loadPromise ??= this.store
.read()
.then((killList) => {
this.currentList = killList
})
.catch((error) => {
// Why: an unusable cache must not prevent Orca from starting; a valid
// network refresh can still restore runtime revocations this session.
console.warn('[plugins] ignoring invalid cached plugin safety list:', error)
this.currentList = null
})
await this.loadPromise
}
onChanged(listener: () => void): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
find(pluginKey: string): PluginKillListEntry | null {
return this.currentList ? findKilledPlugin(this.currentList, pluginKey) : null
}
reason(pluginKey: string): string | null {
return this.find(pluginKey)?.reason ?? null
}
snapshot(): PluginKillList | null {
return this.currentList
}
refresh(): Promise<PluginKillList> {
const refresh = this.refreshChain
.catch(() => this.currentList ?? emptyKillList())
.then(() => this.performRefresh())
this.refreshChain = refresh
return refresh
}
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)
) {
throw new Error('refusing to replace the plugin kill list with an older snapshot')
}
await this.store.write(fetched)
this.currentList = fetched
for (const listener of this.listeners) {
listener()
}
return fetched
}
}
export async function fetchPluginKillList(
fetcher: typeof fetch = fetch,
url = PLUGIN_KILL_LIST_URL
): Promise<PluginKillList> {
const response = await fetcher(url, { cache: 'no-store' })
if (!response.ok) {
throw new Error(`plugin kill-list request failed with HTTP ${response.status}`)
}
const declaredBytes = Number(response.headers.get('content-length') ?? '0')
if (Number.isFinite(declaredBytes) && declaredBytes > PLUGIN_KILL_LIST_DOWNLOAD_LIMIT) {
throw new Error('plugin kill-list response exceeds its size limit')
}
if (!response.body) {
throw new Error('plugin kill-list response has no body')
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let totalBytes = 0
while (true) {
const chunk = await reader.read()
if (chunk.done) {
break
}
totalBytes += chunk.value.byteLength
if (totalBytes > PLUGIN_KILL_LIST_DOWNLOAD_LIMIT) {
await reader.cancel()
throw new Error('plugin kill-list response exceeds its size limit')
}
chunks.push(chunk.value)
}
const bytes = new Uint8Array(totalBytes)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
try {
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)}`
)
}
}
function emptyKillList(): PluginKillList {
return { version: 1, generatedAt: '1970-01-01T00:00:00Z', plugins: [] }
}