From 98bd6ab233705a36ac2f55aa454b081a72f27f53 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Mon, 13 Apr 2026 09:19:39 +0000 Subject: [PATCH 1/3] fix: add input validation to createTimer() for NaN, Infinity, and invalid Date createTimer() silently accepted NaN, Infinity, and invalid Date objects, producing timers with corrupted protobuf Timestamps (defaulting to Unix epoch). This caused timers to fire immediately instead of at the intended time, with no error reported to the orchestrator author. Add validation for: - Non-finite number inputs (NaN, Infinity, -Infinity): throws with descriptive message - Invalid Date objects (NaN timestamp): throws with descriptive message - Valid inputs (finite numbers, valid Dates, zero, negatives): accepted as before Fixes #220 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../worker/runtime-orchestration-context.ts | 9 + .../test/createtimer-validation.spec.ts | 234 ++++++++++++++++++ 2 files changed, 243 insertions(+) create mode 100644 packages/durabletask-js/test/createtimer-validation.spec.ts diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index 157e0366..927b6bbd 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -299,7 +299,16 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { // If a number is passed, we use it as the number of seconds to wait // we use instanceof Date as number is not a native Javascript type if (!(fireAt instanceof Date)) { + if (typeof fireAt !== "number" || !Number.isFinite(fireAt)) { + throw new Error( + `createTimer requires a finite number (seconds) or a valid Date, but received ${String(fireAt)}`, + ); + } fireAt = new Date(this._currentUtcDatetime.getTime() + fireAt * 1000); + } else if (isNaN(fireAt.getTime())) { + throw new Error( + "createTimer received an invalid Date object (NaN timestamp)", + ); } const action = ph.newCreateTimerAction(id, fireAt); diff --git a/packages/durabletask-js/test/createtimer-validation.spec.ts b/packages/durabletask-js/test/createtimer-validation.spec.ts new file mode 100644 index 00000000..d89e47f8 --- /dev/null +++ b/packages/durabletask-js/test/createtimer-validation.spec.ts @@ -0,0 +1,234 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import { OrchestrationContext } from "../src/task/context/orchestration-context"; +import { + newExecutionStartedEvent, + newOrchestratorStartedEvent, +} from "../src/utils/pb-helper.util"; +import { OrchestrationExecutor, OrchestrationExecutionResult } from "../src/worker/orchestration-executor"; +import * as pb from "../src/proto/orchestrator_service_pb"; +import { Registry } from "../src/worker/registry"; +import { TOrchestrator } from "../src/types/orchestrator.type"; +import { NoOpLogger } from "../src/types/logger.type"; + +const testLogger = new NoOpLogger(); +const TEST_INSTANCE_ID = "test-createtimer-validation"; + +/** + * Helper to extract the CompleteOrchestrationAction from an OrchestrationExecutionResult + */ +function getCompleteAction( + result: OrchestrationExecutionResult, +): pb.CompleteOrchestrationAction | undefined { + const completeActions = result.actions.filter((a) => a.hasCompleteorchestration()); + expect(completeActions.length).toEqual(1); + return completeActions[0].getCompleteorchestration() ?? undefined; +} + +describe("createTimer input validation", () => { + it("should reject NaN as fireAt value", async () => { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { + yield ctx.createTimer(NaN); + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const startTime = new Date("2025-01-01T00:00:00Z"); + const newEvents = [ + newOrchestratorStartedEvent(startTime), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + const completeAction = getCompleteAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("createTimer"); + expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("NaN"); + }); + + it("should reject Infinity as fireAt value", async () => { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { + yield ctx.createTimer(Infinity); + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const startTime = new Date("2025-01-01T00:00:00Z"); + const newEvents = [ + newOrchestratorStartedEvent(startTime), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + const completeAction = getCompleteAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("createTimer"); + expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("Infinity"); + }); + + it("should reject negative Infinity as fireAt value", async () => { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { + yield ctx.createTimer(-Infinity); + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const startTime = new Date("2025-01-01T00:00:00Z"); + const newEvents = [ + newOrchestratorStartedEvent(startTime), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + const completeAction = getCompleteAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("createTimer"); + }); + + it("should reject an invalid Date object", async () => { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { + yield ctx.createTimer(new Date("not a date")); + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const startTime = new Date("2025-01-01T00:00:00Z"); + const newEvents = [ + newOrchestratorStartedEvent(startTime), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + const completeAction = getCompleteAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("createTimer"); + expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("invalid Date"); + }); + + it("should accept a valid positive number (seconds)", async () => { + const delaySeconds = 30; + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { + yield ctx.createTimer(delaySeconds); + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const startTime = new Date("2025-01-01T00:00:00Z"); + const expectedFireAt = new Date(startTime.getTime() + delaySeconds * 1000); + const newEvents = [ + newOrchestratorStartedEvent(startTime), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + // Should have a createTimer action (not failed) + const timerActions = result.actions.filter((a) => a.hasCreatetimer()); + expect(timerActions.length).toEqual(1); + const fireAt = timerActions[0].getCreatetimer()?.getFireat()?.toDate(); + expect(fireAt?.getTime()).toEqual(expectedFireAt.getTime()); + }); + + it("should accept zero as a valid delay (fires immediately)", async () => { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { + yield ctx.createTimer(0); + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const startTime = new Date("2025-01-01T00:00:00Z"); + const newEvents = [ + newOrchestratorStartedEvent(startTime), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + // Zero is a valid delay — timer fires at the current orchestration time + const timerActions = result.actions.filter((a) => a.hasCreatetimer()); + expect(timerActions.length).toEqual(1); + const fireAt = timerActions[0].getCreatetimer()?.getFireat()?.toDate(); + expect(fireAt?.getTime()).toEqual(startTime.getTime()); + }); + + it("should accept a valid Date object", async () => { + const futureDate = new Date("2025-06-15T12:00:00Z"); + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { + yield ctx.createTimer(futureDate); + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const startTime = new Date("2025-01-01T00:00:00Z"); + const newEvents = [ + newOrchestratorStartedEvent(startTime), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + const timerActions = result.actions.filter((a) => a.hasCreatetimer()); + expect(timerActions.length).toEqual(1); + const fireAt = timerActions[0].getCreatetimer()?.getFireat()?.toDate(); + expect(fireAt?.getTime()).toEqual(futureDate.getTime()); + }); + + it("should accept a negative number (timer fires in the past, which the sidecar handles)", async () => { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { + yield ctx.createTimer(-5); + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const startTime = new Date("2025-01-01T00:00:00Z"); + const expectedFireAt = new Date(startTime.getTime() - 5000); + const newEvents = [ + newOrchestratorStartedEvent(startTime), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + // Negative delays create a timer in the past — sidecar fires it immediately. + // This is valid behavior for scenarios like conditional timer creation. + const timerActions = result.actions.filter((a) => a.hasCreatetimer()); + expect(timerActions.length).toEqual(1); + const fireAt = timerActions[0].getCreatetimer()?.getFireat()?.toDate(); + expect(fireAt?.getTime()).toEqual(expectedFireAt.getTime()); + }); + + it("should accept fractional seconds", async () => { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { + yield ctx.createTimer(1.5); + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const startTime = new Date("2025-01-01T00:00:00Z"); + const expectedFireAt = new Date(startTime.getTime() + 1500); + const newEvents = [ + newOrchestratorStartedEvent(startTime), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + const timerActions = result.actions.filter((a) => a.hasCreatetimer()); + expect(timerActions.length).toEqual(1); + const fireAt = timerActions[0].getCreatetimer()?.getFireat()?.toDate(); + expect(fireAt?.getTime()).toEqual(expectedFireAt.getTime()); + }); +}); From 56ae68b0c8a343035902dc1c89b7a8db6edb17e4 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Jul 2026 11:35:57 -0700 Subject: [PATCH 2/3] Validate computed timer dates Reject finite numeric timer delays that overflow Date after conversion, preventing invalid timestamps from reaching protobuf serialization. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../worker/runtime-orchestration-context.ts | 6 +++-- .../test/createtimer-validation.spec.ts | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index 927b6bbd..e9d87cda 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -305,9 +305,11 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { ); } fireAt = new Date(this._currentUtcDatetime.getTime() + fireAt * 1000); - } else if (isNaN(fireAt.getTime())) { + } + + if (isNaN(fireAt.getTime())) { throw new Error( - "createTimer received an invalid Date object (NaN timestamp)", + "createTimer received or produced an invalid Date (NaN timestamp)", ); } diff --git a/packages/durabletask-js/test/createtimer-validation.spec.ts b/packages/durabletask-js/test/createtimer-validation.spec.ts index d89e47f8..21184c2e 100644 --- a/packages/durabletask-js/test/createtimer-validation.spec.ts +++ b/packages/durabletask-js/test/createtimer-validation.spec.ts @@ -118,6 +118,29 @@ describe("createTimer input validation", () => { expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("invalid Date"); }); + it("should reject a finite number that produces an invalid Date", async () => { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { + yield ctx.createTimer(Number.MAX_VALUE); + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const startTime = new Date("2025-01-01T00:00:00Z"); + const newEvents = [ + newOrchestratorStartedEvent(startTime), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + const completeAction = getCompleteAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("createTimer"); + expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("invalid Date"); + }); + it("should accept a valid positive number (seconds)", async () => { const delaySeconds = 30; const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { From 208d87de9767d104c44f98c774edfefa9e8d0c16 Mon Sep 17 00:00:00 2001 From: wangbill Date: Wed, 8 Jul 2026 12:39:46 -0700 Subject: [PATCH 3/3] Clarify createTimer type validation Separate number, Date, and invalid runtime input handling before validating the computed timer date. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../worker/runtime-orchestration-context.ts | 19 +++++++++------ .../test/createtimer-validation.spec.ts | 23 +++++++++++++++++++ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts index e9d87cda..64ae1083 100644 --- a/packages/durabletask-js/src/worker/runtime-orchestration-context.ts +++ b/packages/durabletask-js/src/worker/runtime-orchestration-context.ts @@ -296,24 +296,29 @@ export class RuntimeOrchestrationContext extends OrchestrationContext { createTimer(fireAt: number | Date): Task { const id = this.nextSequenceNumber(); - // If a number is passed, we use it as the number of seconds to wait - // we use instanceof Date as number is not a native Javascript type - if (!(fireAt instanceof Date)) { - if (typeof fireAt !== "number" || !Number.isFinite(fireAt)) { + let fireAtDate: Date; + if (typeof fireAt === "number") { + if (!Number.isFinite(fireAt)) { throw new Error( `createTimer requires a finite number (seconds) or a valid Date, but received ${String(fireAt)}`, ); } - fireAt = new Date(this._currentUtcDatetime.getTime() + fireAt * 1000); + fireAtDate = new Date(this._currentUtcDatetime.getTime() + fireAt * 1000); + } else if (fireAt instanceof Date) { + fireAtDate = fireAt; + } else { + throw new Error( + `createTimer requires a finite number (seconds) or a valid Date, but received ${String(fireAt)}`, + ); } - if (isNaN(fireAt.getTime())) { + if (Number.isNaN(fireAtDate.getTime())) { throw new Error( "createTimer received or produced an invalid Date (NaN timestamp)", ); } - const action = ph.newCreateTimerAction(id, fireAt); + const action = ph.newCreateTimerAction(id, fireAtDate); this._pendingActions[action.getId()] = action; const timerTask = new CompletableTask(); diff --git a/packages/durabletask-js/test/createtimer-validation.spec.ts b/packages/durabletask-js/test/createtimer-validation.spec.ts index 21184c2e..d3c4166f 100644 --- a/packages/durabletask-js/test/createtimer-validation.spec.ts +++ b/packages/durabletask-js/test/createtimer-validation.spec.ts @@ -118,6 +118,29 @@ describe("createTimer input validation", () => { expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("invalid Date"); }); + it("should reject values that are neither numbers nor Date objects", async () => { + const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { + yield ctx.createTimer("not a timer" as unknown as number); + }; + + const registry = new Registry(); + const name = registry.addOrchestrator(orchestrator); + const startTime = new Date("2025-01-01T00:00:00Z"); + const newEvents = [ + newOrchestratorStartedEvent(startTime), + newExecutionStartedEvent(name, TEST_INSTANCE_ID), + ]; + const executor = new OrchestrationExecutor(registry, testLogger); + const result = await executor.execute(TEST_INSTANCE_ID, [], newEvents); + + const completeAction = getCompleteAction(result); + expect(completeAction?.getOrchestrationstatus()).toEqual( + pb.OrchestrationStatus.ORCHESTRATION_STATUS_FAILED, + ); + expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("createTimer"); + expect(completeAction?.getFailuredetails()?.getErrormessage()).toContain("not a timer"); + }); + it("should reject a finite number that produces an invalid Date", async () => { const orchestrator: TOrchestrator = async function* (ctx: OrchestrationContext) { yield ctx.createTimer(Number.MAX_VALUE);