mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +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>
200 lines
5.2 KiB
TypeScript
200 lines
5.2 KiB
TypeScript
import {
|
|
PANEL_CONTROL_MESSAGE_MAX_BYTES,
|
|
PANEL_MESSAGE_MAX_BYTES,
|
|
PANEL_MESSAGE_RATE_LIMIT
|
|
} from './plugin-panel-bridge'
|
|
|
|
/**
|
|
* Per-plugin bridge budgets: message size cap and a sliding-window rate
|
|
* limit. Pure (caller supplies the clock) so both the renderer bridge host
|
|
* and tests exercise identical decisions.
|
|
*/
|
|
|
|
export type PanelMessageBudget = {
|
|
readonly maxBytes?: number
|
|
/** Returns null when the message may proceed, or a refusal reason. */
|
|
admit(now: number, messageBytes: number): 'oversized' | 'rate_limited' | null
|
|
}
|
|
|
|
export function createPanelMessageBudget(
|
|
limits: { maxBytes?: number; maxMessages?: number; perMs?: number } = {}
|
|
): PanelMessageBudget {
|
|
const maxBytes = limits.maxBytes ?? PANEL_MESSAGE_MAX_BYTES
|
|
const maxMessages = limits.maxMessages ?? PANEL_MESSAGE_RATE_LIMIT.maxMessages
|
|
const perMs = limits.perMs ?? PANEL_MESSAGE_RATE_LIMIT.perMs
|
|
const timestamps: number[] = []
|
|
return {
|
|
maxBytes,
|
|
admit(now, messageBytes) {
|
|
while (timestamps.length > 0 && timestamps[0]! <= now - perMs) {
|
|
timestamps.shift()
|
|
}
|
|
const rateLimited = timestamps.length >= maxMessages
|
|
// Oversized and malformed traffic still spends rate budget; otherwise
|
|
// it can force unbounded size-estimation work for free.
|
|
if (rateLimited) {
|
|
return 'rate_limited'
|
|
}
|
|
timestamps.push(now)
|
|
if (messageBytes > maxBytes) {
|
|
return 'oversized'
|
|
}
|
|
return null
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reserved liveness lane, size-bounded only. A per-window count here would be
|
|
* spent by the panel's own pongs and would then drop the next genuine reply —
|
|
* the exact starvation this lane exists to prevent. Rate is still bounded
|
|
* because the caller also charges every pong to the data budget.
|
|
*/
|
|
export function createPanelControlMessageBudget(): PanelMessageBudget {
|
|
return {
|
|
maxBytes: PANEL_CONTROL_MESSAGE_MAX_BYTES,
|
|
admit: (_now, messageBytes) =>
|
|
messageBytes > PANEL_CONTROL_MESSAGE_MAX_BYTES ? 'oversized' : null
|
|
}
|
|
}
|
|
|
|
const textEncoder = new TextEncoder()
|
|
|
|
function utf8Bytes(value: string, stopAfter: number): number {
|
|
// UTF-8 is never shorter than the JS code-unit count, so avoid allocating
|
|
// a large encoded copy once the cap is already proven exceeded.
|
|
if (value.length > stopAfter) {
|
|
return stopAfter + 1
|
|
}
|
|
return textEncoder.encode(value).byteLength
|
|
}
|
|
|
|
/**
|
|
* Bounded byte estimate for values accepted by structured clone. It counts
|
|
* strings as UTF-8 and binary backing stores by byteLength, handles cycles,
|
|
* and stops walking as soon as the host cap is exceeded.
|
|
*/
|
|
export function structuredCloneMessageBytes(
|
|
data: unknown,
|
|
stopAfter = PANEL_MESSAGE_MAX_BYTES
|
|
): number {
|
|
const seen = new WeakSet<object>()
|
|
let total = 0
|
|
let visitedNodes = 0
|
|
|
|
const add = (bytes: number): void => {
|
|
total = Math.min(stopAfter + 1, total + bytes)
|
|
}
|
|
|
|
const visit = (value: unknown, depth: number): void => {
|
|
if (total > stopAfter) {
|
|
return
|
|
}
|
|
if (value === null) {
|
|
add(1)
|
|
return
|
|
}
|
|
switch (typeof value) {
|
|
case 'undefined':
|
|
case 'boolean':
|
|
add(1)
|
|
return
|
|
case 'number':
|
|
add(8)
|
|
return
|
|
case 'bigint':
|
|
add(utf8Bytes(value.toString(), stopAfter - total))
|
|
return
|
|
case 'string':
|
|
add(utf8Bytes(value, stopAfter - total))
|
|
return
|
|
case 'symbol':
|
|
case 'function':
|
|
total = stopAfter + 1
|
|
return
|
|
case 'object':
|
|
break
|
|
}
|
|
const object = value as object
|
|
if (seen.has(object)) {
|
|
add(8)
|
|
return
|
|
}
|
|
seen.add(object)
|
|
visitedNodes += 1
|
|
if (visitedNodes > 10_000 || depth > 100) {
|
|
total = stopAfter + 1
|
|
return
|
|
}
|
|
|
|
if (object instanceof ArrayBuffer) {
|
|
add(object.byteLength)
|
|
return
|
|
}
|
|
if (typeof SharedArrayBuffer !== 'undefined' && object instanceof SharedArrayBuffer) {
|
|
add(object.byteLength)
|
|
return
|
|
}
|
|
if (ArrayBuffer.isView(object)) {
|
|
add(16)
|
|
visit(object.buffer, depth + 1)
|
|
return
|
|
}
|
|
if (typeof Blob !== 'undefined' && object instanceof Blob) {
|
|
add(object.size)
|
|
return
|
|
}
|
|
if (object instanceof Date) {
|
|
add(8)
|
|
return
|
|
}
|
|
if (object instanceof RegExp) {
|
|
visit(object.source, depth + 1)
|
|
visit(object.flags, depth + 1)
|
|
return
|
|
}
|
|
if (object instanceof Map) {
|
|
add(8)
|
|
for (const [key, entry] of object) {
|
|
add(4)
|
|
visit(key, depth + 1)
|
|
visit(entry, depth + 1)
|
|
}
|
|
return
|
|
}
|
|
if (object instanceof Set) {
|
|
add(8)
|
|
for (const entry of object) {
|
|
add(4)
|
|
visit(entry, depth + 1)
|
|
}
|
|
return
|
|
}
|
|
if (Array.isArray(object)) {
|
|
add(8)
|
|
for (const entry of object) {
|
|
add(4)
|
|
visit(entry, depth + 1)
|
|
}
|
|
return
|
|
}
|
|
try {
|
|
const prototype = Object.getPrototypeOf(object)
|
|
if (prototype !== Object.prototype && prototype !== null) {
|
|
total = stopAfter + 1
|
|
return
|
|
}
|
|
for (const key of Object.keys(object)) {
|
|
add(4)
|
|
add(utf8Bytes(key, stopAfter - total))
|
|
visit((object as Record<string, unknown>)[key], depth + 1)
|
|
}
|
|
} catch {
|
|
total = stopAfter + 1
|
|
}
|
|
}
|
|
|
|
visit(data, 0)
|
|
return total
|
|
}
|