From d7fc73cc34dd3386a1d1d315b229b58836ecfd30 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 10 Mar 2026 19:44:35 -0700 Subject: [PATCH] [web] [world-vercel] Ensure user-passed run IDs are URL encoded and call out self-hosted security Signed-off-by: Peter Wielander --- .changeset/fresh-snails-draw.md | 6 ++++++ .changeset/world-vercel-encodeuri.md | 5 +++++ packages/web-shared/README.md | 4 ++++ packages/web/README.md | 5 +++++ packages/world-vercel/src/events.ts | 8 ++++---- packages/world-vercel/src/hooks.ts | 4 ++-- packages/world-vercel/src/runs.ts | 4 ++-- packages/world-vercel/src/steps.ts | 10 +++++----- packages/world-vercel/src/streamer.ts | 6 ++++-- 9 files changed, 37 insertions(+), 15 deletions(-) create mode 100644 .changeset/fresh-snails-draw.md create mode 100644 .changeset/world-vercel-encodeuri.md diff --git a/.changeset/fresh-snails-draw.md b/.changeset/fresh-snails-draw.md new file mode 100644 index 0000000000..e1c78fae10 --- /dev/null +++ b/.changeset/fresh-snails-draw.md @@ -0,0 +1,6 @@ +--- +"@workflow/web-shared": patch +"@workflow/web": patch +--- + +Update readme to call out self-hosting limitations diff --git a/.changeset/world-vercel-encodeuri.md b/.changeset/world-vercel-encodeuri.md new file mode 100644 index 0000000000..7b869f39de --- /dev/null +++ b/.changeset/world-vercel-encodeuri.md @@ -0,0 +1,5 @@ +--- +"@workflow/world-vercel": patch +--- + +Encode all user-supplied IDs in URL path segments with `encodeURIComponent()` diff --git a/packages/web-shared/README.md b/packages/web-shared/README.md index 7cecfc609c..19c5a1f4b3 100644 --- a/packages/web-shared/README.md +++ b/packages/web-shared/README.md @@ -37,6 +37,10 @@ export default function MyRunDetailView({ Server actions and data fetching are intentionally **not** part of `web-shared`. Implement those in your app and pass data + callbacks into these components. If you need world run helpers, use `@workflow/core/runtime`. +> **Security notice:** If you implement server-side data fetching using `@workflow/world-vercel` or similar backends, +> ensure that user-supplied IDs (runId, stepId, etc.) are validated before passing them to world functions. +> The server actions in `@workflow/web` do not include authentication — see that package's README for details on securing self-hosted deployments. + ## Styling In order for tailwind classes to be picked up correctly, you might need to configure your NextJS app diff --git a/packages/web/README.md b/packages/web/README.md index f9da4ff973..4a34255544 100644 --- a/packages/web/README.md +++ b/packages/web/README.md @@ -4,6 +4,11 @@ Observability Web UI Package bundled in the [Workflow DevKit](https://useworkflo ## Self-hosting +> **Security notice:** The `@workflow/web` package does not include authentication or authorization. +> All users who can reach the deployment share the same backend credentials configured via environment variables. +> If you self-host this UI, you **must** place it behind your own authentication layer (e.g. VPN, reverse proxy with auth, OAuth). +> Exposing it to untrusted users without authentication is at your own risk and may allow unauthorized access to your workflow data. + While this UI is bundled with the Workflow CLI, you can also self-host it. There are multiple approaches: diff --git a/packages/world-vercel/src/events.ts b/packages/world-vercel/src/events.ts index c9e32c6c31..11e8355e08 100644 --- a/packages/world-vercel/src/events.ts +++ b/packages/world-vercel/src/events.ts @@ -262,7 +262,7 @@ export async function getEvent( searchParams.set('remoteRefBehavior', remoteRefBehavior); const queryString = searchParams.toString(); - const endpoint = `/v2/runs/${runId}/events/${eventId}${queryString ? `?${queryString}` : ''}`; + const endpoint = `/v2/runs/${encodeURIComponent(runId)}/events/${encodeURIComponent(eventId)}${queryString ? `?${queryString}` : ''}`; const event = await makeRequest({ endpoint, @@ -308,7 +308,7 @@ export async function getWorkflowRunEvents( const query = queryString ? `?${queryString}` : ''; const endpoint = correlationId ? `/v2/events${query}` - : `/v2/runs/${runId}/events${query}`; + : `/v2/runs/${encodeURIComponent(runId!)}/events${query}`; let refResolveConcurrency: number | undefined; const response = (await makeRequest({ @@ -384,7 +384,7 @@ export async function createWorkflowRunEvent( return { run }; } const wireResult = await makeRequest({ - endpoint: `/v1/runs/${id}/events`, + endpoint: `/v1/runs/${encodeURIComponent(id!)}/events`, options: { method: 'POST' }, data, config, @@ -403,7 +403,7 @@ export async function createWorkflowRunEvent( } // For run_created events, runId may be client-provided or null - const runIdPath = id === null ? 'null' : id; + const runIdPath = id === null ? 'null' : encodeURIComponent(id); const remoteRefBehavior = eventsNeedingResolve.has(data.eventType) ? 'resolve' diff --git a/packages/world-vercel/src/hooks.ts b/packages/world-vercel/src/hooks.ts index c979798567..b219e59339 100644 --- a/packages/world-vercel/src/hooks.ts +++ b/packages/world-vercel/src/hooks.ts @@ -72,7 +72,7 @@ export async function getHook( config?: APIConfig ): Promise { const resolveData = params?.resolveData || 'all'; - const endpoint = `/v2/hooks/${hookId}`; + const endpoint = `/v2/hooks/${encodeURIComponent(hookId)}`; const hook = await makeRequest({ endpoint, @@ -124,7 +124,7 @@ export async function disposeHook( config?: APIConfig ): Promise { return makeRequest({ - endpoint: `/v2/hooks/${hookId}`, + endpoint: `/v2/hooks/${encodeURIComponent(hookId)}`, options: { method: 'DELETE' }, config, schema: HookSchema, diff --git a/packages/world-vercel/src/runs.ts b/packages/world-vercel/src/runs.ts index 5249386c1a..f008f6f3ab 100644 --- a/packages/world-vercel/src/runs.ts +++ b/packages/world-vercel/src/runs.ts @@ -183,7 +183,7 @@ export async function getWorkflowRun( searchParams.set('remoteRefBehavior', remoteRefBehavior); const queryString = searchParams.toString(); - const endpoint = `/v2/runs/${id}${queryString ? `?${queryString}` : ''}`; + const endpoint = `/v2/runs/${encodeURIComponent(id)}${queryString ? `?${queryString}` : ''}`; try { const run = await makeRequest({ @@ -231,7 +231,7 @@ export async function cancelWorkflowRunV1( searchParams.set('remoteRefBehavior', remoteRefBehavior); const queryString = searchParams.toString(); - const endpoint = `/v1/runs/${id}/cancel${queryString ? `?${queryString}` : ''}`; + const endpoint = `/v1/runs/${encodeURIComponent(id)}/cancel${queryString ? `?${queryString}` : ''}`; try { const run = await makeRequest({ diff --git a/packages/world-vercel/src/steps.ts b/packages/world-vercel/src/steps.ts index 91d62ce351..3f01562c87 100644 --- a/packages/world-vercel/src/steps.ts +++ b/packages/world-vercel/src/steps.ts @@ -162,7 +162,7 @@ export async function listWorkflowRunSteps( searchParams.set('remoteRefBehavior', remoteRefBehavior); const queryString = searchParams.toString(); - const endpoint = `/v2/runs/${runId}/steps${queryString ? `?${queryString}` : ''}`; + const endpoint = `/v2/runs/${encodeURIComponent(runId)}/steps${queryString ? `?${queryString}` : ''}`; const response = (await makeRequest({ endpoint, @@ -185,7 +185,7 @@ export async function createStep( config?: APIConfig ): Promise { const step = await makeRequest({ - endpoint: `/v2/runs/${runId}/steps`, + endpoint: `/v2/runs/${encodeURIComponent(runId)}/steps`, options: { method: 'POST' }, data, config, @@ -202,7 +202,7 @@ export async function updateStep( ): Promise { const serialized = serializeError(data); const step = await makeRequest({ - endpoint: `/v2/runs/${runId}/steps/${stepId}`, + endpoint: `/v2/runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}`, options: { method: 'PUT' }, data: serialized, config, @@ -243,8 +243,8 @@ export async function getStep( const queryString = searchParams.toString(); const endpoint = runId - ? `/v2/runs/${runId}/steps/${stepId}${queryString ? `?${queryString}` : ''}` - : `/v2/steps/${stepId}${queryString ? `?${queryString}` : ''}`; + ? `/v2/runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}${queryString ? `?${queryString}` : ''}` + : `/v2/steps/${encodeURIComponent(stepId)}${queryString ? `?${queryString}` : ''}`; const step = await makeRequest({ endpoint, diff --git a/packages/world-vercel/src/streamer.ts b/packages/world-vercel/src/streamer.ts index 0b418e5202..315cd109d7 100644 --- a/packages/world-vercel/src/streamer.ts +++ b/packages/world-vercel/src/streamer.ts @@ -13,7 +13,7 @@ function getStreamUrl( ) { if (runId) { return new URL( - `${httpConfig.baseUrl}/v2/runs/${runId}/stream/${encodeURIComponent(name)}` + `${httpConfig.baseUrl}/v2/runs/${encodeURIComponent(runId)}/stream/${encodeURIComponent(name)}` ); } return new URL(`${httpConfig.baseUrl}/v2/stream/${encodeURIComponent(name)}`); @@ -141,7 +141,9 @@ export function createStreamer(config?: APIConfig): Streamer { async listStreamsByRunId(runId: string) { const httpConfig = await getHttpConfig(config); - const url = new URL(`${httpConfig.baseUrl}/v2/runs/${runId}/streams`); + const url = new URL( + `${httpConfig.baseUrl}/v2/runs/${encodeURIComponent(runId)}/streams` + ); const response = await fetch(url, { headers: httpConfig.headers, });