Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/vm-determinism-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@workflow/core': patch
'workflow': patch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this should be major, not patch.

Removing WeakRef, FinalizationRegistry, Atomics.waitAsync, and async WebAssembly compilation from the sandbox is a breaking change to the public workflow-globals surface (the thread itself frames it as one). On main the bump type doesn't affect beta numbering — but per the repo's versioning policy it does carry through to stable backports: as written, a backport would remove APIs from v4 in a patch release, which violates semver for anyone pinned to ~4.x.

Marking it major both signals the break correctly and forces the backport decision to be deliberate rather than auto-mergeable. (If the intent is that this ships v5-only, major also matches that: the human reviewing the auto-backport PR gets an obvious signal to close it.)

Suggested:

---
'@workflow/core': major
'workflow': major
---

---

Deterministic sandbox hardening: `crypto.subtle.digest` in workflow functions now computes synchronously via `node:crypto` (byte-identical values, deterministic timing under replay); `Atomics.waitAsync`, async `WebAssembly` compilation, `WeakRef`, and `FinalizationRegistry` are no longer exposed (wall-clock timing and GC observation cannot be replayed).
5 changes: 4 additions & 1 deletion docs/content/docs/v5/api-reference/workflow-globals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ These APIs are available but are **seeded or fixed** to ensure deterministic beh
| [`Date`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date) / `Date.now()` / `new Date()` | Returns a fixed timestamp that advances with the workflow's logical clock |
| [`crypto.getRandomValues()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/getRandomValues) | Seeded — produces deterministic output for a given workflow run |
| [`crypto.randomUUID()`](https://developer.mozilla.org/en-US/docs/Web/API/Crypto/randomUUID) | Seeded — produces deterministic UUIDs for a given workflow run |
| [`crypto.subtle.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) | Passes through to the real implementation (SHA-256, etc. are deterministic by nature) |
| [`crypto.subtle.digest()`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest) | Computed synchronously via `node:crypto` (values are byte-identical to WebCrypto), so the promise settles at a deterministic point during replay |

