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..0ea6c8b2f8b
--- /dev/null
+++ b/apps/sim/lib/workflows/response-security.test.ts
@@ -0,0 +1,91 @@
+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',
+ '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',
+ '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..8e9f4754b5f 100644
--- a/apps/sim/lib/workflows/utils.ts
+++ b/apps/sim/lib/workflows/utils.ts
@@ -293,6 +293,60 @@ 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([
+ 'accept-ch',
+ 'accept-ch-lifetime',
+ 'alt-svc',
+ 'clear-site-data',
+ 'connection',
+ 'content-disposition',
+ 'content-encoding',
+ 'content-length',
+ 'content-location',
+ 'content-range',
+ 'critical-ch',
+ 'document-policy',
+ 'keep-alive',
+ 'link',
+ 'location',
+ 'nel',
+ 'origin-agent-cluster',
+ 'permissions-policy',
+ 'proxy-authenticate',
+ 'public-key-pins',
+ 'public-key-pins-report-only',
+ 'referrer-policy',
+ 'refresh',
+ 'report-to',
+ 'reporting-endpoints',
+ 'set-cookie',
+ 'set-cookie2',
+ 'strict-transport-security',
+ 'trailer',
+ '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',
+])
+
+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 +354,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,