Skip to content

Commit d09cab3

Browse files
authored
Add Workflow name and instance id validations (#10785)
1 parent 8eb7197 commit d09cab3

File tree

13 files changed

+169
-12
lines changed

13 files changed

+169
-12
lines changed

.changeset/stupid-eggs-enjoy.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@cloudflare/workflows-shared": patch
3+
"wrangler": patch
4+
---
5+
6+
Workflows names and instance IDs are now properly validated with production limits.

packages/create-cloudflare/templates/hello-world-workflows/js/wrangler.jsonc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
},
1010
"workflows": [
1111
{
12-
"name": "<TBD>",
12+
"name": "workflows-hello-world",
1313
"binding": "MY_WORKFLOW",
1414
"class_name": "MyWorkflow",
1515
},

packages/miniflare/test/plugins/workflows/index.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export class MyWorkflow extends WorkflowEntrypoint {
1515
}
1616
export default {
1717
async fetch(request, env, ctx) {
18-
const workflow = await env.MY_WORKFLOW.create({id: "i'm an id"})
18+
const workflow = await env.MY_WORKFLOW.create({id: "an-id"})
1919
2020
return new Response(JSON.stringify(await workflow.status()))
2121
},

packages/workflows-shared/src/binding.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { RpcTarget, WorkerEntrypoint } from "cloudflare:workers";
22
import { InstanceEvent, instanceStatusName } from "./instance";
3+
import { WorkflowError } from "./lib/errors";
4+
import { isValidWorkflowInstanceId } from "./lib/validators";
35
import type {
46
DatabaseInstance,
57
DatabaseVersion,
@@ -19,6 +21,10 @@ export class WorkflowBinding extends WorkerEntrypoint<Env> implements Workflow {
1921
id = crypto.randomUUID(),
2022
params = {},
2123
}: WorkflowInstanceCreateOptions = {}): Promise<WorkflowInstance> {
24+
if (!isValidWorkflowInstanceId(id)) {
25+
throw new WorkflowError("Workflow instance has invalid id");
26+
}
27+
2228
const stubId = this.env.ENGINE.idFromName(id);
2329
const stub = this.env.ENGINE.get(stubId);
2430

packages/workflows-shared/src/context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
WorkflowTimeoutError,
99
} from "./lib/errors";
1010
import { calcRetryDuration } from "./lib/retries";
11-
import { MAX_STEP_NAME_LENGTH, validateStepName } from "./lib/validators";
11+
import { isValidStepName, MAX_STEP_NAME_LENGTH } from "./lib/validators";
1212
import type { Engine } from "./engine";
1313
import type { InstanceMetadata } from "./instance";
1414
import type {
@@ -85,7 +85,7 @@ export class Context extends RpcTarget {
8585
stepConfig = {};
8686
}
8787

88-
if (!validateStepName(name)) {
88+
if (!isValidStepName(name)) {
8989
// NOTE(lduarte): marking errors as user error allows the observability layer to avoid leaking
9090
// user errors to sentry while making everything more observable. `isUserError` is not serialized
9191
// into userland code due to how workerd serialzises errors over RPC - we also set it as undefined

packages/workflows-shared/src/lib/errors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ export class WorkflowFatalError extends Error {
1616
};
1717
}
1818
}
19+
20+
export class WorkflowError extends Error {
21+
name = "WorkflowError";
22+
}
Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,45 @@
1+
export const MAX_WORKFLOW_NAME_LENGTH = 64;
2+
3+
export const MAX_WORKFLOW_INSTANCE_ID_LENGTH = 100;
4+
15
export const MAX_STEP_NAME_LENGTH = 256;
6+
7+
export const ALLOWED_STRING_ID_PATTERN = "^[a-zA-Z0-9_][a-zA-Z0-9-_]*$";
8+
const ALLOWED_WORKFLOW_INSTANCE_ID_REGEX = new RegExp(
9+
ALLOWED_STRING_ID_PATTERN
10+
);
11+
const ALLOWED_WORKFLOW_NAME_REGEX = ALLOWED_WORKFLOW_INSTANCE_ID_REGEX;
12+
213
// eslint-disable-next-line no-control-regex
314
const CONTROL_CHAR_REGEX = new RegExp("[\x00-\x1F]");
415

5-
export function validateStepName(string: string): boolean {
6-
if (string.length > MAX_STEP_NAME_LENGTH) {
16+
export function isValidWorkflowName(name: string): boolean {
17+
if (typeof name !== "string") {
18+
return false;
19+
}
20+
if (name.length > MAX_WORKFLOW_NAME_LENGTH) {
21+
return false;
22+
}
23+
24+
return ALLOWED_WORKFLOW_NAME_REGEX.test(name);
25+
}
26+
27+
export function isValidWorkflowInstanceId(id: string): boolean {
28+
if (typeof id !== "string") {
29+
return false;
30+
}
31+
32+
if (id.length > MAX_WORKFLOW_INSTANCE_ID_LENGTH) {
33+
return false;
34+
}
35+
36+
return ALLOWED_WORKFLOW_INSTANCE_ID_REGEX.test(id);
37+
}
38+
39+
export function isValidStepName(name: string): boolean {
40+
if (name.length > MAX_STEP_NAME_LENGTH) {
741
return false;
842
}
943

10-
//check for control chars
11-
return !CONTROL_CHAR_REGEX.test(string);
44+
return !CONTROL_CHAR_REGEX.test(name);
1245
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
isValidStepName,
4+
isValidWorkflowInstanceId,
5+
isValidWorkflowName,
6+
MAX_STEP_NAME_LENGTH,
7+
MAX_WORKFLOW_INSTANCE_ID_LENGTH,
8+
MAX_WORKFLOW_NAME_LENGTH,
9+
} from "../src/lib/validators";
10+
11+
describe("Workflow name validation", () => {
12+
it.each([
13+
"",
14+
NaN,
15+
undefined,
16+
" ",
17+
"\n\nhello",
18+
"w".repeat(MAX_WORKFLOW_NAME_LENGTH + 1),
19+
"#1231231!!!!",
20+
"-badName",
21+
])("should reject invalid names", function (value) {
22+
expect(isValidWorkflowName(value as string)).toBe(false);
23+
});
24+
25+
it.each([
26+
"abc",
27+
"NAME_123-hello",
28+
"a-valid-string",
29+
"w".repeat(MAX_WORKFLOW_NAME_LENGTH),
30+
])("should accept valid names", function (value) {
31+
expect(isValidWorkflowName(value as string)).toBe(true);
32+
});
33+
});
34+
35+
describe("Workflow instance ID validation", () => {
36+
it.each([
37+
"",
38+
NaN,
39+
undefined,
40+
" ",
41+
"\n\nhello",
42+
"w".repeat(MAX_WORKFLOW_INSTANCE_ID_LENGTH + 1),
43+
"#1231231!!!!",
44+
])("should reject invalid IDs", function (value) {
45+
expect(isValidWorkflowInstanceId(value as string)).toBe(false);
46+
});
47+
48+
it.each([
49+
"abc",
50+
"NAME_123-hello",
51+
"a-valid-string",
52+
"w".repeat(MAX_WORKFLOW_INSTANCE_ID_LENGTH),
53+
])("should accept valid IDs", function (value) {
54+
expect(isValidWorkflowInstanceId(value as string)).toBe(true);
55+
});
56+
});
57+
58+
describe("Workflow instance step name validation", () => {
59+
it.each(["\x00", "w".repeat(MAX_STEP_NAME_LENGTH + 1)])(
60+
"should reject invalid names",
61+
function (value) {
62+
expect(isValidStepName(value as string)).toBe(false);
63+
}
64+
);
65+
66+
it.each([
67+
"abc",
68+
"NAME_123-hello",
69+
"a-valid-string",
70+
"w".repeat(MAX_STEP_NAME_LENGTH),
71+
"valid step name",
72+
])("should accept valid names", function (value) {
73+
expect(isValidStepName(value as string)).toBe(true);
74+
});
75+
});

packages/wrangler/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"@cloudflare/workers-shared": "workspace:*",
8686
"@cloudflare/workers-tsconfig": "workspace:*",
8787
"@cloudflare/workers-types": "catalog:default",
88+
"@cloudflare/workflows-shared": "workspace:*",
8889
"@cspotcode/source-map-support": "0.8.1",
8990
"@iarna/toml": "^3.0.0",
9091
"@sentry/node": "^7.86.0",

packages/wrangler/src/__tests__/workflows.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -701,10 +701,35 @@ describe("wrangler workflows", () => {
701701
});
702702

703703
await expect(runWrangler("deploy --dry-run")).rejects.toThrow();
704-
expect(std.err).toContain("must be 64 characters or less");
705-
expect(std.err).toContain("but got 65 characters");
704+
expect(std.err).toMatchInlineSnapshot(`
705+
"X [ERROR] Processing wrangler.toml configuration:
706+
707+
- \\"workflows[0]\\" binding \\"name\\" field is invalid. Workflow names must be 1-64 characters long,
708+
start with a letter, number, or underscore, and may only contain letters, numbers, underscores, or
709+
hyphens.
710+
711+
"
712+
`);
706713
});
707714

715+
it.each(["", " ", "\n\nhello", "#1231231!!!!", "-badName"])(
716+
"should reject workflow binding with name with invalid characters",
717+
async function (invalidName) {
718+
writeWranglerConfig({
719+
workflows: [
720+
{
721+
binding: "MY_WORKFLOW",
722+
name: invalidName,
723+
class_name: "MyWorkflow",
724+
},
725+
],
726+
});
727+
728+
await expect(runWrangler("deploy --dry-run")).rejects.toThrow();
729+
expect(std.err).toContain('binding "name" field is invalid');
730+
}
731+
);
732+
708733
it("should accept workflow binding with name exactly 64 characters", async () => {
709734
const maxLengthName = "a".repeat(64); // exactly 64 characters
710735
writeWorkerSource({ format: "ts" });

0 commit comments

Comments
 (0)