From e0b0e22cb0ec1b2e143515a47334ed8b0c941d7b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 14:21:47 -0700 Subject: [PATCH 1/2] fix(api): constrain workflow response headers --- .../docs/workflows/blocks/response.mdx | 2 + .../lib/workflows/response-security.test.ts | 80 +++++++++++++++++++ apps/sim/lib/workflows/utils.ts | 60 +++++++++++++- 3 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 apps/sim/lib/workflows/response-security.test.ts diff --git a/apps/docs/content/docs/workflows/blocks/response.mdx b/apps/docs/content/docs/workflows/blocks/response.mdx index 9857b049eb8..db61bfb17fb 100644 --- a/apps/docs/content/docs/workflows/blocks/response.mdx +++ b/apps/docs/content/docs/workflows/blocks/response.mdx @@ -52,6 +52,8 @@ Extra response headers, as key-value pairs: | Cache-Control | no-cache | | X-API-Version | 1.0 | +HTTP responses always use `Content-Type: application/json` and `X-Content-Type-Options: nosniff`. Headers that set cookies, redirect the browser, change security or CORS policies, or control the HTTP transport are ignored. Ordinary custom headers and cache directives are preserved. + ## Outputs A Response block is a terminal block, so nothing reads from it. Its `data`, `status`, and `headers` become the HTTP response itself. A workflow with no Response block returns its last block's output by default; add a Response block when you need exact HTTP control. diff --git a/apps/sim/lib/workflows/response-security.test.ts b/apps/sim/lib/workflows/response-security.test.ts new file mode 100644 index 00000000000..eaff419eb0a --- /dev/null +++ b/apps/sim/lib/workflows/response-security.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest' +import { createHttpResponseFromBlock } from '@/lib/workflows/utils' + +describe('workflow HTTP response safety', () => { + it.each([ + { 'Content-Type': 'text/html' }, + { 'content-type': 'text/html' }, + { 'CoNtEnT-TyPe': 'image/svg+xml' }, + { 'Content-Type': 'application/json', 'content-type': 'text/html' }, + ])('keeps untrusted markup as JSON with headers %j', async (headers) => { + const data = { message: '' } + const response = await createHttpResponseFromBlock({ + output: { + data, + status: 201, + headers: { + ...headers, + 'X-Content-Type-Options': 'invalid', + 'X-API-Version': '1.0', + 'Cache-Control': 'no-cache', + 'Retry-After': '30', + }, + }, + }) + + expect(response.headers.get('content-type')).toBe('application/json') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + expect(response.status).toBe(201) + expect(response.headers.get('x-api-version')).toBe('1.0') + expect(response.headers.get('cache-control')).toBe('no-cache') + expect(response.headers.get('retry-after')).toBe('30') + expect(await response.json()).toEqual(data) + }) + + it('does not let workflow output set cookies, browser policies, redirects, or transport headers', async () => { + const response = await createHttpResponseFromBlock({ + output: { + data: { message: 'complete' }, + status: 200, + headers: { + 'SeT-CoOkIe': 'session=untrusted; Path=/', + 'Set-Cookie2': 'session=untrusted', + 'Content-Disposition': 'inline', + 'Content-Security-Policy': "default-src * 'unsafe-inline'", + 'Content-Security-Policy-Report-Only': 'report-uri /untrusted', + 'X-Frame-Options': 'ALLOWALL', + 'X-XSS-Protection': '0', + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Credentials': 'true', + 'Cross-Origin-Resource-Policy': 'cross-origin', + 'Clear-Site-Data': '"*"', + 'Permissions-Policy': 'camera=*', + 'Document-Policy': 'force-load-at-top', + 'Referrer-Policy': 'unsafe-url', + 'Strict-Transport-Security': 'max-age=0', + 'Origin-Agent-Cluster': '?0', + Location: '/untrusted', + Refresh: '0; url=/untrusted', + Link: '; rel=preload; as=script', + 'Report-To': '{"group":"untrusted"}', + 'Reporting-Endpoints': 'default="/untrusted"', + NEL: '{"report_to":"untrusted","max_age":3600}', + 'Content-Length': '1', + 'Content-Encoding': 'gzip', + 'Transfer-Encoding': 'chunked', + Connection: 'close', + 'X-Middleware-Rewrite': '/untrusted', + 'X-Accel-Redirect': '/untrusted', + 'X-Sendfile': '/untrusted', + }, + }, + }) + + expect(Object.fromEntries(response.headers)).toEqual({ + 'content-type': 'application/json', + 'x-content-type-options': 'nosniff', + }) + expect(await response.json()).toEqual({ message: 'complete' }) + }) +}) diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index 6160e8dfefd..d7077c918b1 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -293,6 +293,49 @@ export const workflowHasResponseBlock = ( return responseBlock !== undefined } +/** Headers that control the app origin or HTTP transport belong to the server. */ +const RESERVED_RESPONSE_HEADERS = new Set([ + 'alt-svc', + 'clear-site-data', + 'connection', + 'content-disposition', + 'content-encoding', + 'content-length', + 'content-location', + 'content-range', + 'document-policy', + 'keep-alive', + 'link', + 'location', + 'nel', + 'origin-agent-cluster', + 'permissions-policy', + 'proxy-authenticate', + 'referrer-policy', + 'refresh', + 'report-to', + 'reporting-endpoints', + 'set-cookie', + 'set-cookie2', + 'strict-transport-security', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'www-authenticate', + 'x-frame-options', + 'x-sendfile', + 'x-xss-protection', +]) + +const RESERVED_RESPONSE_HEADER_PREFIXES = [ + 'access-control-', + 'content-security-policy', + 'cross-origin-', + 'sec-', + 'x-accel-', + 'x-middleware-', +] as const + export const createHttpResponseFromBlock = async ( executionResult: Pick, context?: ExecutionMaterializationContext @@ -300,10 +343,19 @@ export const createHttpResponseFromBlock = async ( const { data = {}, status = 200, headers = {} } = executionResult.output const responseData = await materializeInlineExecutionValue(data, context) - const responseHeaders = new Headers({ - 'Content-Type': 'application/json', - ...headers, - }) + const responseHeaders = new Headers() + for (const [name, value] of new Headers(headers)) { + if ( + !RESERVED_RESPONSE_HEADERS.has(name) && + !RESERVED_RESPONSE_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix)) + ) { + responseHeaders.set(name, value) + } + } + + // JSON serialization does not escape HTML; enforce the MIME type after normalizing header names. + responseHeaders.set('Content-Type', 'application/json') + responseHeaders.set('X-Content-Type-Options', 'nosniff') return NextResponse.json(responseData, { status: status, From ba5cde1d89841aefa2d885c989dacc4986950038 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sat, 26 Sep 2026 14:31:14 -0700 Subject: [PATCH 2/2] fix(api): reserve additional browser policy headers --- apps/sim/lib/workflows/response-security.test.ts | 11 +++++++++++ apps/sim/lib/workflows/utils.ts | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/apps/sim/lib/workflows/response-security.test.ts b/apps/sim/lib/workflows/response-security.test.ts index eaff419eb0a..0ea6c8b2f8b 100644 --- a/apps/sim/lib/workflows/response-security.test.ts +++ b/apps/sim/lib/workflows/response-security.test.ts @@ -45,6 +45,17 @@ describe('workflow HTTP response safety', () => { 'Content-Security-Policy-Report-Only': 'report-uri /untrusted', 'X-Frame-Options': 'ALLOWALL', 'X-XSS-Protection': '0', + 'X-Download-Options': 'untrusted', + 'X-DNS-Prefetch-Control': 'on', + 'X-Permitted-Cross-Domain-Policies': 'all', + 'X-UA-Compatible': 'IE=7', + 'X-WebKit-CSP': "default-src * 'unsafe-inline'", + 'X-Content-Security-Policy': "default-src * 'unsafe-inline'", + 'Accept-CH': 'Sec-CH-UA-Model', + 'Accept-CH-Lifetime': '86400', + 'Critical-CH': 'Sec-CH-UA-Model', + 'Public-Key-Pins': 'max-age=0', + 'Public-Key-Pins-Report-Only': 'max-age=0; report-uri="/untrusted"', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Credentials': 'true', 'Cross-Origin-Resource-Policy': 'cross-origin', diff --git a/apps/sim/lib/workflows/utils.ts b/apps/sim/lib/workflows/utils.ts index d7077c918b1..8e9f4754b5f 100644 --- a/apps/sim/lib/workflows/utils.ts +++ b/apps/sim/lib/workflows/utils.ts @@ -295,6 +295,8 @@ export const workflowHasResponseBlock = ( /** Headers that control the app origin or HTTP transport belong to the server. */ const RESERVED_RESPONSE_HEADERS = new Set([ + 'accept-ch', + 'accept-ch-lifetime', 'alt-svc', 'clear-site-data', 'connection', @@ -303,6 +305,7 @@ const RESERVED_RESPONSE_HEADERS = new Set([ 'content-length', 'content-location', 'content-range', + 'critical-ch', 'document-policy', 'keep-alive', 'link', @@ -311,6 +314,8 @@ const RESERVED_RESPONSE_HEADERS = new Set([ 'origin-agent-cluster', 'permissions-policy', 'proxy-authenticate', + 'public-key-pins', + 'public-key-pins-report-only', 'referrer-policy', 'refresh', 'report-to', @@ -322,8 +327,14 @@ const RESERVED_RESPONSE_HEADERS = new Set([ 'transfer-encoding', 'upgrade', 'www-authenticate', + 'x-content-security-policy', + 'x-dns-prefetch-control', + 'x-download-options', 'x-frame-options', + 'x-permitted-cross-domain-policies', 'x-sendfile', + 'x-ua-compatible', + 'x-webkit-csp', 'x-xss-protection', ])