<Callout type="info">
You can safely use `Math.random()`, `Date.now()`, and `crypto.randomUUID()` in workflow functions. The framework ensures these return the same values across replays.
Expand Down Expand Up @@ -100,3 +100,6 @@ The following are **not available** in workflow functions. Move this logic to [s
- **Global `fetch`**: Use [`import { fetch } from "workflow"`](/docs/api-reference/workflow/fetch) instead. See [fetch-in-workflow](/docs/errors/fetch-in-workflow).
- **Timers**: `setTimeout`, `setInterval`, `setImmediate`, and their `clear*` counterparts. Use [`sleep()`](/docs/api-reference/workflow/sleep) instead. See [timeout-in-workflow](/docs/errors/timeout-in-workflow).
- **`Buffer`**: Node.js-specific API. Use `Uint8Array` with `toBase64()` / `fromBase64()` / `toHex()` / `fromHex()` for binary data encoding, or `atob()` / `btoa()` for string-based base64.
- **`WeakRef` and `FinalizationRegistry`**: garbage-collection timing is not deterministic, so observing it would make workflow code impossible to replay. (`WeakMap` and `WeakSet` remain available — they do not expose GC state.)
- **`Atomics.waitAsync`**: a wall-clock timer, which cannot be replayed. Use [`sleep()`](/docs/api-reference/workflow/sleep) instead.
- **Async `WebAssembly` compilation** (`compile`, `instantiate`, `compileStreaming`, `instantiateStreaming`): resolves on compile-thread timing. The synchronous `new WebAssembly.Module()` and `new WebAssembly.Instance()` constructors remain available.
165 changes: 165 additions & 0 deletions packages/core/src/vm/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,24 @@ describe('createContext', () => {
expect(err?.message).toContain('not available inside a workflow function');
});

it('rejects the remaining async `crypto.subtle` methods immediately', async () => {
// These would settle on host threadpool timing (breaking the quiescence
// invariant a retained VM relies on), so they must stay unreachable:
// through the crypto proxy the receiver is not a real SubtleCrypto and
// the brand check rejects before any crypto work is scheduled.
const { context } = createContext({ seed, fixedTimestamp });
for (const call of [
'crypto.subtle.importKey("raw", new Uint8Array(16), { name: "AES-GCM" }, false, ["encrypt"])',
'crypto.subtle.encrypt({ name: "AES-GCM" }, {}, new Uint8Array(4))',
'crypto.subtle.sign("HMAC", {}, new Uint8Array(4))',
'crypto.subtle.deriveBits({ name: "PBKDF2" }, {}, 128)',
]) {
await expect(vm.runInContext(call, context)).rejects.toThrow(
/must be of type SubtleCrypto/
);
}
});

it('should call `onWorkflowError` when a workflow error occurs', async () => {
const { context } = createContext({ seed, fixedTimestamp });

Expand Down Expand Up @@ -257,3 +275,150 @@ describe('createContext', () => {
expect(result).toBe('undefined');
});
});

describe('host-timed async', () => {
it('does not expose any API that settles on host timing', () => {
const { context } = createContext({ seed, fixedTimestamp });

expect(vm.runInContext('typeof Atomics.waitAsync', context)).toBe(
'undefined'
);
expect(vm.runInContext('typeof WebAssembly.compile', context)).toBe(
'undefined'
);
expect(vm.runInContext('typeof WebAssembly.instantiate', context)).toBe(
'undefined'
);
expect(
vm.runInContext('typeof WebAssembly.compileStreaming', context)
).toBe('undefined');
expect(
vm.runInContext('typeof WebAssembly.instantiateStreaming', context)
).toBe('undefined');
// Deterministic synchronous WebAssembly stays available.
expect(vm.runInContext('typeof WebAssembly.Module', context)).toBe(
'function'
);
});
});

describe('crypto.subtle.digest', () => {
it.each([
'SHA-1',
'SHA-256',
'SHA-384',
'SHA-512',
])('matches WebCrypto for %s', async (algorithm) => {
const { context } = createContext({ seed, fixedTimestamp });

const result = await vm.runInContext(
`crypto.subtle.digest(${JSON.stringify(algorithm)}, new Uint8Array([1,2,3,4,5,255,0,128]))`,
context
);
const expected = await globalThis.crypto.subtle.digest(
algorithm,
new Uint8Array([1, 2, 3, 4, 5, 255, 0, 128])
);
expect(result).toBeInstanceOf(ArrayBuffer);
expect(new Uint8Array(result)).toEqual(new Uint8Array(expected));
});

it('accepts the { name } algorithm form and ArrayBuffer input', async () => {
const { context } = createContext({ seed, fixedTimestamp });

const result = await vm.runInContext(
'crypto.subtle.digest({ name: "sha-256" }, new ArrayBuffer(4))',
context
);
const expected = await globalThis.crypto.subtle.digest(
'SHA-256',
new ArrayBuffer(4)
);
expect(new Uint8Array(result)).toEqual(new Uint8Array(expected));
});

it('respects typed-array subviews', async () => {
const { context } = createContext({ seed, fixedTimestamp });

const result = await vm.runInContext(
'const bytes = new Uint8Array([9, 1, 2, 9]); crypto.subtle.digest("SHA-256", bytes.subarray(1, 3))',
context
);
const expected = await globalThis.crypto.subtle.digest(
'SHA-256',
new Uint8Array([1, 2])
);
expect(new Uint8Array(result)).toEqual(new Uint8Array(expected));
});

it('rejects unsupported algorithms like WebCrypto does', async () => {
const { context } = createContext({ seed, fixedTimestamp });

await expect(
vm.runInContext('crypto.subtle.digest("MD5", new Uint8Array(1))', context)
).rejects.toMatchObject({ name: 'NotSupportedError' });
});
});

describe('GC observation', () => {
it('does not expose WeakRef or FinalizationRegistry', () => {
const { context } = createContext({ seed, fixedTimestamp });

expect(vm.runInContext('typeof WeakRef', context)).toBe('undefined');
expect(vm.runInContext('typeof FinalizationRegistry', context)).toBe(
'undefined'
);
// WeakMap/WeakSet do not expose GC state and stay available.
expect(vm.runInContext('typeof WeakMap', context)).toBe('function');
expect(vm.runInContext('typeof WeakSet', context)).toBe('function');
});
});

describe('crypto.subtle.digest input validation', () => {
it.each([
['a number', '2000000000'],
['a plain object', '({})'],
['a string', '"data"'],
['null', 'null'],
])('rejects %s with TypeError', async (_label, expression) => {
const { context } = createContext({ seed, fixedTimestamp });

await expect(
vm.runInContext(`crypto.subtle.digest("SHA-256", ${expression})`, context)
).rejects.toThrow(TypeError);
});
});

describe('crypto.subtle.digest SharedArrayBuffer rejection', () => {
it('rejects SharedArrayBuffer-backed views like WebCrypto does', async () => {
const { context } = createContext({ seed, fixedTimestamp });

await expect(
vm.runInContext(
'crypto.subtle.digest("SHA-256", new Uint8Array(new SharedArrayBuffer(4)))',
context
)
).rejects.toThrow(TypeError);
});
});

describe('crypto.subtle.digest view metadata', () => {
it('reads view ranges from internal slots, ignoring shadowed properties', async () => {
const { context } = createContext({ seed, fixedTimestamp });

const result = await vm.runInContext(
`(() => {
const view = new Uint8Array([1, 2, 3, 4]);
Object.defineProperty(view, "byteLength", { value: 0 });
Object.defineProperty(view, "byteOffset", { value: 2 });
return crypto.subtle.digest("SHA-256", view);
})()`,
context
);
const expected = await globalThis.crypto.subtle.digest(
'SHA-256',
new Uint8Array([1, 2, 3, 4])
);
expect(new Uint8Array(result)).toEqual(new Uint8Array(expected));
});
});
125 changes: 123 additions & 2 deletions packages/core/src/vm/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { createHash } from 'node:crypto';
import { types } from 'node:util';
import { runInContext, createContext as vmCreateContext } from 'node:vm';
import { WorkflowRuntimeError } from '@workflow/errors';
import seedrandom from 'seedrandom';
Expand All @@ -10,6 +12,69 @@ export interface CreateContextOptions {
fixedTimestamp: number;
}

// WebCrypto digest algorithm names → node:crypto (OpenSSL) names.
const DIGEST_ALGORITHMS: Record<string, string> = {
'SHA-1': 'sha1',
'SHA-256': 'sha256',
'SHA-384': 'sha384',
'SHA-512': 'sha512',
};

// Intrinsic prototype getters, captured so view metadata is read from
// internal slots like WebCrypto's BufferSource conversion — own properties
// shadowing `buffer`/`byteOffset`/`byteLength` on a view must not change
// which bytes are hashed.
function intrinsicGetter(prototype: object, name: string) {
// biome-ignore lint/style/noNonNullAssertion: intrinsic accessors always exist
return Object.getOwnPropertyDescriptor(prototype, name)!.get!;
}
const arrayBufferByteLength = intrinsicGetter(
ArrayBuffer.prototype,
'byteLength'
);
const typedArrayPrototype = Object.getPrototypeOf(
Uint8Array.prototype
) as object;
const viewGetters = {
typedArray: {
buffer: intrinsicGetter(typedArrayPrototype, 'buffer'),
byteOffset: intrinsicGetter(typedArrayPrototype, 'byteOffset'),
byteLength: intrinsicGetter(typedArrayPrototype, 'byteLength'),
},
dataView: {
buffer: intrinsicGetter(DataView.prototype, 'buffer'),
byteOffset: intrinsicGetter(DataView.prototype, 'byteOffset'),
byteLength: intrinsicGetter(DataView.prototype, 'byteLength'),
},
};

// WebCrypto BufferSource conversion: typed-array/DataView views are read via
// internal slots; anything else must be a real ArrayBuffer (the native
// byteLength getter is a brand check that works across vm realms) so that
// e.g. a plain number is rejected with TypeError instead of allocating a
// Uint8Array of that length.
function toDigestBytes(data: ArrayBuffer | ArrayBufferView): Uint8Array {
if (types.isTypedArray(data) || types.isDataView(data)) {
const getters = types.isDataView(data)
? viewGetters.dataView
: viewGetters.typedArray;
const buffer = getters.buffer.call(data) as ArrayBuffer;
// WebCrypto's BufferSource excludes SharedArrayBuffer-backed views.
if (types.isSharedArrayBuffer(buffer)) {
throw new TypeError(
'crypto.subtle.digest does not accept SharedArrayBuffer-backed views'
);
}
return new Uint8Array(
buffer,
getters.byteOffset.call(data) as number,
getters.byteLength.call(data) as number
);
}
arrayBufferByteLength.call(data);
return new Uint8Array(data as ArrayBuffer);
}

/**
* Creates a Node.js `vm.Context` configured to be usable for
* executing workflow logic in a deterministic environment.
Expand Down Expand Up @@ -58,7 +123,63 @@ export function createContext(options: CreateContextOptions) {

const randomUUID = createRandomUUID(rng);

const boundDigest = originalSubtle.digest.bind(originalSubtle);
// The sandbox must not expose any way for workflow code to observe host
// timing or host state: after this block, every promise a workflow can
// create settles either from the event log or within its own microtask
// cascade, so a suspended VM is fully quiescent and can be retained across
// inline steps.
//
// - `Atomics.waitAsync` is a wall-clock timer (via SharedArrayBuffer).
// - The async `WebAssembly` entry points resolve on compile-thread timing;
// the synchronous `new WebAssembly.Module()` / `Instance()` remain.
// - `WeakRef.deref()` and finalizer callbacks observe GC timing.
// - Dynamic `import()` settles within a microtask (rejected: no
// `importModuleDynamically`), so it needs no handling.
// - The remaining async `crypto.subtle` methods (`encrypt`, `sign`,
// `importKey`, …) would settle on host threadpool timing, but none of
// them are usable: invoked through the crypto proxy below the receiver
// is not a real SubtleCrypto, so the brand check rejects immediately
// ("Value of 'this' must be of type SubtleCrypto") before any crypto
// work is scheduled — a deterministic microtask rejection. Only the
// deterministic overrides (`digest`, `getRandomValues`, `randomUUID`)
// and the explicit `generateKey` error do real work — locked in by a
// test. Do not "fix" those methods by binding them to the host subtle:
// that would hand workflows a host-timing promise.
const intrinsics = g as unknown as Record<string, Record<string, unknown>>;
delete intrinsics.Atomics.waitAsync;
delete intrinsics.WebAssembly.compile;
delete intrinsics.WebAssembly.instantiate;
delete intrinsics.WebAssembly.compileStreaming;
delete intrinsics.WebAssembly.instantiateStreaming;
delete intrinsics.WeakRef;
delete intrinsics.FinalizationRegistry;

// `crypto.subtle.digest` computes synchronously via node:crypto, so its
// promise settles on a deterministic microtask instead of host threadpool
// timing — a digest can never advance a suspended workflow, and
// digest-using VMs stay retainable. Values are byte-identical to WebCrypto.
const digest = (
algorithm: string | { name: string },
data: ArrayBuffer | ArrayBufferView
): Promise<ArrayBuffer> => {
try {
const name = typeof algorithm === 'string' ? algorithm : algorithm.name;
const ossl = DIGEST_ALGORITHMS[name.toUpperCase()];
if (!ossl) {
throw new DOMException(
`Unrecognized algorithm name: ${name}`,
'NotSupportedError'
);
}
const hash = createHash(ossl).update(toDigestBytes(data)).digest();
// Copy out: small Buffers share the internal pool allocation.
const out = new ArrayBuffer(hash.byteLength);
new Uint8Array(out).set(hash);
return Promise.resolve(out);
} catch (error) {
return Promise.reject(error);
}
};

g.crypto = new Proxy(originalCrypto, {
get(target, prop) {
Expand All @@ -78,7 +199,7 @@ export function createContext(options: CreateContextOptions) {
);
};
} else if (prop === 'digest') {
return boundDigest;
return digest;
}
return target[prop as keyof typeof originalSubtle];
},
Expand Down
Loading