diff --git a/.changeset/hardened-vm-serialization.md b/.changeset/hardened-vm-serialization.md new file mode 100644 index 0000000000..f375ae1edc --- /dev/null +++ b/.changeset/hardened-vm-serialization.md @@ -0,0 +1,6 @@ +--- +'@workflow/core': patch +'workflow': patch +--- + +Serializing values built inside the `node:vm` workflow VM no longer executes workflow code, using engine brand checks and host intrinsics captured at boot. diff --git a/packages/core/package.json b/packages/core/package.json index 7d7e0a978b..8ed4814a15 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -102,7 +102,7 @@ "@workflow/world-local": "workspace:*", "@workflow/world-vercel": "workspace:*", "debug": "4.4.3", - "devalue": "5.8.1", + "devalue": "5.9.0", "ms": "2.1.3", "nanoid": "5.1.6", "seedrandom": "3.0.5", diff --git a/packages/core/src/serialization.ts b/packages/core/src/serialization.ts index be7fc7bcb0..0257bf703a 100644 --- a/packages/core/src/serialization.ts +++ b/packages/core/src/serialization.ts @@ -62,6 +62,11 @@ import { isEncrypted, peekFormatPrefix, } from './serialization/format.js'; +import { + type GuestCodeStats, + isInstanceOfPrototype, + readProperty, +} from './serialization/hardened.js'; import { getClassReducers, getClassRevivers, @@ -233,6 +238,34 @@ async function recordCompression( } } +/** + * Emits OTel span attributes for workflow (guest) code executions that the + * hardened serializer could not avoid (getters, proxies, custom + * serializers). No-ops when serialization was fully side-effect free — + * the common case. Same never-break-the-data-path contract as + * `recordCompression` above. + */ +async function recordGuestCodeExecutions(stats: GuestCodeStats): Promise { + if (stats.executions.length === 0) return; + try { + const span = await getActiveSpan(); + if (!span) return; + const details = [ + ...new Set( + stats.executions.map((e) => + e.detail ? `${e.kind} (${e.detail})` : e.kind + ) + ), + ]; + span.setAttributes({ + ...Attr.SerializationGuestCodeExecutions(stats.executions.length), + ...Attr.SerializationGuestCodeDetails(details), + }); + } catch { + // ignore telemetry failures + } +} + export function getSerializeStream( reducers: Partial, cryptoKey: EncryptionKeyParam @@ -1568,15 +1601,23 @@ function getAllBaseReducers( // Request and Response reducers are mode-specific and added by // getExternalReducers / getWorkflowReducers / getStepReducers below. Request: (value) => { - if (!(value instanceof global.Request)) return false; + // Chain walk rather than `instanceof global.Request`: see the + // ReadableStream reducer in getWorkflowReducers for why. Reads go + // through descriptors so a getter cannot run unreported. + if ( + !isInstanceOfPrototype(value, getHostClassPrototype(global, 'Request')) + ) + return false; const data: SerializableSpecial['Request'] = { - method: value.method, - url: value.url, - headers: value.headers, - body: value.body, - duplex: value.duplex, + method: readProperty(value, 'method') as string, + url: readProperty(value, 'url') as string, + headers: readProperty(value, 'headers') as Headers, + body: readProperty(value, 'body') as ReadableStream | null, + duplex: readProperty(value, 'duplex') as 'half', }; - const responseWritable = value[WEBHOOK_RESPONSE_WRITABLE]; + const responseWritable = readProperty(value, WEBHOOK_RESPONSE_WRITABLE) as + | WritableStream + | undefined; if (responseWritable) { data.responseWritable = responseWritable; } @@ -1589,25 +1630,30 @@ function getAllBaseReducers( // Plain non-aborted native signals are intentionally dropped (would // mint stream infra for every Request, including the auto-generated // signal on `new Request(url)`). + const signal = readProperty(value, 'signal'); if ( - value.signal && - (value.signal.aborted || - (value.signal as AbortInternals)[ABORT_STREAM_NAME]) + signal && + (readProperty(signal, 'aborted') || + readProperty(signal, ABORT_STREAM_NAME)) ) { - data.signal = value.signal; + data.signal = signal as AbortSignal; } return data; }, Response: (value) => { - if (!(value instanceof global.Response)) return false; + // See the Request reducer above. + if ( + !isInstanceOfPrototype(value, getHostClassPrototype(global, 'Response')) + ) + return false; return { - type: value.type, - url: value.url, - status: value.status, - statusText: value.statusText, - headers: value.headers, - body: value.body, - redirected: value.redirected, + type: readProperty(value, 'type') as Response['type'], + url: readProperty(value, 'url') as string, + status: readProperty(value, 'status') as number, + statusText: readProperty(value, 'statusText') as string, + headers: readProperty(value, 'headers') as Headers, + body: readProperty(value, 'body') as ReadableStream | null, + redirected: readProperty(value, 'redirected') as boolean, }; }, }; @@ -1937,6 +1983,51 @@ export function getExternalReducers( * @param global * @returns */ +/** + * Prototypes used for brand-style identification in the reducers below. + * + * The stream and abort classes are host classes injected into the sandbox, so + * instances carry the host prototype in their chain and a chain walk + * identifies them without consulting `Symbol.hasInstance` on the sandbox + * class. `undefined` when the runtime lacks the class, in which case + * identification falls back to the infrastructure symbols alone. + */ +function getStreamPrototype( + global: Record, + kind: 'Readable' | 'Writable' +): object | undefined { + return getHostClassPrototype(global, `${kind}Stream`); +} + +/** + * The prototype of a host class that may also be injected into the sandbox. + * Prefers the sandbox binding (the same host class object in practice) and + * falls back to the host's own, so a chain walk identifies instances from + * either realm without consulting `Symbol.hasInstance`. + */ +function getHostClassPrototype( + global: Record, + name: string +): object | undefined { + const ctor = + global[name] ?? (globalThis as Record)[name] ?? undefined; + return typeof ctor === 'function' ? ctor.prototype : undefined; +} + +function getAbortControllerPrototype( + global: Record +): object | undefined { + const ctor = global.AbortController ?? globalThis.AbortController; + return typeof ctor === 'function' ? ctor.prototype : undefined; +} + +function getAbortSignalPrototype( + global: Record +): object | undefined { + const ctor = global.AbortSignal ?? globalThis.AbortSignal; + return typeof ctor === 'function' ? ctor.prototype : undefined; +} + export function getWorkflowReducers( global: Record = globalThis ): Partial { @@ -1946,31 +2037,48 @@ export function getWorkflowReducers( // Readable/Writable streams from within the workflow execution environment // are simply "handles" that can be passed around to other steps. ReadableStream: (value) => { - if (!(value instanceof global.ReadableStream)) return false; + // Walk the prototype chain instead of `instanceof global.ReadableStream`: + // the class is host-provided (injected into the sandbox), so its + // prototype is in the chain of both real streams and the + // `Object.create(ReadableStream.prototype)` handles used for request + // bodies — but a chain walk never consults `Symbol.hasInstance`, which + // the sandbox can define and which ran for every value the earlier + // reducers did not claim. Reads below go through descriptors so a + // getter on a step argument cannot run unreported. + if (!isInstanceOfPrototype(value, getStreamPrototype(global, 'Readable'))) + return false; // Check if this is a fake stream storing BodyInit from Request/Response constructor - const bodyInit = value[BODY_INIT_SYMBOL]; + const bodyInit = readProperty(value, BODY_INIT_SYMBOL); if (bodyInit !== undefined) { // This is a fake stream - serialize the BodyInit directly // devalue will handle serializing strings, Uint8Array, etc. return { bodyInit }; } - const name = value[STREAM_NAME_SYMBOL]; + const name = readProperty(value, STREAM_NAME_SYMBOL) as string; if (!name) { throw new WorkflowRuntimeError('ReadableStream `name` is not set'); } - const s: SerializableSpecial['ReadableStream'] = { name }; - const type = value[STREAM_TYPE_SYMBOL]; + const s: Extract< + SerializableSpecial['ReadableStream'], + { name: string } + > = { name }; + const type = readProperty(value, STREAM_TYPE_SYMBOL) as + | 'bytes' + | undefined; if (type) s.type = type; - const framing: ByteStreamFraming | undefined = - value[STREAM_FRAMING_SYMBOL]; + const framing = readProperty(value, STREAM_FRAMING_SYMBOL) as + | ByteStreamFraming + | undefined; if (framing) s.framing = framing; return s; }, WritableStream: (value) => { - if (!(value instanceof global.WritableStream)) return false; - const name = value[STREAM_NAME_SYMBOL]; + // See the ReadableStream reducer above for why this walks the chain. + if (!isInstanceOfPrototype(value, getStreamPrototype(global, 'Writable'))) + return false; + const name = readProperty(value, STREAM_NAME_SYMBOL) as string; if (!name) { throw new WorkflowRuntimeError('WritableStream `name` is not set'); } @@ -1978,13 +2086,19 @@ export function getWorkflowReducers( // When the handle was forwarded from another run (parent → child // via `start()`), preserve the foreign runId so the step-side // reviver opens the writable against the original stream. - const foreignRunId = value[STREAM_SERVER_RUN_ID_SYMBOL]; + const foreignRunId = readProperty(value, STREAM_SERVER_RUN_ID_SYMBOL); if (typeof foreignRunId === 'string') s.runId = foreignRunId; - const foreignDeploymentId = value[STREAM_SERVER_DEPLOYMENT_ID_SYMBOL]; + const foreignDeploymentId = readProperty( + value, + STREAM_SERVER_DEPLOYMENT_ID_SYMBOL + ); if (typeof foreignDeploymentId === 'string') { s.deploymentId = foreignDeploymentId; } - const foreignPublicKey = value[STREAM_SERVER_PUBLIC_KEY_SYMBOL]; + const foreignPublicKey = readProperty( + value, + STREAM_SERVER_PUBLIC_KEY_SYMBOL + ); if (typeof foreignPublicKey === 'string') { s.encryptionPublicKey = foreignPublicKey; } @@ -1996,26 +2110,42 @@ export function getWorkflowReducers( // is a plain object (not a class), so instanceof checks won't work for signals. // Detect instances by the presence of the ABORT_STREAM_NAME symbol instead. AbortController: (value) => { - if (!value || !value.signal) return false; + // `value.signal` was a bare read, so a `signal` getter on any object + // reaching this reducer executed unreported. Read through descriptors + // and gate on the infrastructure symbol / prototype first. + if (value === null || typeof value !== 'object') return false; const holder = value as AbortController & AbortHolder; - const hasAbortSymbol = - holder[ABORT_STREAM_NAME] ?? holder.signal?.[ABORT_STREAM_NAME]; - const isNativeAbortController = - global.AbortController && - typeof global.AbortController === 'function' && - value instanceof global.AbortController; - if (!hasAbortSymbol && !isNativeAbortController) return false; - return reduceAbortBySymbol(value.signal, holder); + const ownSymbol = readProperty(value, ABORT_STREAM_NAME); + const isNativeAbortController = isInstanceOfPrototype( + value, + getAbortControllerPrototype(global) + ); + if (ownSymbol === undefined && !isNativeAbortController) { + // Not ours and not a native controller — but a foreign controller + // may still carry the symbol on its signal. + const maybeSignal = readProperty(value, 'signal'); + if ( + maybeSignal === null || + typeof maybeSignal !== 'object' || + readProperty(maybeSignal, ABORT_STREAM_NAME) === undefined + ) { + return false; + } + } + const signal = readProperty(value, 'signal'); + if (!signal) return false; + return reduceAbortBySymbol(signal as AbortSignal, holder); }, AbortSignal: (value) => { - const signal = value as (AbortSignal & AbortInternals) | undefined; - const hasAbortSymbol = signal?.[ABORT_STREAM_NAME]; - const isNativeAbortSignal = - global.AbortSignal && - typeof global.AbortSignal === 'function' && - value instanceof global.AbortSignal; + if (value === null || typeof value !== 'object') return false; + const hasAbortSymbol = + readProperty(value, ABORT_STREAM_NAME) !== undefined; + const isNativeAbortSignal = isInstanceOfPrototype( + value, + getAbortSignalPrototype(global) + ); if (!hasAbortSymbol && !isNativeAbortSignal) return false; - return reduceAbortBySymbol(value, value as AbortHolder); + return reduceAbortBySymbol(value as AbortSignal, value as AbortHolder); }, }; } @@ -3420,7 +3550,15 @@ export async function dehydrateWorkflowReturnValue( key: PayloadKey | undefined, global: Record = globalThis, v1Compat = false, - compression = false + compression = false, + /** + * Optional sink receiving every workflow-code execution serialization could + * not avoid, for callers that need them programmatically (e.g. a + * retained-VM gate deciding whether the VM is still reusable). The + * executions are emitted as span attributes either way, so omitting this + * loses nothing observability-wise. No runtime caller passes one yet. + */ + guestCodeStatsOut?: GuestCodeStats ): Promise { if (v1Compat) { const str = stringify(value, getWorkflowReducers(global)); @@ -3428,13 +3566,18 @@ export async function dehydrateWorkflowReturnValue( } try { const compressionStats: CompressionStats = {}; + const guestCodeStats: GuestCodeStats = guestCodeStatsOut ?? { + executions: [], + }; const result = await stepModule.serialize(value, key, { global, extraReducers: getStreamAndRequestReducers(getWorkflowReducers(global)), compression, compressionStats, + guestCodeStats, }); await recordCompression(compressionStats, 'serialize'); + await recordGuestCodeExecutions(guestCodeStats); return result; } catch (error) { const cause = unwrapSerializationCause(error); @@ -3483,7 +3626,9 @@ export async function dehydrateStepArguments( key: PayloadKey | undefined, global: Record = globalThis, v1Compat = false, - compression = false + compression = false, + /** See `dehydrateWorkflowReturnValue`. */ + guestCodeStatsOut?: GuestCodeStats ): Promise { if (v1Compat) { const str = stringify(value, getWorkflowReducers(global)); @@ -3491,13 +3636,18 @@ export async function dehydrateStepArguments( } try { const compressionStats: CompressionStats = {}; + const guestCodeStats: GuestCodeStats = guestCodeStatsOut ?? { + executions: [], + }; const result = await stepModule.serialize(value, key, { global, extraReducers: getStreamAndRequestReducers(getWorkflowReducers(global)), compression, compressionStats, + guestCodeStats, }); await recordCompression(compressionStats, 'serialize'); + await recordGuestCodeExecutions(guestCodeStats); return result; } catch (error) { const cause = unwrapSerializationCause(error); diff --git a/packages/core/src/serialization/codec-devalue.ts b/packages/core/src/serialization/codec-devalue.ts index 474aa72600..a7ac8e0037 100644 --- a/packages/core/src/serialization/codec-devalue.ts +++ b/packages/core/src/serialization/codec-devalue.ts @@ -12,6 +12,7 @@ import { parse, stringify, unflatten } from 'devalue'; import type { Codec, CodecOptions, SerializationMode } from './codec.js'; +import { hardenedStringifyOperations, withGuestCodeStats } from './hardened.js'; import { getClassReducers, getClassRevivers } from './reducers/class.js'; import { getCommonReducers, getCommonRevivers } from './reducers/common.js'; import { @@ -112,7 +113,15 @@ export const devalueCodec: Codec = { options?.global, options?.extraReducers ); - const str = stringify(value, reducers); + // Hardened operations: devalue's own introspection (classification, + // built-in extraction, property reads) goes through captured host + // intrinsics and descriptor reads, so serializing a value constructed + // inside a workflow VM cannot execute sandbox code behind our back. + // Where workflow code must run (getters, proxies, custom serializers), + // the execution is recorded into `options.guestCodeStats`. + const str = withGuestCodeStats(options?.guestCodeStats, () => + stringify(value, reducers, { operations: hardenedStringifyOperations }) + ); return encoder.encode(str); }, diff --git a/packages/core/src/serialization/codec.ts b/packages/core/src/serialization/codec.ts index 7411d6b80e..4ff6342863 100644 --- a/packages/core/src/serialization/codec.ts +++ b/packages/core/src/serialization/codec.ts @@ -15,6 +15,7 @@ */ import type { CompressionStats } from './compression.js'; +import type { GuestCodeStats } from './hardened.js'; import type { FormatPrefix } from './types.js'; /** @@ -74,6 +75,23 @@ export interface CodecOptions { * Used by the dehydrate/hydrate wrappers to emit OTel span attributes. */ compressionStats?: CompressionStats; + + /** + * Optional sink populated by the hardened serializer with every + * workflow (guest) code execution that serialization could not avoid — + * getters, proxies, and custom `[WORKFLOW_SERIALIZE]` methods. A non-empty + * `executions` array means serialization may have perturbed VM state + * (it runs exactly once per payload and is never replayed, so any side + * effect it triggers diverges from replay). + * + * Every dehydrate path already reports this as span attributes. Passing a + * sink is for callers that need the executions *programmatically* — a + * retained-VM gate deciding whether the VM is still reusable. No caller + * does that yet, so nothing in the runtime currently passes one. + * + * Serialize side only. + */ + guestCodeStats?: GuestCodeStats; } export interface Codec { diff --git a/packages/core/src/serialization/hardened.test.ts b/packages/core/src/serialization/hardened.test.ts new file mode 100644 index 0000000000..04a45826fe --- /dev/null +++ b/packages/core/src/serialization/hardened.test.ts @@ -0,0 +1,838 @@ +/** + * Hardened serialization: serializing values built inside a workflow VM + * must not execute workflow code, and must record the cases where it + * cannot be avoided. + * + * Every fixture here is minted inside a real `node:vm` context via + * `createContext()` — the same context the workflow runtime uses — and + * serialized with the VM's `globalThis` as `options.global`, mirroring + * `dehydrateStepArguments`. + */ + +import { runInContext } from 'node:vm'; +import { describe, expect, it } from 'vitest'; +import { dehydrateStepArguments } from '../serialization.js'; +import { createContext } from '../vm/index.js'; +import { devalueCodec } from './codec-devalue.js'; +import { type GuestCodeStats, markUseStepClosureFn } from './hardened.js'; + +const seed = 'hardened-serialization'; +const fixedTimestamp = 1714857600000; + +function makeVm() { + const { context, globalThis: vmGlobalThis } = createContext({ + seed, + fixedTimestamp, + }); + // The real workflow VM injects these host classes (see workflow.ts). + vmGlobalThis.Headers = globalThis.Headers; + vmGlobalThis.Request = globalThis.Request; + vmGlobalThis.Response = globalThis.Response; + vmGlobalThis.ReadableStream = globalThis.ReadableStream; + vmGlobalThis.WritableStream = globalThis.WritableStream; + + const decoder = new TextDecoder(); + + return { + context, + vmGlobalThis, + /** Evaluate an expression inside the VM. */ + evaluate: (source: string) => runInContext(`(${source})`, context), + /** Run statements inside the VM. */ + run: (source: string) => runInContext(source, context), + /** + * Serialize a VM value the way the suspension handler does, returning + * the wire string plus whatever guest code could not be avoided. + */ + serialize: (value: unknown) => { + const guestCodeStats: GuestCodeStats = { executions: [] }; + const bytes = devalueCodec.serialize(value, 'step', { + global: vmGlobalThis, + guestCodeStats, + }); + return { wire: decoder.decode(bytes), stats: guestCodeStats }; + }, + }; +} + +describe('hardened serialization: patched prototypes are never executed', () => { + it('serializes a Date without consulting the VM Date.prototype', () => { + const vm = makeVm(); + vm.run(` + globalThis.sideEffects = 0; + const originalToISOString = Date.prototype.toISOString; + const originalGetDate = Date.prototype.getDate; + Date.prototype.toISOString = function () { + globalThis.sideEffects++; + return originalToISOString.call(this); + }; + Date.prototype.getDate = function () { + globalThis.sideEffects++; + return originalGetDate.call(this); + }; + `); + + const date = vm.evaluate('new Date(1700000000000)'); + const { wire, stats } = vm.serialize(date); + + expect(wire).toContain('2023-11-14T22:13:20.000Z'); + expect(vm.evaluate('globalThis.sideEffects')).toBe(0); + expect(stats.executions).toEqual([]); + }); + + it('serializes Map/Set without consulting a patched Symbol.iterator', () => { + const vm = makeVm(); + vm.run(` + globalThis.sideEffects = 0; + const patch = (proto) => { + const original = proto[Symbol.iterator]; + proto[Symbol.iterator] = function () { + globalThis.sideEffects++; + return original.call(this); + }; + }; + patch(Map.prototype); + patch(Set.prototype); + Map.prototype.entries = function () { globalThis.sideEffects++; throw new Error('nope'); }; + Set.prototype.values = function () { globalThis.sideEffects++; throw new Error('nope'); }; + `); + + const value = vm.evaluate( + '({ map: new Map([["k", "v"]]), set: new Set([1, 2]) })' + ); + const { wire, stats } = vm.serialize(value); + + expect(wire).toContain('Map'); + expect(wire).toContain('Set'); + expect(vm.evaluate('globalThis.sideEffects')).toBe(0); + expect(stats.executions).toEqual([]); + }); + + it('serializes a RegExp without consulting patched source/flags getters', () => { + const vm = makeVm(); + vm.run(` + globalThis.sideEffects = 0; + Object.defineProperty(RegExp.prototype, 'source', { + configurable: true, + get() { globalThis.sideEffects++; return 'hacked'; }, + }); + Object.defineProperty(RegExp.prototype, 'flags', { + configurable: true, + get() { globalThis.sideEffects++; return 'hacked'; }, + }); + `); + + const { wire, stats } = vm.serialize(vm.evaluate('/ab+c/gi')); + + expect(wire).toContain('ab+c'); + expect(wire).not.toContain('hacked'); + expect(vm.evaluate('globalThis.sideEffects')).toBe(0); + expect(stats.executions).toEqual([]); + }); + + it('serializes typed arrays from internal slots, ignoring shadowed metadata', () => { + const vm = makeVm(); + const view = vm.evaluate(`(() => { + const bytes = new Uint8Array([1, 2, 3, 4]); + // shadow the view metadata: a naive reader would hash the wrong range + Object.defineProperty(bytes, 'byteLength', { value: 0 }); + Object.defineProperty(bytes, 'byteOffset', { value: 2 }); + return bytes; + })()`); + + const { wire, stats } = vm.serialize(view); + const expected = devalueCodec.serialize( + new Uint8Array([1, 2, 3, 4]), + 'step' + ); + + expect(wire).toBe(new TextDecoder().decode(expected)); + expect(stats.executions).toEqual([]); + }); + + it('is not fooled by a Symbol.toStringTag spoof', () => { + const vm = makeVm(); + // Object.prototype.toString would report [object Date] for this + const spoofed = vm.evaluate( + 'Object.defineProperty({ a: 1 }, Symbol.toStringTag, { value: "Date" })' + ); + + const { wire } = vm.serialize(spoofed); + + // Classified by engine brand, so it serializes as the plain object it + // is. (Unhardened devalue honours the tag and crashes calling + // `toISOString()` on it — hardening is strictly better here.) + expect(wire).toBe( + new TextDecoder().decode(devalueCodec.serialize({ a: 1 }, 'step')) + ); + }); + + it('does not consult a patched Object.prototype.toString', () => { + const vm = makeVm(); + vm.run(` + globalThis.sideEffects = 0; + Object.prototype.toString = function () { + globalThis.sideEffects++; + return '[object Date]'; + }; + `); + + const { wire } = vm.serialize(vm.evaluate('({ nested: { a: 1 } })')); + + expect(wire).toContain('nested'); + expect(vm.evaluate('globalThis.sideEffects')).toBe(0); + }); + + it('serializes URL/URLSearchParams without consulting patched accessors', () => { + const vm = makeVm(); + // `URL`/`URLSearchParams` are host classes injected into the sandbox, so + // patching their prototypes from inside the VM mutates the *host* + // prototype for the rest of this worker process. Restore both, or every + // later test in the file inherits the patch. + const originalHref = Object.getOwnPropertyDescriptor( + URL.prototype, + 'href' + ) as PropertyDescriptor; + const originalParamsToString = URLSearchParams.prototype.toString; + try { + vm.run(` + globalThis.sideEffects = 0; + Object.defineProperty(URL.prototype, 'href', { + configurable: true, + get() { globalThis.sideEffects++; return 'https://hacked.example/'; }, + }); + URLSearchParams.prototype.toString = function () { + globalThis.sideEffects++; + return 'hacked=1'; + }; + `); + + const value = vm.evaluate( + '({ url: new URL("https://example.com/x?y=1"), params: new URLSearchParams("a=1") })' + ); + const { wire, stats } = vm.serialize(value); + + expect(wire).toContain('https://example.com/x?y=1'); + expect(wire).toContain('a=1'); + expect(wire).not.toContain('hacked'); + expect(vm.evaluate('globalThis.sideEffects')).toBe(0); + expect(stats.executions).toEqual([]); + + // Only now confirm the patch really did reach the host prototype — + // reading `.href` here invokes it, so it has to come after the + // side-effect assertion above. + expect(new URL('https://example.com/').href).toBe( + 'https://hacked.example/' + ); + } finally { + Object.defineProperty(URL.prototype, 'href', originalHref); + URLSearchParams.prototype.toString = originalParamsToString; + } + }); + + it('serializes errors without consulting patched Error.prototype accessors', () => { + const vm = makeVm(); + vm.run(` + globalThis.sideEffects = 0; + Object.defineProperty(Error.prototype, 'message', { + configurable: true, + get() { globalThis.sideEffects++; return 'hacked'; }, + }); + `); + + // own `message` data property (as `new Error(...)` produces) wins + const error = vm.evaluate('new TypeError("boom")'); + const { wire, stats } = vm.serialize(error); + + expect(wire).toContain('boom'); + expect(wire).not.toContain('hacked'); + expect(vm.evaluate('globalThis.sideEffects')).toBe(0); + expect(stats.executions).toEqual([]); + }); +}); + +describe('hardened serialization: unavoidable guest code is recorded', () => { + it('records getter invocations and still serializes the value', () => { + const vm = makeVm(); + const value = vm.evaluate(`(() => { + globalThis.getterRuns = 0; + return { + plain: 'data', + get computed() { globalThis.getterRuns++; return 'from getter'; }, + }; + })()`); + + const { wire, stats } = vm.serialize(value); + + // compatibility preserved: the getter still ran, the data is present + expect(wire).toContain('from getter'); + expect(vm.evaluate('globalThis.getterRuns')).toBe(1); + expect(stats.executions).toEqual([{ kind: 'getter', detail: 'computed' }]); + }); + + it('still identifies a proxied host class, and reports the traps', async () => { + // Next.js hands the runtime a proxied `NextRequest`. `instanceof` + // forwards through the `getPrototypeOf` trap, and the Request reducer + // reads ordinary properties, so such values serialized fine before + // hardening — answering "not a Request" for one silently broke every + // webhook. Identification has to stay correct; the traps get reported. + // No body: a request body is a ReadableStream, which the workflow-context + // reducers expect to be a named handle. The proxy identification is what + // this test is about; the full webhook path is covered end to end. + const target = new Request('https://example.com/hook', { method: 'POST' }); + // Mirror how Next.js proxies a Request: the `get` trap forwards with the + // *target* as receiver, so the built-in's private-slot reads still work. + // (A bare `new Proxy(request, {})` throws on those reads — before this + // change as well as after.) + const proxied = new Proxy(target, { + get(t, key) { + const v = Reflect.get(t, key, t); + return typeof v === 'function' ? v.bind(t) : v; + }, + }); + + const stats: GuestCodeStats = { executions: [] }; + const bytes = (await dehydrateStepArguments( + proxied, + 'wrun_proxy', + undefined, + globalThis, + false, + false, + stats + )) as Uint8Array; + const wire = new TextDecoder().decode(bytes); + + // serialized as a Request, not rejected as an arbitrary non-POJO + expect(wire).toContain('Request'); + expect(wire).toContain('https://example.com/hook'); + // ...and the traps that ran are on the record + expect(stats.executions.some((e) => e.kind === 'proxy')).toBe(true); + }); + + it('records a proxy once, not once per trap', () => { + const vm = makeVm(); + const value = vm.evaluate(`(() => { + globalThis.trapRuns = 0; + const target = { a: 1, b: 2 }; + return { + wrapped: new Proxy(target, { + get(t, k) { globalThis.trapRuns++; return t[k]; }, + ownKeys(t) { globalThis.trapRuns++; return Reflect.ownKeys(t); }, + getOwnPropertyDescriptor(t, k) { + globalThis.trapRuns++; + return Reflect.getOwnPropertyDescriptor(t, k); + }, + }), + }; + })()`); + + const { wire, stats } = vm.serialize(value); + + expect(wire).toContain('"a"'); + const proxyReports = stats.executions.filter((e) => e.kind === 'proxy'); + expect(proxyReports).toHaveLength(1); + }); + + // Both sides of the `__closureVarsFn` provenance check. The mark proves + // the function came through `useStep`, nothing stronger — see + // `markUseStepClosureFn`. + it('reports a __closureVarsFn the step proxy did not bring', () => { + let calls = 0; + const stepLike = Object.assign(() => {}, { + stepId: 'step//a.ts//fake', + __closureVarsFn: () => { + calls += 1; + return { captured: 1 }; + }, + }); + + const stats: GuestCodeStats = { executions: [] }; + const wire = new TextDecoder().decode( + devalueCodec.serialize(stepLike, 'workflow', { guestCodeStats: stats }) + ); + + // it still runs (the closure vars are the data), and it is reported + expect(calls).toBe(1); + expect(wire).toContain('captured'); + expect(stats.executions).toEqual([ + { kind: 'method', detail: '__closureVarsFn' }, + ]); + }); + + it('does not report a __closureVarsFn that came through useStep', () => { + let calls = 0; + const closureVarsFn = () => { + calls += 1; + return { captured: 1 }; + }; + // what step.ts does when it builds the proxy + markUseStepClosureFn(closureVarsFn); + + const stepLike = Object.assign(() => {}, { + stepId: 'step//a.ts//real', + __closureVarsFn: closureVarsFn, + }); + + const stats: GuestCodeStats = { executions: [] }; + const wire = new TextDecoder().decode( + devalueCodec.serialize(stepLike, 'workflow', { guestCodeStats: stats }) + ); + + expect(calls).toBe(1); + expect(wire).toContain('captured'); + expect(stats.executions).toEqual([]); + }); + + it('records custom WORKFLOW_SERIALIZE invocations', async () => { + const { WORKFLOW_SERIALIZE } = await import('@workflow/serde'); + class Point { + static classId = 'test/Point'; + static [WORKFLOW_SERIALIZE](p: Point) { + return { x: p.x, y: p.y }; + } + constructor( + public x: number, + public y: number + ) {} + } + + const guestCodeStats: GuestCodeStats = { executions: [] }; + devalueCodec.serialize(new Point(1, 2), 'step', { guestCodeStats }); + + expect(guestCodeStats.executions).toEqual([ + { kind: 'method', detail: '[WORKFLOW_SERIALIZE] (test/Point)' }, + ]); + }); + + it('reports nothing for values that serialize side-effect free', () => { + const vm = makeVm(); + const value = vm.evaluate(`({ + string: 'text', + number: 42, + bigint: 123n, + bool: true, + nil: null, + nested: { deep: [1, 2, { three: 3 }] }, + when: new Date(1700000000000), + pattern: /x/g, + map: new Map([['k', 'v']]), + set: new Set([1]), + bytes: new Uint8Array([1, 2, 3]), + buffer: new ArrayBuffer(4), + url: new URL('https://example.com/'), + error: new Error('boom'), + sparse: [1, , 3], + })`); + + const { stats } = vm.serialize(value); + + expect(stats.executions).toEqual([]); + }); +}); + +describe('hardened serialization: wire-format parity', () => { + // Values built in the VM must serialize byte-identically to the same + // values built on the host — hardening changes how we read, not what we + // produce. + const cases: Array<[string, string, unknown]> = [ + ['date', 'new Date(1700000000000)', new Date(1700000000000)], + ['invalid date', 'new Date(NaN)', new Date(NaN)], + ['regexp', '/ab+c/gi', /ab+c/gi], + ['regexp without flags', '/plain/', /plain/], + [ + 'map', + 'new Map([["k", 1], ["j", 2]])', + new Map([ + ['k', 1], + ['j', 2], + ]), + ], + ['set', 'new Set([1, "two"])', new Set([1, 'two'])], + ['empty map', 'new Map()', new Map()], + [ + 'url', + 'new URL("https://example.com/a?b=1")', + new URL('https://example.com/a?b=1'), + ], + [ + 'url search params', + 'new URLSearchParams("a=1&b=2")', + new URLSearchParams('a=1&b=2'), + ], + ['empty url search params', 'new URLSearchParams()', new URLSearchParams()], + ['uint8array', 'new Uint8Array([1, 2, 255])', new Uint8Array([1, 2, 255])], + ['empty uint8array', 'new Uint8Array(0)', new Uint8Array(0)], + ['int16array', 'new Int16Array([-1, 0, 1])', new Int16Array([-1, 0, 1])], + ['float64array', 'new Float64Array([1.5])', new Float64Array([1.5])], + [ + 'bigint64array', + 'new BigInt64Array([1n, -2n])', + new BigInt64Array([1n, -2n]), + ], + [ + 'arraybuffer', + 'new Uint8Array([1, 2, 3]).buffer', + new Uint8Array([1, 2, 3]).buffer, + ], + ['bigint', '123n', 123n], + ['negative bigint', '-7n', -7n], + ['plain object', '({ a: 1, b: "two" })', { a: 1, b: 'two' }], + ['array', '[1, "two", null]', [1, 'two', null]], + // biome-ignore lint/suspicious/noSparseArray: the hole is the fixture + ['sparse array', '[1, , 3]', [1, , 3]], + ['nested', '({ a: { b: [{ c: 1 }] } })', { a: { b: [{ c: 1 }] } }], + ['-0', '-0', -0], + ['NaN', 'NaN', NaN], + ['Infinity', 'Infinity', Infinity], + ['undefined', 'undefined', undefined], + // DataView — exercises the three dataView* intrinsic captures + [ + 'dataview', + 'new DataView(new Uint8Array([1,2,3,4,5,6,7,8]).buffer, 1, 4)', + new DataView(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]).buffer, 1, 4), + ], + [ + 'dataview whole buffer', + 'new DataView(new Uint8Array([1,2,3,4]).buffer)', + new DataView(new Uint8Array([1, 2, 3, 4]).buffer), + ], + // boxed primitives — exercises unbox()'s three valueOf captures + ['boxed number', 'new Number(42)', new Number(42)], + ['boxed string', 'new String("boxed")', new String('boxed')], + ['boxed boolean', 'new Boolean(true)', new Boolean(true)], + // null-prototype objects, with and without data + [ + 'null-proto object', + 'Object.assign(Object.create(null), { x: 1, y: "two" })', + Object.assign(Object.create(null), { x: 1, y: 'two' }), + ], + ['empty null-proto object', 'Object.create(null)', Object.create(null)], + // a setter-only property reads as undefined (the `return undefined` + // branch in readProperty), same as an unhardened `thing[key]` + [ + 'setter-only property', + '(() => { const o = { a: 1 }; Object.defineProperty(o, "writeOnly", { set() {}, enumerable: true, configurable: true }); return o; })()', + (() => { + const o: Record = { a: 1 }; + Object.defineProperty(o, 'writeOnly', { + set() {}, + enumerable: true, + configurable: true, + }); + return o; + })(), + ], + ]; + + it.each( + cases + )('%s matches host serialization', (_label, source, hostValue) => { + const vm = makeVm(); + const decoder = new TextDecoder(); + + const fromVm = vm.serialize(vm.evaluate(source)); + const fromHost = decoder.decode(devalueCodec.serialize(hostValue, 'step')); + + expect(fromVm.wire).toBe(fromHost); + expect(fromVm.stats.executions).toEqual([]); + }); + + it('round-trips a Headers instance built in the VM', () => { + const vm = makeVm(); + const headers = vm.evaluate('new Headers({ "x-a": "1" })'); + const { wire, stats } = vm.serialize(headers); + + expect(wire).toContain('Headers'); + expect(wire).toContain('x-a'); + expect(stats.executions).toEqual([]); + + const revived = devalueCodec.deserialize( + new TextEncoder().encode(wire), + 'step', + { global: vm.vmGlobalThis } + ) as Headers; + expect(revived.get('x-a')).toBe('1'); + }); + + // Error payloads carry realm-specific stack text (devalue stores the frames + // as separate string elements), so these assert a structural round trip + // rather than byte parity. + it('round-trips a DOMException built in the VM', () => { + const vm = makeVm(); + vm.vmGlobalThis.DOMException = globalThis.DOMException; + const { wire, stats } = vm.serialize( + vm.evaluate('new DOMException("nope", "AbortError")') + ); + + expect(wire).toContain('DOMException'); + expect(stats.executions).toEqual([]); + + const revived = devalueCodec.deserialize( + new TextEncoder().encode(wire), + 'step', + { global: vm.vmGlobalThis } + ) as DOMException; + expect(revived.name).toBe('AbortError'); + expect(revived.message).toBe('nope'); + }); + + it('round-trips an AggregateError built in the VM', () => { + const vm = makeVm(); + const { wire, stats } = vm.serialize( + vm.evaluate( + 'new AggregateError([new Error("a"), new Error("b")], "many")' + ) + ); + + expect(wire).toContain('AggregateError'); + expect(stats.executions).toEqual([]); + + const revived = devalueCodec.deserialize( + new TextEncoder().encode(wire), + 'step', + { global: vm.vmGlobalThis } + ) as AggregateError; + expect(revived.message).toBe('many'); + expect(revived.errors.map((e: Error) => e.message)).toEqual(['a', 'b']); + }); + + it('round-trips a class instance through WORKFLOW_SERIALIZE, and reports it', async () => { + const { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } = await import( + '@workflow/serde' + ); + const { registerSerializationClass } = await import( + '../class-serialization.js' + ); + + class Point { + static classId = 'test/hardened-Point'; + static [WORKFLOW_SERIALIZE](p: Point) { + return { x: p.x, y: p.y }; + } + static [WORKFLOW_DESERIALIZE](data: { x: number; y: number }) { + return new Point(data.x, data.y); + } + constructor( + public x: number, + public y: number + ) {} + } + registerSerializationClass('test/hardened-Point', Point); + + const stats: GuestCodeStats = { executions: [] }; + const bytes = devalueCodec.serialize(new Point(1, 2), 'step', { + guestCodeStats: stats, + }); + const wire = new TextDecoder().decode(bytes); + + // The custom serializer is workflow code by definition, so it is reported + expect(stats.executions).toEqual([ + { kind: 'method', detail: '[WORKFLOW_SERIALIZE] (test/hardened-Point)' }, + ]); + + const revived = devalueCodec.deserialize(bytes, 'step') as Point; + expect(revived).toBeInstanceOf(Point); + expect([revived.x, revived.y]).toEqual([1, 2]); + expect(wire).toContain('test/hardened-Point'); + }); + + it('reports the RetryableError duck-typed retryAfter path', async () => { + const vm = makeVm(); + // A date-like object with a `getTime()` method — invoking it is workflow + // code, and the reducer says so. + const error = vm.evaluate(`(() => { + const e = new Error('later'); + e.name = 'RetryableError'; + e.retryAfter = { getTime: () => 1700000000000 }; + return e; + })()`); + + const { wire, stats } = vm.serialize(error); + + expect(wire).toContain('RetryableError'); + expect(wire).toContain('1700000000000'); + expect(stats.executions).toEqual([{ kind: 'method', detail: 'getTime' }]); + }); + + it('reads a real Date retryAfter without reporting anything', () => { + const vm = makeVm(); + const error = vm.evaluate(`(() => { + const e = new Error('later'); + e.name = 'RetryableError'; + e.retryAfter = new Date(1700000000000); + return e; + })()`); + + const { wire, stats } = vm.serialize(error); + + expect(wire).toContain('1700000000000'); + expect(stats.executions).toEqual([]); + }); + + it('reports an accessor-valued Symbol.toStringTag', () => { + const vm = makeVm(); + const value = vm.evaluate(`(() => { + globalThis.tagReads = 0; + const o = { a: 1 }; + Object.defineProperty(o, Symbol.toStringTag, { + configurable: true, + get() { globalThis.tagReads++; return 'CustomTag'; }, + }); + return o; + })()`); + + const { wire, stats } = vm.serialize(value); + + // The tag is not brand-decided, so it is honoured — and reading it + // through the accessor is reported. + expect(vm.evaluate('globalThis.tagReads')).toBe(1); + expect(stats.executions).toEqual([ + { kind: 'getter', detail: 'Symbol(Symbol.toStringTag)' }, + ]); + expect(wire).toContain('"a"'); + }); + + it('preserves shared references and cycles', () => { + const vm = makeVm(); + const cyclic = vm.evaluate( + '(() => { const o = { name: "cycle" }; o.self = o; return { first: o, second: o }; })()' + ); + + const { wire, stats } = vm.serialize(cyclic); + expect(stats.executions).toEqual([]); + + const revived = devalueCodec.deserialize( + new TextEncoder().encode(wire), + 'step', + { global: vm.vmGlobalThis } + ) as any; + expect(revived.first).toBe(revived.second); + expect(revived.first.self).toBe(revived.first); + }); +}); + +describe('hardened serialization: the real step-argument reducer set', () => { + // `serialize()` above mirrors `dehydrateStepArguments` minus its + // `extraReducers` — which is where the stream/request/abort guards live. + // Those guards run on every value the earlier reducers do not claim, so + // report completeness has to hold with them installed. + // The real dehydrate path, including its stream/request/abort reducers, + // with the report collected through the new out-param. + async function serializeLikeDehydrate( + vm: ReturnType, + value: unknown + ) { + const stats: GuestCodeStats = { executions: [] }; + const bytes = (await dehydrateStepArguments( + value, + 'wrun_hardened', + undefined, + vm.vmGlobalThis, + false, + false, + stats + )) as Uint8Array; + return { wire: new TextDecoder().decode(bytes), stats }; + } + + it('does not consult Symbol.hasInstance on the sandbox stream classes', async () => { + const vm = makeVm(); + vm.run(` + globalThis.hasInstanceRuns = 0; + for (const ctor of [ReadableStream, WritableStream, Request, Response]) { + Object.defineProperty(ctor, Symbol.hasInstance, { + configurable: true, + value() { globalThis.hasInstanceRuns++; return false; }, + }); + } + `); + + const { wire } = await serializeLikeDehydrate( + vm, + vm.evaluate('({ a: 1, nested: { b: [2, 3] }, when: new Date(0) })') + ); + + expect(wire).toContain('nested'); + expect(vm.evaluate('globalThis.hasInstanceRuns')).toBe(0); + }); + + it('reports a non-enumerable `signal` getter instead of running it silently', async () => { + const vm = makeVm(); + // `Object.keys` never reaches a non-enumerable property, so the plain + // object walk does not read it — only the AbortController guard does. + const value = vm.evaluate(`(() => { + globalThis.signalReads = 0; + const o = { plain: 1 }; + Object.defineProperty(o, 'signal', { + enumerable: false, + configurable: true, + get() { globalThis.signalReads++; return undefined; }, + }); + return o; + })()`); + + const { stats } = await serializeLikeDehydrate(vm, value); + + const reads = vm.evaluate('globalThis.signalReads') as number; + // Either the guard never touched it, or it did and said so — the thing + // that must not happen is an invocation with an empty report. + expect(reads === 0 || stats.executions.length > 0).toBe(true); + if (reads > 0) { + expect(stats.executions).toContainEqual({ + kind: 'getter', + detail: 'signal', + }); + } + }); + + it('still reports nothing for an ordinary payload', async () => { + const vm = makeVm(); + const { stats } = await serializeLikeDehydrate( + vm, + vm.evaluate( + '({ list: [1, 2], when: new Date(1700000000000), map: new Map([["k","v"]]) })' + ) + ); + expect(stats.executions).toEqual([]); + }); +}); + +describe('hardened serialization: cross-realm classification', () => { + // Reassigning a sandbox global used to break `instanceof global.X` + // classification; brand checks are immune. + it('classifies values whose VM global was reassigned', () => { + const vm = makeVm(); + const value = vm.evaluate(`(() => { + const real = { date: new Date(1700000000000), map: new Map([["k", "v"]]) }; + globalThis.Date = function FakeDate() {}; + globalThis.Map = function FakeMap() {}; + return real; + })()`); + + const { wire, stats } = vm.serialize(value); + + expect(wire).toContain('2023-11-14T22:13:20.000Z'); + expect(wire).toContain('Map'); + expect(stats.executions).toEqual([]); + }); + + it('classifies values with a hostile Symbol.hasInstance', () => { + const vm = makeVm(); + const value = vm.evaluate(`(() => { + globalThis.hasInstanceRuns = 0; + Object.defineProperty(globalThis.Date, Symbol.hasInstance, { + configurable: true, + value() { globalThis.hasInstanceRuns++; return true; }, + }); + return { plain: { a: 1 } }; + })()`); + + const { wire } = vm.serialize(value); + + // the plain object is not misclassified as a Date, and hasInstance + // was never consulted + expect(wire).toBe('[{"plain":1},{"a":2},1]'); + expect(vm.evaluate('globalThis.hasInstanceRuns')).toBe(0); + }); +}); diff --git a/packages/core/src/serialization/hardened.ts b/packages/core/src/serialization/hardened.ts new file mode 100644 index 0000000000..84ebb9bc88 --- /dev/null +++ b/packages/core/src/serialization/hardened.ts @@ -0,0 +1,763 @@ +/** + * Hardened introspection for serializing values that may originate inside a + * workflow VM (`node:vm`) sandbox realm. + * + * Serialization runs on the host, but the values it inspects were + * constructed by workflow code — so a naive dynamic operation like + * `value.toISOString()`, `Array.from(map)`, or `Object.prototype.toString` + * dispatches into the sandbox realm and executes workflow code (patched + * prototype methods, getters, proxy traps, `Symbol.toStringTag` accessors). + * That is a determinism hazard: serialization happens exactly once per + * payload (never again on replay), so any workflow-visible side effect it + * triggers exists only on the live execution path and diverges from replay. + * + * This module makes serialization side-effect free wherever the data allows + * it, and *observable* where it does not: + * + * - **Classification** uses engine-level brand checks (`node:util` `types`, + * internal-slot probes) instead of `instanceof` / `Object.prototype.toString` + * — immune to `Symbol.hasInstance`, `Symbol.toStringTag`, and reassigned + * globals. + * - **Extraction** goes through intrinsics captured at module load (host + * boot, before any workflow code runs), invoked with an explicit receiver. + * Internal slots are realm-agnostic, so host intrinsics operate on + * VM-realm objects without touching the sandbox's (patchable) prototypes. + * - **Property access** reads through descriptors, so plain data never + * invokes anything. Where workflow code *must* run because the data itself + * lives behind it — getters, proxies, custom `[WORKFLOW_SERIALIZE]` + * methods, `toString()` on toStringTag-branded objects (e.g. Temporal + * polyfills) — the execution is preserved for compatibility and recorded + * in the active {@link GuestCodeStats} sink, so callers (e.g. a retained-VM + * gate) can react. + * + * **Recording is not prevention.** For the recorded cases the determinism + * hazard is still live: a getter that calls `Math.random()` advances the + * run's seeded PRNG during serialization, and because serialization happens + * exactly once and is never replayed, every subsequent draw — including the + * correlation ids derived from that stream — shifts relative to replay. The + * report is the only trace of that, which is why the sink exists rather than + * a bare `console.warn`; acting on it (warning, or refusing to serialize) is + * left to the caller. + * + * The recorder is ambient module state, set for the duration of a + * synchronous `stringify` call via {@link withGuestCodeStats}. devalue's + * `stringify` is fully synchronous, so this is safe without async context. + */ + +import { types } from 'node:util'; +import type { StringifyOperations } from 'devalue'; +import { defaultStringifyOperations } from 'devalue'; + +// ---- Guest code observation ------------------------------------------------- + +/** + * A single instance of workflow (guest) code executing during serialization. + */ +export interface GuestCodeExecution { + /** + * What forced the execution: + * - `getter`: an accessor property was invoked to read data + * - `proxy`: a proxy was introspected, firing its traps. Note this also + * implies a **shape change**: brand checks answer "not that type" for a + * proxy, so a proxied `Map` serializes as a plain object rather than as a + * `Map`, and this report is the only evidence of it. (Such values were + * never serializable before — the internal-slot reads in the previous + * implementation threw on them — so the shape change replaces a crash, + * but it is silent.) + * - `method`: a workflow-defined function was invoked (e.g. a custom + * `[WORKFLOW_SERIALIZE]` serializer, `toString()` on a + * `Symbol.toStringTag`-branded object, a duck-typed `getTime()`, or a + * `__closureVarsFn` this package did not generate) + */ + kind: 'getter' | 'proxy' | 'method'; + /** Best-effort context: the property key, method name, or tag involved. */ + detail?: string; +} + +/** + * Mutable sink recording every workflow-code execution serialization could + * not avoid. Pass via `CodecOptions.guestCodeStats`; consumers that retain + * the VM across steps can use a non-empty `executions` array as a signal + * that the VM state may have been perturbed by serialization. + */ +export interface GuestCodeStats { + executions: GuestCodeExecution[]; +} + +let activeStats: GuestCodeStats | null = null; +let reportedProxies: WeakSet | null = null; + +/** + * Runs `fn` (synchronously) with `stats` as the active guest-code sink. + * Nested calls stack correctly; a `null`/`undefined` sink disables + * recording without disabling hardening. + */ +export function withGuestCodeStats( + stats: GuestCodeStats | undefined, + fn: () => T +): T { + const previousStats = activeStats; + const previousProxies = reportedProxies; + activeStats = stats ?? null; + reportedProxies = stats ? new WeakSet() : null; + try { + return fn(); + } finally { + activeStats = previousStats; + reportedProxies = previousProxies; + } +} + +/** + * Closure-variable functions that arrived through `useStep` when a step + * proxy was built. + * + * The step-function reducer must invoke `__closureVarsFn` to read a step's + * captured closure variables, and the compiler-generated function is a + * sequence of lexical reads that cannot perturb observable VM state. The + * *property*, though, is reachable from workflow code, which can replace it + * with an arbitrary function — so the reducer checks membership here instead + * of assuming provenance, and reports anything it does not recognize. + * + * Be precise about what membership proves: **the function was passed to + * `useStep`**, not that this package generated it. `useStep` is published on + * the sandbox global as `Symbol.for('WORKFLOW_USE_STEP')` (see + * `workflow.ts`), so workflow code can call it directly with a function of + * its own and have it marked here. That launders a side-effectful function + * past the report — costing a missing telemetry entry, never incorrect + * output, and requiring deliberate effort — which is the same trade as the + * other caveats on {@link isEngineAccessor}. Marking is still worthwhile: + * closure capture is common, and reporting every step that captures a + * variable would bury the signal. + * + * Closing it properly means branding at the source (a compiler-emitted + * marker the runtime verifies), which is a compiler change and does not + * belong here. + */ +const useStepClosureFns = new WeakSet(); + +/** + * Marks a function as having been passed to `useStep`. See + * {@link isUseStepClosureFn} for exactly what that does and does not prove. + */ +export function markUseStepClosureFn(fn: T): T { + useStepClosureFns.add(fn); + return fn; +} + +/** Whether `fn` was marked by {@link markUseStepClosureFn}. */ +export function isUseStepClosureFn(fn: unknown): boolean { + return typeof fn === 'function' && useStepClosureFns.has(fn as object); +} + +export function recordGuestCode( + kind: GuestCodeExecution['kind'], + detail?: string +): void { + if (!activeStats) return; + const execution: GuestCodeExecution = { kind }; + if (detail !== undefined) execution.detail = detail; + activeStats.executions.push(execution); +} + +/** Records a proxy once per serialization pass (proxies fire many traps). */ +function recordProxy(value: object): void { + if (!activeStats || !reportedProxies) return; + if (reportedProxies.has(value)) return; + reportedProxies.add(value); + recordGuestCode('proxy'); +} + +// ---- Captured intrinsics ---------------------------------------------------- +// +// Captured at module load — host boot, before any workflow bundle can run. +// Invoked with explicit receivers so no property lookup ever resolves +// through a sandbox-realm prototype chain. + +const uncurryThis = Function.prototype.bind.bind(Function.prototype.call) as < + T, + A extends unknown[], + R, +>( + fn: (this: T, ...args: A) => R +) => (thisArg: T, ...args: A) => R; + +/** + * Captures a prototype accessor, or `undefined` when it is absent. + * + * This table is built at module scope, so a hard failure here would be an + * *import-time* crash of `@workflow/core` rather than a degraded + * serialization path. Not every member is universally available — + * `URLSearchParams.prototype.size` landed in Node 19.8 / Safari 17, and the + * WHATWG classes are host-provided rather than ECMAScript intrinsics — so + * every capture is optional and each use site falls back to not claiming the + * value (the reducers already treat "did not match" as ordinary). + */ +function intrinsicGetter( + prototype: object | undefined, + key: PropertyKey +): ((value: unknown) => unknown) | undefined { + if (!prototype) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(prototype, key); + if (!descriptor?.get) return undefined; + return uncurryThis(descriptor.get as (this: unknown) => unknown); +} + +/** See {@link intrinsicGetter} — captures a prototype method, if present. */ +function intrinsicMethod( + method: ((this: T, ...args: A) => R) | undefined +): ((thisArg: T, ...args: A) => R) | undefined { + return typeof method === 'function' ? uncurryThis(method) : undefined; +} + +/** `undefined` when the class itself is absent from the runtime. */ +function prototypeOf(ctor: unknown): object | undefined { + return typeof ctor === 'function' + ? ((ctor as { prototype?: object }).prototype ?? undefined) + : undefined; +} + +const TypedArrayPrototype = Object.getPrototypeOf( + Uint8Array.prototype +) as object; + +const call = { + // ECMAScript intrinsics: guaranteed by any engine that can load this + // package, so these are captured unconditionally. + dateGetDate: uncurryThis(Date.prototype.getDate), + dateGetTime: uncurryThis(Date.prototype.getTime), + dateToISOString: uncurryThis(Date.prototype.toISOString), + mapEntries: uncurryThis(Map.prototype.entries), + setValues: uncurryThis(Set.prototype.values), + numberValueOf: uncurryThis(Number.prototype.valueOf), + stringValueOf: uncurryThis(String.prototype.valueOf), + booleanValueOf: uncurryThis(Boolean.prototype.valueOf), + bigIntValueOf: uncurryThis( + BigInt.prototype.valueOf as (this: unknown) => bigint + ), + // Host-provided (WHATWG) classes: optional. + headersIterator: intrinsicMethod( + prototypeOf(globalThis.Headers)?.[Symbol.iterator as never] as + | ((this: Headers) => Iterator) + | undefined + ), + urlSearchParamsToString: intrinsicMethod( + prototypeOf(globalThis.URLSearchParams)?.toString as + | ((this: unknown) => string) + | undefined + ), +}; + +const get = { + regExpSource: intrinsicGetter(RegExp.prototype, 'source'), + regExpFlags: intrinsicGetter(RegExp.prototype, 'flags'), + typedArrayTag: intrinsicGetter(TypedArrayPrototype, Symbol.toStringTag) as + | ((value: unknown) => string | undefined) + | undefined, + typedArrayBuffer: intrinsicGetter(TypedArrayPrototype, 'buffer'), + typedArrayByteOffset: intrinsicGetter(TypedArrayPrototype, 'byteOffset'), + typedArrayByteLength: intrinsicGetter(TypedArrayPrototype, 'byteLength'), + typedArrayLength: intrinsicGetter(TypedArrayPrototype, 'length'), + dataViewBuffer: intrinsicGetter(DataView.prototype, 'buffer'), + dataViewByteOffset: intrinsicGetter(DataView.prototype, 'byteOffset'), + dataViewByteLength: intrinsicGetter(DataView.prototype, 'byteLength'), + arrayBufferByteLength: intrinsicGetter(ArrayBuffer.prototype, 'byteLength'), + sharedArrayBufferByteLength: intrinsicGetter( + prototypeOf(globalThis.SharedArrayBuffer), + 'byteLength' + ), + urlHref: intrinsicGetter(prototypeOf(globalThis.URL), 'href'), + // `size` landed in Node 19.8 / Safari 17 — newer than some runtimes the + // SDK still loads on. + urlSearchParamsSize: intrinsicGetter( + prototypeOf(globalThis.URLSearchParams), + 'size' + ), +}; + +// ---- Safe access primitives ------------------------------------------------- + +const { isProxy } = types; + +const functionToString = uncurryThis(Function.prototype.toString); + +/** Memoized nativeness, keyed on the getter function itself. */ +const engineAccessorCache = new WeakMap(); + +/** + * Whether an accessor is provided by the engine or the host, rather than + * defined by workflow code. + * + * Serialization must read some properties that are accessors by construction, + * and reporting those would drown the signal in noise. Two disjoint cases, + * both of which occur in practice: + * + * - **Engine accessors**, e.g. `stack`, which V8 defines as an *own accessor* + * on every Error instance. These are native code, but V8 installs them + * per realm, so a VM-realm error's `stack` getter is a VM-realm function. + * Detected by nativeness, via the captured host + * `Function.prototype.toString` (cross-realm, uninterceptable). + * - **Host builtins implemented in JavaScript**, e.g. Node's + * `DOMException.prototype.message`. These are ordinary functions — so not + * native — but they belong to the *host* realm, and workflow code cannot + * author a host-realm function. Detected by comparing the function's + * prototype against the host `Function.prototype`. + * + * Two known limitations, both of which cost a missing telemetry entry rather + * than incorrect output: + * + * - A bound function (`fn.bind(x)`) reports as native code, so + * `{ get: sideEffect.bind(null) }` would not be reported. + * - Workflow code that reaches a host function (any injected global) can read + * the host `Function.prototype` off it and `setPrototypeOf` its own getter + * to impersonate host provenance. + * + * Note also that V8's native `stack` getter can itself invoke a + * workflow-defined `Error.prepareStackTrace`; that indirection is not + * detected here. + */ +function isEngineAccessor(getter: object): boolean { + const cached = engineAccessorCache.get(getter); + if (cached !== undefined) return cached; + + let provided = false; + // A callable Proxy reports `function () { [native code] }` from + // Function.prototype.toString rather than throwing, so it would otherwise + // be cached as engine-provided and invoked unreported. Its traps are + // workflow code by definition. + if (!isProxy(getter)) { + try { + provided = + functionToString(getter as () => unknown).endsWith( + '{ [native code] }' + ) || Reflect.getPrototypeOf(getter) === Function.prototype; + } catch { + // Not a plain function — treat as workflow code. + provided = false; + } + } + engineAccessorCache.set(getter, provided); + return provided; +} + +/** + * Reads `value[key]` with `[[Get]]` semantics, but through descriptors: + * plain data properties never invoke anything; accessor properties are + * invoked (the data lives behind them) and recorded; proxies fall back to + * a plain read (their traps are the only access path) and are recorded. + */ +export function readProperty(value: unknown, key: PropertyKey): unknown { + if ( + value === null || + (typeof value !== 'object' && typeof value !== 'function') + ) { + return undefined; + } + if (isProxy(value)) { + recordProxy(value); + return (value as Record)[key]; + } + + let current: object | null = value; + while (current !== null) { + if (isProxy(current)) { + // a proxy in the prototype chain — its traps answer the lookup + recordProxy(current); + return (value as Record)[key]; + } + const descriptor = Object.getOwnPropertyDescriptor(current, key); + if (descriptor) { + if ('value' in descriptor) return descriptor.value; + if (descriptor.get) { + if (!isEngineAccessor(descriptor.get)) { + recordGuestCode('getter', String(key)); + } + return descriptor.get.call(value); + } + return undefined; // setter-only property + } + current = Reflect.getPrototypeOf(current); + } + return undefined; +} + +/** + * `key in value` semantics without firing proxy traps for ordinary + * objects. Proxies fall back to the `in` operator and are recorded. + */ +export function hasProperty(value: unknown, key: PropertyKey): boolean { + if ( + value === null || + (typeof value !== 'object' && typeof value !== 'function') + ) { + return false; + } + if (isProxy(value)) { + recordProxy(value); + return key in (value as object); + } + + let current: object | null = value; + while (current !== null) { + if (isProxy(current)) { + recordProxy(current); + return key in (value as object); + } + if (Object.getOwnPropertyDescriptor(current, key)) return true; + current = Reflect.getPrototypeOf(current); + } + return false; +} + +/** + * `value instanceof C` semantics for a known `C.prototype`, without + * consulting `Symbol.hasInstance` (which workflow code can define). Used + * for host classes that are injected into the sandbox (Headers, URL, + * URLSearchParams, DOMException), where the instances — from any realm the + * host handed the class to — carry the host prototype in their chain. + * + * Proxies are walked rather than rejected: `Reflect.getPrototypeOf` fires the + * proxy's `getPrototypeOf` trap, matching `instanceof` semantics, so a + * proxied instance is still identified when the host prototype is found in + * its chain. Real values depend on that — Next.js hands the runtime a + * proxied `NextRequest`. Any proxy encountered along the way is recorded, + * because its traps are guest-observable. + */ +export function isInstanceOfPrototype( + value: unknown, + prototype: object | undefined +): boolean { + if (!prototype) return false; + if (value === null || typeof value !== 'object') return false; + // Proxies are walked rather than rejected. `instanceof` forwards through a + // proxy's `getPrototypeOf` trap, and real values rely on that: Next.js + // hands the runtime a proxied `NextRequest`, whose reducer reads ordinary + // properties and serializes fine through the traps. Answering "not a + // Request" for it would silently break webhooks. The traps are + // guest-observable, so the proxy is reported — but the answer stays + // correct. + if (isProxy(value)) recordProxy(value); + let current: object | null = Reflect.getPrototypeOf(value); + while (current !== null) { + if (current === prototype) return true; + if (isProxy(current)) recordProxy(current); + current = Reflect.getPrototypeOf(current); + } + return false; +} + +// ---- Intrinsic-backed extraction helpers (used by the reducers) ------------- + +/** `Date.prototype.getDate`, for invalid-date checks. */ +export const dateGetDate = call.dateGetDate; +/** `Date.prototype.getTime`. */ +export const dateGetTime = call.dateGetTime; +/** `Date.prototype.toISOString`. */ +export const dateToISOString = call.dateToISOString; +/** `RegExp.prototype.source` getter. */ +export const regExpSource = get.regExpSource as (value: unknown) => string; +/** `RegExp.prototype.flags` getter. */ +export const regExpFlags = get.regExpFlags as (value: unknown) => string; + +/** + * Whether the WHATWG captures a reducer needs are available. When a class or + * one of its members is missing from the runtime, the corresponding reducer + * declines to match rather than serializing through a live (patchable) + * lookup — devalue then treats the value like any other object. + */ +export const canReadUrl = get.urlHref !== undefined; +export const canReadUrlSearchParams = + get.urlSearchParamsSize !== undefined && + call.urlSearchParamsToString !== undefined; +export const canReadHeaders = call.headersIterator !== undefined; + +/** + * Intrinsics read internal slots, which a Proxy does not have — invoking one + * with a proxy receiver throws, where the pre-existing dynamic read forwarded + * through the trap. For the (rare) proxy case, fall back to the dynamic read + * so behavior is unchanged, and record that the traps ran. + */ +function readProxyAware( + value: unknown, + viaIntrinsic: (v: unknown) => T, + viaTrap: (v: unknown) => T +): T { + if (isProxy(value)) { + recordProxy(value as object); + return viaTrap(value); + } + return viaIntrinsic(value); +} + +/** `URL.prototype.href` getter. Guard with {@link canReadUrl}. */ +export const urlHref = (value: unknown): string => + readProxyAware( + value, + get.urlHref as (v: unknown) => string, + (v) => (v as URL).href + ); +/** + * `URLSearchParams.prototype.size` getter. Guard with + * {@link canReadUrlSearchParams}. + */ +export const urlSearchParamsSize = (value: unknown): number => + readProxyAware( + value, + get.urlSearchParamsSize as (v: unknown) => number, + (v) => (v as URLSearchParams).size + ); +/** + * `URLSearchParams.prototype.toString`. Guard with + * {@link canReadUrlSearchParams}. + */ +export const urlSearchParamsToString = (value: unknown): string => + readProxyAware( + value, + call.urlSearchParamsToString as (v: unknown) => string, + (v) => String(v) + ); + +/** + * Iterates a genuine Map's entries entirely through host intrinsics: the + * iterator object is created by the host `Map.prototype.entries`, so its + * realm — and therefore its `next` — is the host's, not the sandbox's. + */ +export function mapToEntries( + value: Map +): [unknown, unknown][] { + return [...call.mapEntries(value)]; +} + +/** See {@link mapToEntries}. */ +export function setToValues(value: Set): unknown[] { + return [...call.setValues(value)]; +} + +/** + * Iterates a Headers instance through the captured host iterator. Headers + * is a host class injected into the sandbox, so instances are host-realm — + * but the shared prototype is reachable from workflow code, which makes the + * boot-time capture (rather than a live lookup) load-bearing. + */ +export function headersToEntries(value: Headers): [string, string][] { + return readProxyAware( + value, + (v) => [ + ...( + call.headersIterator as ( + h: Headers + ) => IterableIterator<[string, string]> + )(v as Headers), + ], + (v) => Array.from(v as Headers) + ); +} + +/** + * The bytes of an `ArrayBufferView`, read via internal-slot getters — + * own-property shadowing and prototype patches cannot change which bytes + * are serialized. + */ +export function viewInfo(value: ArrayBufferView): { + buffer: ArrayBufferLike; + byteOffset: number; + byteLength: number; +} { + const isDataView = types.isDataView(value); + const read = ( + dataViewGetter: ((v: unknown) => unknown) | undefined, + typedArrayGetter: ((v: unknown) => unknown) | undefined + ) => (isDataView ? dataViewGetter : typedArrayGetter)?.(value); + return { + buffer: read(get.dataViewBuffer, get.typedArrayBuffer) as ArrayBufferLike, + byteOffset: read( + get.dataViewByteOffset, + get.typedArrayByteOffset + ) as number, + byteLength: read( + get.dataViewByteLength, + get.typedArrayByteLength + ) as number, + }; +} + +/** `ArrayBuffer.prototype.byteLength` getter (internal slot read). */ +export function arrayBufferByteLength(value: ArrayBuffer): number { + return (get.arrayBufferByteLength as (v: unknown) => number)(value); +} + +// ---- Hardened devalue operations --------------------------------------------- +// +// The workflow reducers claim most special types before devalue's built-in +// handling runs, so these operations mainly govern plain objects, arrays, +// boxed primitives, thenable probes — and classification (`tagOf`), which +// runs for every object the reducers did not claim. + +const KNOWN_VIEW_TAGS = new Set([ + 'Int8Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Float16Array', + 'Int32Array', + 'Uint32Array', + 'Float32Array', + 'Float64Array', + 'BigInt64Array', + 'BigUint64Array', +]); + +/** + * Tags that {@link brandOf} decides authoritatively. If the brand check said + * "not one of these" then a `Symbol.toStringTag` claiming one is a spoof, and + * honouring it would route the value into an extractor that requires the real + * internal slot (`Object.prototype.toString` semantics let any object claim + * `[object Date]`). Unbranded values that claim one of these are classified + * as plain objects instead. + */ +const BRAND_DECIDED_TAGS = new Set([ + 'Date', + 'RegExp', + 'Map', + 'Set', + 'Array', + 'DataView', + 'ArrayBuffer', + 'SharedArrayBuffer', + 'Number', + 'String', + 'Boolean', + 'BigInt', + 'URL', + 'URLSearchParams', + ...KNOWN_VIEW_TAGS, +]); + +/** + * Classifies a value by engine brand. Returns undefined when no brand + * matches (the caller falls back to `Symbol.toStringTag` semantics). + */ +function brandOf(value: object): string | undefined { + if (types.isDate(value)) return 'Date'; + if (types.isRegExp(value)) return 'RegExp'; + if (types.isMap(value)) return 'Map'; + if (types.isSet(value)) return 'Set'; + if (Array.isArray(value)) return 'Array'; + if (types.isTypedArray(value)) { + const tag = get.typedArrayTag?.(value); + return tag !== undefined && KNOWN_VIEW_TAGS.has(tag) ? tag : undefined; + } + if (types.isDataView(value)) return 'DataView'; + if (types.isArrayBuffer(value)) return 'ArrayBuffer'; + if (types.isSharedArrayBuffer(value)) return 'SharedArrayBuffer'; + if (types.isNumberObject(value)) return 'Number'; + if (types.isStringObject(value)) return 'String'; + if (types.isBooleanObject(value)) return 'Boolean'; + if (types.isBigIntObject(value)) return 'BigInt'; + if (canReadUrl && isInstanceOfPrototype(value, URL.prototype)) return 'URL'; + if ( + canReadUrlSearchParams && + isInstanceOfPrototype(value, URLSearchParams.prototype) + ) { + return 'URLSearchParams'; + } + return undefined; +} + +/** + * Reads `Symbol.toStringTag` the way `Object.prototype.toString` would, + * but through descriptors — a data-property tag (the common case, e.g. + * Temporal polyfills) costs no workflow-code execution; an accessor tag is + * invoked (compat) and recorded. + */ +function readToStringTag(value: object): string | undefined { + const tag = readProperty(value, Symbol.toStringTag); + return typeof tag === 'string' ? tag : undefined; +} + +export const hardenedStringifyOperations: Partial = { + tagOf: (value: object) => { + if (isProxy(value)) { + // A proxy's classification is answered by its traps — that is the + // only access path there is. Record it and preserve today's + // behavior for everything downstream. + recordProxy(value); + return defaultStringifyOperations.tagOf(value); + } + const brand = brandOf(value); + if (brand !== undefined) return brand; + // No engine brand matched. A `Symbol.toStringTag` is still meaningful for + // types devalue identifies that way (`Temporal.*`), but one naming a + // brand-decided type is a spoof and is ignored. + const tag = readToStringTag(value); + if (tag === undefined || BRAND_DECIDED_TAGS.has(tag)) return 'Object'; + return tag; + }, + + isThenable: (value: { then?: unknown }) => { + if (types.isPromise(value)) return true; + return typeof readProperty(value, 'then') === 'function'; + }, + + unbox: (boxed: object) => { + if (types.isNumberObject(boxed)) return call.numberValueOf(boxed); + if (types.isStringObject(boxed)) return call.stringValueOf(boxed); + if (types.isBooleanObject(boxed)) return call.booleanValueOf(boxed); + return call.bigIntValueOf(boxed); + }, + + toISOString: (date: Date) => + Number.isNaN(call.dateGetDate(date)) ? '' : call.dateToISOString(date), + + toStringValue: (value: object) => { + // Reached for URL / URLSearchParams (when the reducers did not claim + // them, e.g. instances of a different realm's classes) and for + // toStringTag-branded objects like Temporal polyfills, whose string + // form only their own toString() can produce. + if (canReadUrl && isInstanceOfPrototype(value, URL.prototype)) { + return urlHref(value); + } + if ( + canReadUrlSearchParams && + isInstanceOfPrototype(value, URLSearchParams.prototype) + ) { + return urlSearchParamsToString(value); + } + recordGuestCode( + 'method', + `toString (${readToStringTag(value) ?? 'unknown'})` + ); + return (value as { toString(): string }).toString(); + }, + + regExpInfo: (regexp: RegExp) => ({ + source: regExpSource(regexp), + flags: regExpFlags(regexp), + }), + + valuesOf: (set: Set) => setToValues(set), + entriesOf: (map: Map) => + mapToEntries(map) as [unknown, unknown][], + + viewInfo: (view: ArrayBufferView) => { + const info = viewInfo(view); + const bufferByteLength = types.isSharedArrayBuffer(info.buffer) + ? (get.sharedArrayBufferByteLength?.(info.buffer) as number) + : arrayBufferByteLength(info.buffer as ArrayBuffer); + return { + ...info, + length: types.isDataView(view) + ? 0 + : (get.typedArrayLength?.(view) as number), + bufferByteLength, + }; + }, + + shapeOf: (value: object) => { + if (isProxy(value)) recordProxy(value); + return defaultStringifyOperations.shapeOf(value); + }, + + get: (value: object, key: string | number) => readProperty(value, key), +}; diff --git a/packages/core/src/serialization/index.ts b/packages/core/src/serialization/index.ts index 5416f3bc44..8fe3d68213 100644 --- a/packages/core/src/serialization/index.ts +++ b/packages/core/src/serialization/index.ts @@ -6,7 +6,7 @@ */ // Re-export codec interface and mode type -export type { Codec, SerializationMode } from './codec.js'; +export type { Codec, CodecOptions, SerializationMode } from './codec.js'; export { devalueCodec } from './codec-devalue.js'; // Re-export composable compression export { @@ -24,7 +24,6 @@ export { type EncryptionKeyParam, encrypt, } from './encryption.js'; - // Re-export format prefix utilities export { decodeFormatPrefix, @@ -32,6 +31,9 @@ export { isEncrypted, peekFormatPrefix, } from './format.js'; +// Re-export hardened-serialization observation types (populated via +// `CodecOptions.guestCodeStats`) +export type { GuestCodeExecution, GuestCodeStats } from './hardened.js'; // Re-export types export type { FormatPrefix, diff --git a/packages/core/src/serialization/reducers/class.ts b/packages/core/src/serialization/reducers/class.ts index 8a22bc03b9..5fbcce92a8 100644 --- a/packages/core/src/serialization/reducers/class.ts +++ b/packages/core/src/serialization/reducers/class.ts @@ -8,6 +8,7 @@ import { WORKFLOW_DESERIALIZE, WORKFLOW_SERIALIZE } from '@workflow/serde'; import { getSerializationClass } from '../../class-serialization.js'; +import { readProperty, recordGuestCode } from '../hardened.js'; import type { Reducers, Revivers } from '../types.js'; // ---- Reducers ---- @@ -19,25 +20,29 @@ export function getClassReducers(): Partial { // over the generic Error serialization (devalue uses first-match-wins). Class: (value) => { if (typeof value !== 'function') return false; - const classId = (value as any).classId; + const classId = readProperty(value, 'classId'); if (typeof classId !== 'string') return false; return { classId }; }, Instance: (value) => { if (value === null || typeof value !== 'object') return false; - const cls = value.constructor; + const cls = readProperty(value, 'constructor'); if (!cls || typeof cls !== 'function') return false; - const serialize = cls[WORKFLOW_SERIALIZE]; + const serialize = readProperty(cls, WORKFLOW_SERIALIZE); if (typeof serialize !== 'function') return false; - const classId = cls.classId; + const classId = readProperty(cls, 'classId'); if (typeof classId !== 'string') { throw new Error( - `Class "${cls.name}" with ${String(WORKFLOW_SERIALIZE)} must have a static "classId" property.` + `Class "${String(readProperty(cls, 'name'))}" with ${String(WORKFLOW_SERIALIZE)} must have a static "classId" property.` ); } + // Custom serializers are workflow code by definition — the data only + // exists behind them. Record the execution so retention-aware callers + // can account for possible VM-state perturbation. + recordGuestCode('method', `[WORKFLOW_SERIALIZE] (${classId})`); const data = serialize.call(cls, value); return { classId, data }; }, diff --git a/packages/core/src/serialization/reducers/common.ts b/packages/core/src/serialization/reducers/common.ts index 6ad8b773ff..6e4b1b7eda 100644 --- a/packages/core/src/serialization/reducers/common.ts +++ b/packages/core/src/serialization/reducers/common.ts @@ -16,6 +16,28 @@ import { RetryableError, RuntimeDecryptionError, } from '@workflow/errors'; +import { + arrayBufferByteLength, + canReadHeaders, + canReadUrl, + canReadUrlSearchParams, + dateGetDate, + dateGetTime, + dateToISOString, + hasProperty, + headersToEntries, + isInstanceOfPrototype, + mapToEntries, + readProperty, + recordGuestCode, + regExpFlags, + regExpSource, + setToValues, + urlHref, + urlSearchParamsSize, + urlSearchParamsToString, + viewInfo, +} from '../hardened.js'; import type { Reducers, Revivers, SerializableSpecial } from '../types.js'; // ---- Base64 helpers ---- @@ -33,7 +55,12 @@ function arrayBufferToBase64( } function viewToBase64(value: ArrayBufferView): string { - return arrayBufferToBase64(value.buffer, value.byteOffset, value.byteLength); + // Read the view's range through internal-slot getters (see hardened.ts): + // own properties shadowing `buffer`/`byteOffset`/`byteLength` — or patched + // prototype getters in the sandbox realm — cannot change which bytes are + // serialized. + const info = viewInfo(value); + return arrayBufferToBase64(info.buffer, info.byteOffset, info.byteLength); } function reviveArrayBuffer( @@ -96,11 +123,14 @@ type SimpleErrorSubclassKey = { */ function reduceErrorBase(value: unknown): BaseErrorPayload | false { if (!types.isNativeError(value)) return false; + // `message`/`stack`/`cause` are own data properties on natural errors, so + // the descriptor-based reads cost nothing; a sandbox-defined accessor + // (e.g. a getter on an Error subclass) is still invoked but recorded. const reduced: BaseErrorPayload = { - message: value.message, - stack: value.stack, + message: readProperty(value, 'message') as string, + stack: readProperty(value, 'stack') as string | undefined, }; - if ('cause' in value) reduced.cause = (value as { cause: unknown }).cause; + if (hasProperty(value, 'cause')) reduced.cause = readProperty(value, 'cause'); return reduced; } @@ -127,7 +157,7 @@ function reduceNamedErrorSubclassBase( value: unknown ): BaseErrorPayload | false { if (!types.isNativeError(value)) return false; - if (value.name !== subclassName) return false; + if (readProperty(value, 'name') !== subclassName) return false; return reduceErrorBase(value); } @@ -170,21 +200,32 @@ function makeErrorSubclassReviver( // ---- Reducers ---- export function getCommonReducers( - global: Record = globalThis + // The `global` parameter is retained for API compatibility, but the + // reducers no longer perform `instanceof global.X` checks: classification + // is done with engine-level brand checks (realm-agnostic, immune to + // reassigned sandbox globals and `Symbol.hasInstance`), and extraction + // goes through intrinsics captured at host boot. See ../hardened.ts. + _global: Record = globalThis ): Partial { return { ArrayBuffer: (value) => - value instanceof global.ArrayBuffer && - arrayBufferToBase64(value, 0, value.byteLength), - BigInt: (value) => typeof value === 'bigint' && value.toString(), + types.isArrayBuffer(value) && + arrayBufferToBase64(value, 0, arrayBufferByteLength(value)), + BigInt: (value) => + // String(bigint) is a spec-internal numeric conversion — unlike + // `value.toString()`, it never consults BigInt.prototype. + typeof value === 'bigint' && String(value), BigInt64Array: (value) => - value instanceof global.BigInt64Array && viewToBase64(value), + types.isBigInt64Array(value) && viewToBase64(value), BigUint64Array: (value) => - value instanceof global.BigUint64Array && viewToBase64(value), + types.isBigUint64Array(value) && viewToBase64(value), Date: (value) => { - if (!(value instanceof global.Date)) return false; - const valid = !Number.isNaN(value.getDate()); - return valid ? value.toISOString() : '.'; + // Brand check + captured intrinsics: a sandbox-side patch of + // `Date.prototype.toISOString` (e.g. a Temporal polyfill wrapping it) + // is never executed, and cannot perturb VM state during serialization. + if (!types.isDate(value)) return false; + const valid = !Number.isNaN(dateGetDate(value)); + return valid ? dateToISOString(value) : '.'; }, // DOMException is a special case: it `instanceof Error` is true in Node, // but `types.isNativeError()` returns FALSE for it, so the generic Error @@ -194,18 +235,16 @@ export function getCommonReducers( // for instances minted in another context). DOMException: (value) => { if (value === null || typeof value !== 'object') return false; - if ( - (value as { constructor?: { name?: string } }).constructor?.name !== - 'DOMException' - ) - return false; - const e = value as Error & { cause?: unknown }; + const ctor = readProperty(value, 'constructor'); + if (!ctor || readProperty(ctor, 'name') !== 'DOMException') return false; const reduced: SerializableSpecial['DOMException'] = { - message: e.message, - name: e.name, - stack: e.stack, + message: readProperty(value, 'message') as string, + name: readProperty(value, 'name') as string, + stack: readProperty(value, 'stack') as string | undefined, }; - if ('cause' in e) reduced.cause = e.cause; + if (hasProperty(value, 'cause')) { + reduced.cause = readProperty(value, 'cause'); + } return reduced; }, // Error subclass reducers are intentionally placed before the base Error @@ -218,13 +257,15 @@ export function getCommonReducers( HookConflictError: (value) => { const base = reduceNamedErrorSubclassBase('HookConflictError', value); if (!base) return false; - const error = value as HookConflictError; const reduced: SerializableSpecial['HookConflictError'] = { ...base, - token: error.token, + token: readProperty(value, 'token') as HookConflictError['token'], }; - if (error.conflictingRunId !== undefined) { - reduced.conflictingRunId = error.conflictingRunId; + const conflictingRunId = readProperty(value, 'conflictingRunId') as + | HookConflictError['conflictingRunId'] + | undefined; + if (conflictingRunId !== undefined) { + reduced.conflictingRunId = conflictingRunId; } return reduced; }, @@ -237,15 +278,22 @@ export function getCommonReducers( RetryableError: (value) => { const base = reduceNamedErrorSubclassBase('RetryableError', value); if (!base) return false; - const retryAfterRaw = (value as RetryableError).retryAfter as unknown; + const retryAfterRaw = readProperty(value, 'retryAfter'); let retryAfter: number; - if ( - retryAfterRaw && - typeof retryAfterRaw === 'object' && - typeof (retryAfterRaw as { getTime?: unknown }).getTime === 'function' - ) { - const t = (retryAfterRaw as Date).getTime(); + if (types.isDate(retryAfterRaw)) { + // Genuine Date (any realm): read the epoch through the intrinsic. + const t = dateGetTime(retryAfterRaw); retryAfter = Number.isNaN(t) ? Date.now() + 1000 : t; + } else if (retryAfterRaw && typeof retryAfterRaw === 'object') { + // Duck-typed date-like: invoking its getTime() runs workflow code. + const getTime = readProperty(retryAfterRaw, 'getTime'); + if (typeof getTime === 'function') { + recordGuestCode('method', 'getTime'); + const t = (getTime as (this: unknown) => number).call(retryAfterRaw); + retryAfter = Number.isNaN(t) ? Date.now() + 1000 : t; + } else { + retryAfter = Date.now() + 1000; + } } else if ( typeof retryAfterRaw === 'string' || typeof retryAfterRaw === 'number' @@ -272,7 +320,9 @@ export function getCommonReducers( const reduced: SerializableSpecial['RuntimeDecryptionError'] = { ...base, }; - const context = (value as RuntimeDecryptionError).context; + const context = readProperty(value, 'context') as + | RuntimeDecryptionError['context'] + | undefined; if (context !== undefined) { reduced.context = context; } @@ -288,7 +338,7 @@ export function getCommonReducers( if (!base) return false; return { ...base, - errors: (value as AggregateError).errors, + errors: readProperty(value, 'errors') as AggregateError['errors'], } satisfies SerializableSpecial['AggregateError']; }, // Base Error reducer — catch-all for any Error instance not matched by a @@ -298,59 +348,70 @@ export function getCommonReducers( Error: (value) => { if (!types.isNativeError(value)) return false; const reduced: SerializableSpecial['Error'] = { - name: value.name, - message: value.message, - stack: value.stack, + name: readProperty(value, 'name') as string, + message: readProperty(value, 'message') as string, + stack: readProperty(value, 'stack') as string | undefined, }; - if ('cause' in value) reduced.cause = value.cause; + if (hasProperty(value, 'cause')) { + reduced.cause = readProperty(value, 'cause'); + } return reduced; }, - Float32Array: (value) => - value instanceof global.Float32Array && viewToBase64(value), - Float64Array: (value) => - value instanceof global.Float64Array && viewToBase64(value), - Headers: (value) => value instanceof global.Headers && Array.from(value), - Int8Array: (value) => - value instanceof global.Int8Array && viewToBase64(value), - Int16Array: (value) => - value instanceof global.Int16Array && viewToBase64(value), - Int32Array: (value) => - value instanceof global.Int32Array && viewToBase64(value), - Map: (value) => value instanceof global.Map && Array.from(value), + Float32Array: (value) => types.isFloat32Array(value) && viewToBase64(value), + Float64Array: (value) => types.isFloat64Array(value) && viewToBase64(value), + // Headers is a host class injected into the sandbox, so its (shared) + // prototype is reachable from workflow code — iterate through the + // boot-captured iterator instead of a live Symbol.iterator lookup. + Headers: (value) => + canReadHeaders && + isInstanceOfPrototype(value, Headers.prototype) && + headersToEntries(value as Headers), + Int8Array: (value) => types.isInt8Array(value) && viewToBase64(value), + Int16Array: (value) => types.isInt16Array(value) && viewToBase64(value), + Int32Array: (value) => types.isInt32Array(value) && viewToBase64(value), + // Engine brand check + host-realm iteration: a patched + // `Map.prototype[Symbol.iterator]` in the sandbox is never consulted. + Map: (value) => + types.isMap(value) && mapToEntries(value as Map), RegExp: (value) => - value instanceof global.RegExp && { - source: value.source, - flags: value.flags, + types.isRegExp(value) && { + source: regExpSource(value), + flags: regExpFlags(value), }, // Request and Response are intentionally NOT in common reducers. // They require mode-specific revivers (stream handling, etc.) and // including them here without matching revivers would cause them // to deserialize as plain objects. - Set: (value) => value instanceof global.Set && Array.from(value), - URL: (value) => value instanceof global.URL && value.href, + Set: (value) => types.isSet(value) && setToValues(value as Set), + URL: (value) => + canReadUrl && + isInstanceOfPrototype(value, URL.prototype) && + urlHref(value), WorkflowFunction: (value) => { // Only match function references with a workflowId property (set by // the SWC compiler on workflow functions). Plain { workflowId } objects // are NOT matched — this prevents infinite recursion since the reduced // form { workflowId } is a plain object, not a function. if (typeof value !== 'function') return false; - const workflowId = (value as any).workflowId; + const workflowId = readProperty(value, 'workflowId'); if (typeof workflowId !== 'string') return false; return { workflowId }; }, URLSearchParams: (value) => { - if (!(value instanceof global.URLSearchParams)) return false; - if (value.size === 0) return '.'; - return String(value); + if ( + !canReadUrlSearchParams || + !isInstanceOfPrototype(value, URLSearchParams.prototype) + ) { + return false; + } + if (urlSearchParamsSize(value) === 0) return '.'; + return urlSearchParamsToString(value); }, - Uint8Array: (value) => - value instanceof global.Uint8Array && viewToBase64(value), + Uint8Array: (value) => types.isUint8Array(value) && viewToBase64(value), Uint8ClampedArray: (value) => - value instanceof global.Uint8ClampedArray && viewToBase64(value), - Uint16Array: (value) => - value instanceof global.Uint16Array && viewToBase64(value), - Uint32Array: (value) => - value instanceof global.Uint32Array && viewToBase64(value), + types.isUint8ClampedArray(value) && viewToBase64(value), + Uint16Array: (value) => types.isUint16Array(value) && viewToBase64(value), + Uint32Array: (value) => types.isUint32Array(value) && viewToBase64(value), }; } diff --git a/packages/core/src/serialization/reducers/step-function.ts b/packages/core/src/serialization/reducers/step-function.ts index 59a2e31ff0..7b57ce581e 100644 --- a/packages/core/src/serialization/reducers/step-function.ts +++ b/packages/core/src/serialization/reducers/step-function.ts @@ -18,6 +18,12 @@ * round trip. */ +import { + hasProperty, + isUseStepClosureFn, + readProperty, + recordGuestCode, +} from '../hardened.js'; import type { Reducers, Revivers } from '../types.js'; // ---- Reducer ---- @@ -26,14 +32,26 @@ export function getStepFunctionReducer(): Partial { return { StepFunction: (value) => { if (typeof value !== 'function') return false; - const stepId = (value as any).stepId; + const stepId = readProperty(value, 'stepId'); if (typeof stepId !== 'string') return false; - const closureVarsFn = (value as any).__closureVarsFn; - const closureVars = - closureVarsFn && typeof closureVarsFn === 'function' - ? closureVarsFn() - : undefined; + const closureVarsFn = readProperty(value, '__closureVarsFn'); + // The reducer has to invoke this to read the step's captured closure + // variables. The compiler-generated function is a sequence of lexical + // reads and cannot perturb observable VM state, so reporting it would + // flag every step that captures a variable — but the property is + // reachable from workflow code, which can replace it with anything. + // `step.ts` marks the function that came through `useStep` when it + // builds the proxy, so this is checked rather than assumed; anything + // unrecognized is reported like other guest code. See + // `markUseStepClosureFn` for what the mark does and does not prove. + let closureVars: Record | undefined; + if (typeof closureVarsFn === 'function') { + if (!isUseStepClosureFn(closureVarsFn)) { + recordGuestCode('method', '__closureVarsFn'); + } + closureVars = (closureVarsFn as () => Record)(); + } // `__boundThis` / `__boundArgs` are marker properties added by the // step proxy's overridden `.bind` (see step.ts) to record the @@ -42,9 +60,13 @@ export function getStepFunctionReducer(): Partial { // `undefined`/`null`. `__boundArgs` is only set when the user // actually supplied prefilled args, so a missing property means // "no prefilled args". - const hasBoundThis = '__boundThis' in (value as any); - const boundThis = hasBoundThis ? (value as any).__boundThis : undefined; - const boundArgs = (value as any).__boundArgs as unknown[] | undefined; + const hasBoundThis = hasProperty(value, '__boundThis'); + const boundThis = hasBoundThis + ? readProperty(value, '__boundThis') + : undefined; + const boundArgs = readProperty(value, '__boundArgs') as + | unknown[] + | undefined; const payload: { stepId: string; diff --git a/packages/core/src/step.ts b/packages/core/src/step.ts index 7801b25d6d..c6dbb13494 100644 --- a/packages/core/src/step.ts +++ b/packages/core/src/step.ts @@ -10,6 +10,7 @@ import { type WorkflowOrchestratorContext, } from './private.js'; import type { Serializable } from './schemas.js'; +import { markUseStepClosureFn } from './serialization/hardened.js'; import { hydrateStepError, hydrateStepReturnValue } from './serialization.js'; export function createUseStep(ctx: WorkflowOrchestratorContext) { @@ -363,8 +364,14 @@ export function createUseStep(ctx: WorkflowOrchestratorContext) { configurable: false, }); - // Store the closure variables function for serialization + // Store the closure variables function for serialization. Mark it so the + // step-function reducer can tell a function that came through `useStep` + // apart from one workflow code assigned over the property afterwards — + // the reducer has to invoke whatever is there, and only the latter is + // worth reporting. See `markUseStepClosureFn` for the limits of what + // this proves. if (closureVarsFn) { + markUseStepClosureFn(closureVarsFn); Object.defineProperty(stepFunction, '__closureVarsFn', { value: closureVarsFn, writable: false, diff --git a/packages/core/src/telemetry/semantic-conventions.ts b/packages/core/src/telemetry/semantic-conventions.ts index 8ab58fe88b..f2991767d9 100644 --- a/packages/core/src/telemetry/semantic-conventions.ts +++ b/packages/core/src/telemetry/semantic-conventions.ts @@ -472,6 +472,22 @@ export const SerializationCompressionRatio = SemanticConvention( 'workflow.serialization.compression_ratio' ); +/** + * Number of workflow (guest) code executions serialization could not avoid + * (getters, proxies, custom serializers); set only when non-zero. + */ +export const SerializationGuestCodeExecutions = SemanticConvention( + 'workflow.serialization.guest_code_executions' +); + +/** + * Deduplicated `kind (detail)` descriptions of the guest-code executions; + * set only when non-zero. + */ +export const SerializationGuestCodeDetails = SemanticConvention( + 'workflow.serialization.guest_code_details' +); + // RPC/Peer Service attributes - For service maps and dependency tracking // See: https://opentelemetry.io/docs/specs/semconv/rpc/rpc-spans/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index da1422c2b2..4bcb7b0a5c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,7 +61,7 @@ catalogs: overrides: '@opentelemetry/api': 1.9.1 rfc6902: 5.1.2 - devalue: 5.8.1 + devalue: 5.9.0 '@sveltejs/acorn-typescript': 1.0.10 postcss@<8.5.18: 8.5.22 @@ -547,8 +547,8 @@ importers: specifier: 4.4.3 version: 4.4.3(supports-color@8.1.1) devalue: - specifier: 5.8.1 - version: 5.8.1 + specifier: 5.9.0 + version: 5.9.0 ms: specifier: 2.1.3 version: 2.1.3 @@ -11435,8 +11435,8 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} - devalue@5.8.1: - resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + devalue@5.9.0: + resolution: {integrity: sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==} devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -20004,7 +20004,7 @@ snapshots: consola: 3.4.2 defu: 6.1.7 destr: 2.0.5 - devalue: 5.8.1 + devalue: 5.9.0 errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -20074,7 +20074,7 @@ snapshots: consola: 3.4.2 defu: 6.1.7 destr: 2.0.5 - devalue: 5.8.1 + devalue: 5.9.0 errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -25015,7 +25015,7 @@ snapshots: '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 - devalue: 5.8.1 + devalue: 5.9.0 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 @@ -25037,7 +25037,7 @@ snapshots: '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 - devalue: 5.8.1 + devalue: 5.9.0 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 @@ -25059,7 +25059,7 @@ snapshots: '@types/cookie': 0.6.0 acorn: 8.16.0 cookie: 0.6.0 - devalue: 5.8.1 + devalue: 5.9.0 esm-env: 1.2.2 kleur: 4.1.5 magic-string: 0.30.21 @@ -27109,7 +27109,7 @@ snapshots: clsx: 2.1.1 common-ancestor-path: 2.0.0 cookie: 1.1.1 - devalue: 5.8.1 + devalue: 5.9.0 diff: 8.0.3 dset: 3.1.4 es-module-lexer: 2.0.0 @@ -27201,7 +27201,7 @@ snapshots: clsx: 2.1.1 common-ancestor-path: 2.0.0 cookie: 1.1.1 - devalue: 5.8.1 + devalue: 5.9.0 diff: 8.0.3 dset: 3.1.4 es-module-lexer: 2.0.0 @@ -28415,7 +28415,7 @@ snapshots: detect-node-es@1.1.0: {} - devalue@5.8.1: {} + devalue@5.9.0: {} devlop@1.1.0: dependencies: @@ -32052,7 +32052,7 @@ snapshots: consola: 3.4.2 cookie-es: 3.1.1 defu: 6.1.7 - devalue: 5.8.1 + devalue: 5.9.0 errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -32181,7 +32181,7 @@ snapshots: consola: 3.4.2 cookie-es: 3.1.1 defu: 6.1.7 - devalue: 5.8.1 + devalue: 5.9.0 errx: 0.1.0 escape-string-regexp: 5.0.0 exsolve: 1.0.8 @@ -34790,7 +34790,7 @@ snapshots: aria-query: 5.3.1 axobject-query: 4.1.0 clsx: 2.1.1 - devalue: 5.8.1 + devalue: 5.9.0 esm-env: 1.2.2 esrap: 2.2.13(@typescript-eslint/types@8.46.4) is-reference: 3.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 52da4f2ff1..4b1fbf6a3c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -36,7 +36,7 @@ overrides: # spans were silently dropped this way while core's flowed (#2900). '@opentelemetry/api': 1.9.1 rfc6902: 5.1.2 - devalue: 5.8.1 + devalue: 5.9.0 '@sveltejs/acorn-typescript': 1.0.10 # postcss < 8.5.18 is vulnerable to CVE-2026-45623 and GHSA-r28c-9q8g-f849 # (arbitrary file read / .map disclosure via attacker-controlled