fix(code-runtime): reject forged container prototypes
This commit is contained in:
@@ -23,7 +23,7 @@ Every field is validated and defaulted; `maxOutputBytes` is a safe integer of at
|
||||
- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and validates every binding resolution and completion as lossless JSON. Forged `log`/`done` messages cannot bypass the outer cap: the host repeats validation and accounts every admitted log plus the completion or diagnostic. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys.
|
||||
- **Binding rejection classes are request data** — an optional namespace descriptor names the constructor global and the own property that receives the failed member name. The worker materializes and injects that real class, so `instanceof` works without hardcoding `tools` or `ToolCallError`; declarations with invalid or colliding globals fail before a worker spawns.
|
||||
- **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`).
|
||||
- **Intermediate binding values are complete JSON** — binding arguments and resolutions undergo iterative lossless-JSON validation, flatten into a bounded-depth pre-order wire value for structured clone, and rebuild iteratively on the other side. They have no byte, JavaScript call-stack, or nested structured-clone depth cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits.
|
||||
- **Intermediate binding values are complete JSON** — binding arguments and resolutions undergo iterative lossless-JSON validation, including a native-constructor identity check captured before program execution so user-authored functions cannot impersonate plain-container prototypes. Values flatten into a bounded-depth pre-order wire value for structured clone and rebuild iteratively on the other side. They have no byte, JavaScript call-stack, or nested structured-clone depth cap. They never enter the outer-output ledger or model context; provider/executor acquisition bounds and process/worker memory remain the limits.
|
||||
- **Logs stream eagerly into one outer ledger** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. The worker charges exact JSON-string bytes and preflights completion values and exception diagnostics against the remaining combined budget before posting them; a thrown million-byte stack therefore becomes the fixed `output-limit` diagnostic at the worker boundary. Native writes that bypass the patched stream slots arrive on pipes independent of the completion port, so the host repeats the ledger for those bytes and hostile forged traffic; settlement continues bounded pipe capture until worker termination completes before materializing the result. `maxOutputBytes` accounts the JSON serialization of the outer `logs` array plus the completion value or failure-message payload; fixed `CodeRunResult` field names, braces, the bounded error-kind tag, and later presentation whitespace are outside that variable-payload ledger. At or below the cap the exact value returns; a lossy completion is `invalid-output`, and a combined overflow is `output-limit` rather than a substituted inspected string. The failure retains the fitting captured prefix and later follows the normal outer `run_code` spill policy.
|
||||
- **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags.
|
||||
- **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving.
|
||||
|
||||
@@ -3,13 +3,27 @@
|
||||
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
|
||||
|
||||
/* jscpd:ignore-start -- the source worker mirrors session JSON helpers without workspace runtime imports */
|
||||
/** Whether a realm-owned intrinsic prototype names and points back to its constructor. */
|
||||
type IntrinsicCallable = (this: unknown, ...args: unknown[]) => unknown
|
||||
|
||||
const intrinsicFunctionToString = Reflect.get(Function.prototype, 'toString') as IntrinsicCallable
|
||||
const intrinsicReflectApply = Reflect.get(Reflect, 'apply') as (
|
||||
target: IntrinsicCallable,
|
||||
thisArgument: unknown,
|
||||
argumentsList: readonly unknown[],
|
||||
) => unknown
|
||||
|
||||
/** Whether a realm-owned intrinsic prototype is backed by its native constructor. */
|
||||
function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
|
||||
const constructor: unknown = descriptor?.value
|
||||
return typeof constructor === 'function'
|
||||
&& constructor.name === name
|
||||
&& constructor.prototype === prototype
|
||||
if (typeof constructor !== 'function') return false
|
||||
try {
|
||||
return constructor.name === name
|
||||
&& constructor.prototype === prototype
|
||||
&& intrinsicReflectApply(intrinsicFunctionToString, constructor, []) === `function ${name}() { [native code] }`
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a candidate is one realm's intrinsic `Object.prototype`. */
|
||||
|
||||
@@ -623,6 +623,40 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('rejects intrinsic-looking exotic objects as arguments and completions', async () => {
|
||||
const { runtime } = await setup()
|
||||
let calls = 0
|
||||
const forgeObject = `
|
||||
const prototype = Object.create(null);
|
||||
const SpoofedObject = function Object() {};
|
||||
SpoofedObject.prototype = prototype;
|
||||
Object.defineProperty(prototype, 'constructor', { value: SpoofedObject });
|
||||
const forged = Object.assign(Object.create(prototype), { value: 1 });
|
||||
Function.prototype.toString = () => 'function Object() { [native code] }';
|
||||
`
|
||||
const argument = await runtime.run({
|
||||
program: `${forgeObject}
|
||||
try { await tools.never(forged) } catch (error) {
|
||||
return { typed: error instanceof ToolCallError, name: error.name, toolName: error.toolName, message: error.message };
|
||||
}
|
||||
`,
|
||||
bindings: tools({ never: async () => { calls += 1; return null } }),
|
||||
})
|
||||
expect(calls).toBe(0)
|
||||
expect(argument.value).toEqual({
|
||||
typed: true,
|
||||
name: 'ToolCallError',
|
||||
toolName: 'never',
|
||||
message: 'binding arguments must be lossless JSON',
|
||||
})
|
||||
|
||||
const completion = await runtime.run({ program: `${forgeObject}\nreturn forged`, bindings: [] })
|
||||
expect(completion).toEqual({
|
||||
logs: [],
|
||||
error: { kind: 'invalid-output', message: 'program completion must be lossless JSON' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects forged lossy binding arguments again at the host boundary', async () => {
|
||||
const { runtime } = await setup()
|
||||
let calls = 0
|
||||
|
||||
@@ -97,6 +97,19 @@ describe('snapshotCodeJsonValue', () => {
|
||||
Object.setPrototypeOf(forgedPrototype, null)
|
||||
const forgedArray = [1]
|
||||
Object.setPrototypeOf(forgedArray, forgedPrototype)
|
||||
const spoofedObjectPrototype = Object.create(null) as Record<string, unknown>
|
||||
const SpoofedObject = function Object() {}
|
||||
SpoofedObject.prototype = spoofedObjectPrototype
|
||||
Object.defineProperty(spoofedObjectPrototype, 'constructor', { value: SpoofedObject })
|
||||
const spoofedObject = Object.create(spoofedObjectPrototype) as Record<string, unknown>
|
||||
spoofedObject.value = 1
|
||||
const spoofedArrayPrototype: unknown[] = []
|
||||
Object.setPrototypeOf(spoofedArrayPrototype, Object.prototype)
|
||||
const SpoofedArray = function Array() {}
|
||||
SpoofedArray.prototype = spoofedArrayPrototype
|
||||
Object.defineProperty(spoofedArrayPrototype, 'constructor', { value: SpoofedArray })
|
||||
const spoofedArray = [1]
|
||||
Object.setPrototypeOf(spoofedArray, spoofedArrayPrototype)
|
||||
|
||||
for (const value of [
|
||||
new ExoticObject(),
|
||||
@@ -110,11 +123,15 @@ describe('snapshotCodeJsonValue', () => {
|
||||
symbolObject,
|
||||
customPrototypeObject,
|
||||
forgedArray,
|
||||
spoofedObject,
|
||||
spoofedArray,
|
||||
cyclic,
|
||||
[undefined],
|
||||
{ value: undefined },
|
||||
]) {
|
||||
expect(snapshotCodeJsonValue(value)).toBeUndefined()
|
||||
const canonical = snapshotJsonValue(value)
|
||||
expect(canonical).toBeUndefined()
|
||||
expect(snapshotCodeJsonValue(value)).toEqual(canonical)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user