Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/docs/content/docs/workflows/blocks/response.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
91 changes: 91 additions & 0 deletions apps/sim/lib/workflows/response-security.test.ts
Original file line number Diff line number Diff line change
@@ -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: '<script>alert(document.domain)</script>' }
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: '</untrusted>; 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' })
})
})
71 changes: 67 additions & 4 deletions apps/sim/lib/workflows/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,17 +293,80 @@ 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',
Comment thread
waleedlatif1 marked this conversation as resolved.
])

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<ExecutionResult, 'output'>,
context?: ExecutionMaterializationContext
): Promise<NextResponse> => {
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)
Comment thread
waleedlatif1 marked this conversation as resolved.
}
}

// 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,
Expand Down
Loading