mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +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>
64 lines
2.3 KiB
TypeScript
64 lines
2.3 KiB
TypeScript
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()
|
|
.url()
|
|
.max(2_048)
|
|
.refine((value) => new URL(value).protocol === 'https:', 'advisory URL must use HTTPS')
|
|
|
|
export const pluginKillListEntrySchema = z.strictObject({
|
|
pluginKey: z.string().refine(isQualifiedPluginKey, 'invalid qualified plugin key'),
|
|
reason: z.string().min(1).max(1_024),
|
|
advisoryUrl: advisoryUrlSchema.optional()
|
|
})
|
|
|
|
export const pluginKillListSchema = z
|
|
.strictObject({
|
|
version: z.literal(1),
|
|
generatedAt: z.string().datetime({ offset: true }),
|
|
plugins: z.array(pluginKillListEntrySchema).max(PLUGIN_KILL_LIST_ENTRY_LIMIT)
|
|
})
|
|
.superRefine((killList, context) => {
|
|
const seen = new Set<string>()
|
|
for (const [index, plugin] of killList.plugins.entries()) {
|
|
if (seen.has(plugin.pluginKey)) {
|
|
context.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['plugins', index, 'pluginKey'],
|
|
message: `duplicate killed plugin: ${plugin.pluginKey}`
|
|
})
|
|
}
|
|
seen.add(plugin.pluginKey)
|
|
}
|
|
})
|
|
|
|
export type PluginKillList = z.infer<typeof pluginKillListSchema>
|
|
export type PluginKillListEntry = z.infer<typeof pluginKillListEntrySchema>
|
|
|
|
/** 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<string> {
|
|
return new Set(killList.plugins.map((plugin) => plugin.pluginKey))
|
|
}
|
|
|
|
export function findKilledPlugin(
|
|
killList: PluginKillList,
|
|
pluginKey: string
|
|
): PluginKillListEntry | null {
|
|
return killList.plugins.find((plugin) => plugin.pluginKey === pluginKey) ?? null
|
|
}
|