Skip to content

Commit 64f790a

Browse files
[world] Asymmetric ULID timestamp validation thresholds
Use 24h past / 5min future instead of symmetric 5min threshold. This supports resilient start where queue retry delays (up to 24h via VQS) can cause run_created timestamps to be far in the past, while keeping future timestamps tight to prevent abuse. Extracted from #1537. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d1330cf commit 64f790a

4 files changed

Lines changed: 57 additions & 14 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@workflow/world": patch
3+
"@workflow/world-local": patch
4+
---
5+
6+
Use asymmetric ULID timestamp validation thresholds: 24h past, 5min future.

packages/world-local/src/storage.test.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2848,17 +2848,25 @@ describe('Storage', () => {
28482848
});
28492849

28502850
it('should accept a runId within the threshold', async () => {
2851-
// 4 minutes ago — within the 5-minute default
2851+
// 4 minutes ago — within the 24-hour past threshold
28522852
const runId = makeRunId(Date.now() - 4 * 60 * 1000);
28532853
const result = await storage.events.create(runId, runCreatedEvent);
28542854

28552855
expect(result.run).toBeDefined();
28562856
expect(result.run!.runId).toBe(runId);
28572857
});
28582858

2859-
it('should reject a runId with a timestamp too far in the past', async () => {
2860-
// 10 minutes ago — exceeds the 5-minute threshold
2859+
it('should accept a runId with a timestamp 10 minutes in the past', async () => {
2860+
// 10 minutes ago — within the 24-hour past threshold
28612861
const runId = makeRunId(Date.now() - 10 * 60 * 1000);
2862+
const result = await storage.events.create(runId, runCreatedEvent);
2863+
expect(result.run).toBeDefined();
2864+
expect(result.run!.runId).toBe(runId);
2865+
});
2866+
2867+
it('should reject a runId with a timestamp too far in the past', async () => {
2868+
// 25 hours ago — exceeds the 24-hour past threshold
2869+
const runId = makeRunId(Date.now() - 25 * 60 * 60 * 1000);
28622870

28632871
await expect(
28642872
storage.events.create(runId, runCreatedEvent)
@@ -2870,7 +2878,7 @@ describe('Storage', () => {
28702878
});
28712879

28722880
it('should reject a runId with a timestamp too far in the future', async () => {
2873-
// 10 minutes from now
2881+
// 10 minutes from now — exceeds the 5-minute future threshold
28742882
const runId = makeRunId(Date.now() + 10 * 60 * 1000);
28752883

28762884
await expect(

packages/world/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,9 @@ export {
5353
export type * from './steps.js';
5454
export { StepSchema, StepStatusSchema } from './steps.js';
5555
export {
56+
DEFAULT_TIMESTAMP_THRESHOLD_FUTURE_MS,
5657
DEFAULT_TIMESTAMP_THRESHOLD_MS,
58+
DEFAULT_TIMESTAMP_THRESHOLD_PAST_MS,
5759
ulidToDate,
5860
validateUlidTimestamp,
5961
} from './ulid.js';

packages/world/src/ulid.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,25 @@ import { z } from 'zod';
44
const UlidSchema = z.string().ulid();
55

66
/**
7-
* Default threshold for ULID timestamp validation (5 minutes in milliseconds).
7+
* Default threshold for ULID timestamps in the past (24 hours).
8+
*
9+
* Set to 24 hours to support the resilient start path: when start() fails to
10+
* create run_created, the queue carries the run input and the runtime creates
11+
* the run on run_started. VQS supports delayed messages up to 24 hours.
12+
*/
13+
export const DEFAULT_TIMESTAMP_THRESHOLD_PAST_MS = 24 * 60 * 60 * 1000;
14+
15+
/**
16+
* Default threshold for ULID timestamps in the future (5 minutes).
17+
*
18+
* Kept tight to prevent abuse from client-generated ULIDs with manipulated
19+
* future timestamps while still tolerating minor clock skew.
820
*/
9-
export const DEFAULT_TIMESTAMP_THRESHOLD_MS = 5 * 60 * 1000;
21+
export const DEFAULT_TIMESTAMP_THRESHOLD_FUTURE_MS = 5 * 60 * 1000;
22+
23+
/** @deprecated Use DEFAULT_TIMESTAMP_THRESHOLD_PAST_MS instead */
24+
export const DEFAULT_TIMESTAMP_THRESHOLD_MS =
25+
DEFAULT_TIMESTAMP_THRESHOLD_PAST_MS;
1026

1127
/**
1228
* Extracts a Date from a ULID string, or null if the string is not a valid ULID.
@@ -21,18 +37,22 @@ export function ulidToDate(maybeUlid: string): Date | null {
2137
}
2238

2339
/**
24-
* Validates that a prefixed ULID's embedded timestamp is within an acceptable threshold
25-
* of the current server time. This prevents client-generated ULIDs with manipulated timestamps.
40+
* Validates that a prefixed ULID's embedded timestamp is within acceptable thresholds
41+
* of the current server time. Uses asymmetric thresholds: 24h in the past (to support
42+
* resilient start with queue delays) and 5min in the future (to prevent abuse while
43+
* tolerating clock skew).
2644
*
2745
* @param prefixedUlid - The prefixed ULID to validate (e.g., "wrun_01ARYZ...")
2846
* @param prefix - The prefix to strip (e.g., "wrun_")
29-
* @param thresholdMs - Maximum allowed drift in milliseconds (default: 5 minutes)
47+
* @param pastThresholdMs - Maximum allowed age in the past (default: 24 hours)
48+
* @param futureThresholdMs - Maximum allowed distance in the future (default: 5 minutes)
3049
* @returns null if valid, or an error message string if invalid
3150
*/
3251
export function validateUlidTimestamp(
3352
prefixedUlid: string,
3453
prefix: string,
35-
thresholdMs: number = DEFAULT_TIMESTAMP_THRESHOLD_MS
54+
pastThresholdMs: number = DEFAULT_TIMESTAMP_THRESHOLD_PAST_MS,
55+
futureThresholdMs: number = DEFAULT_TIMESTAMP_THRESHOLD_FUTURE_MS
3656
): string | null {
3757
const raw = prefixedUlid.startsWith(prefix)
3858
? prefixedUlid.slice(prefix.length)
@@ -44,13 +64,20 @@ export function validateUlidTimestamp(
4464
}
4565

4666
const serverTimestamp = new Date();
47-
const driftMs = Math.abs(serverTimestamp.getTime() - ulidTimestamp.getTime());
67+
const diffMs = serverTimestamp.getTime() - ulidTimestamp.getTime();
4868

49-
if (driftMs <= thresholdMs) {
50-
return null;
69+
// diffMs > 0 means the ULID is in the past; diffMs < 0 means it's in the future
70+
if (diffMs > 0 && diffMs <= pastThresholdMs) {
71+
return null; // Within past threshold
72+
}
73+
if (diffMs <= 0 && -diffMs <= futureThresholdMs) {
74+
return null; // Within future threshold
5175
}
5276

77+
const driftMs = Math.abs(diffMs);
5378
const driftSeconds = Math.round(driftMs / 1000);
79+
const direction = diffMs > 0 ? 'past' : 'future';
80+
const thresholdMs = diffMs > 0 ? pastThresholdMs : futureThresholdMs;
5481
const thresholdSeconds = Math.round(thresholdMs / 1000);
55-
return `Invalid runId timestamp: embedded timestamp differs from server time by ${driftSeconds}s (threshold: ${thresholdSeconds}s)`;
82+
return `Invalid runId timestamp: embedded timestamp is ${driftSeconds}s in the ${direction} (threshold: ${thresholdSeconds}s)`;
5683
}

0 commit comments

Comments
 (0)