mirror of
https://github.com/stablyai/orca.git
synced 2026-09-25 16: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>
149 lines
4.6 KiB
TypeScript
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: [] }
|
|
}
|