Skip to content

Commit ec0f474

Browse files
authored
fix(api): constrain workflow response headers (#8341)
* fix(api): constrain workflow response headers * fix(api): reserve additional browser policy headers
1 parent 7a36d74 commit ec0f474

3 files changed

Lines changed: 160 additions & 4 deletions

File tree

‎apps/docs/content/docs/workflows/blocks/response.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ Extra response headers, as key-value pairs:
5252
| Cache-Control | no-cache |
5353
| X-API-Version | 1.0 |
5454

55+
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.
56+
5557
## Outputs
5658

5759
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.
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createHttpResponseFromBlock } from '@/lib/workflows/utils'
3+
4+
describe('workflow HTTP response safety', () => {
5+
it.each([
6+
{ 'Content-Type': 'text/html' },
7+
{ 'content-type': 'text/html' },
8+
{ 'CoNtEnT-TyPe': 'image/svg+xml' },
9+
{ 'Content-Type': 'application/json', 'content-type': 'text/html' },
10+
])('keeps untrusted markup as JSON with headers %j', async (headers) => {
11+
const data = { message: '<script>alert(document.domain)</script>' }
12+
const response = await createHttpResponseFromBlock({
13+
output: {
14+
data,
15+
status: 201,
16+
headers: {
17+
...headers,
18+
'X-Content-Type-Options': 'invalid',
19+
'X-API-Version': '1.0',
20+
'Cache-Control': 'no-cache',
21+
'Retry-After': '30',
22+
},
23+
},
24+
})
25+
26+
expect(response.headers.get('content-type')).toBe('application/json')
27+
expect(response.headers.get('x-content-type-options')).toBe('nosniff')
28+
expect(response.status).toBe(201)
29+
expect(response.headers.get('x-api-version')).toBe('1.0')
30+
expect(response.headers.get('cache-control')).toBe('no-cache')
31+
expect(response.headers.get('retry-after')).toBe('30')
32+
expect(await response.json()).toEqual(data)
33+
})
34+
35+
it('does not let workflow output set cookies, browser policies, redirects, or transport headers', async () => {
36+
const response = await createHttpResponseFromBlock({
37+
output: {
38+
data: { message: 'complete' },
39+
status: 200,
40+
headers: {
41+
'SeT-CoOkIe': 'session=untrusted; Path=/',
42+
'Set-Cookie2': 'session=untrusted',
43+
'Content-Disposition': 'inline',
44+
'Content-Security-Policy': "default-src * 'unsafe-inline'",
45+
'Content-Security-Policy-Report-Only': 'report-uri /untrusted',
46+
'X-Frame-Options': 'ALLOWALL',
47+
'X-XSS-Protection': '0',
48+
'X-Download-Options': 'untrusted',
49+
'X-DNS-Prefetch-Control': 'on',
50+
'X-Permitted-Cross-Domain-Policies': 'all',
51+
'X-UA-Compatible': 'IE=7',
52+
'X-WebKit-CSP': "default-src * 'unsafe-inline'",
53+
'X-Content-Security-Policy': "default-src * 'unsafe-inline'",
54+
'Accept-CH': 'Sec-CH-UA-Model',
55+
'Accept-CH-Lifetime': '86400',
56+
'Critical-CH': 'Sec-CH-UA-Model',
57+
'Public-Key-Pins': 'max-age=0',
58+
'Public-Key-Pins-Report-Only': 'max-age=0; report-uri="/untrusted"',
59+
'Access-Control-Allow-Origin': '*',
60+
'Access-Control-Allow-Credentials': 'true',
61+
'Cross-Origin-Resource-Policy': 'cross-origin',
62+
'Clear-Site-Data': '"*"',
63+
'Permissions-Policy': 'camera=*',
64+
'Document-Policy': 'force-load-at-top',
65+
'Referrer-Policy': 'unsafe-url',
66+
'Strict-Transport-Security': 'max-age=0',
67+
'Origin-Agent-Cluster': '?0',
68+
Location: '/untrusted',
69+
Refresh: '0; url=/untrusted',
70+
Link: '</untrusted>; rel=preload; as=script',
71+
'Report-To': '{"group":"untrusted"}',
72+
'Reporting-Endpoints': 'default="/untrusted"',
73+
NEL: '{"report_to":"untrusted","max_age":3600}',
74+
'Content-Length': '1',
75+
'Content-Encoding': 'gzip',
76+
'Transfer-Encoding': 'chunked',
77+
Connection: 'close',
78+
'X-Middleware-Rewrite': '/untrusted',
79+
'X-Accel-Redirect': '/untrusted',
80+
'X-Sendfile': '/untrusted',
81+
},
82+
},
83+
})
84+
85+
expect(Object.fromEntries(response.headers)).toEqual({
86+
'content-type': 'application/json',
87+
'x-content-type-options': 'nosniff',
88+
})
89+
expect(await response.json()).toEqual({ message: 'complete' })
90+
})
91+
})

‎apps/sim/lib/workflows/utils.ts‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -293,17 +293,80 @@ export const workflowHasResponseBlock = (
293293
return responseBlock !== undefined
294294
}
295295

296+
/** Headers that control the app origin or HTTP transport belong to the server. */
297+
const RESERVED_RESPONSE_HEADERS = new Set([
298+
'accept-ch',
299+
'accept-ch-lifetime',
300+
'alt-svc',
301+
'clear-site-data',
302+
'connection',
303+
'content-disposition',
304+
'content-encoding',
305+
'content-length',
306+
'content-location',
307+
'content-range',
308+
'critical-ch',
309+
'document-policy',
310+
'keep-alive',
311+
'link',
312+
'location',
313+
'nel',
314+
'origin-agent-cluster',
315+
'permissions-policy',
316+
'proxy-authenticate',
317+
'public-key-pins',
318+
'public-key-pins-report-only',
319+
'referrer-policy',
320+
'refresh',
321+
'report-to',
322+
'reporting-endpoints',
323+
'set-cookie',
324+
'set-cookie2',
325+
'strict-transport-security',
326+
'trailer',
327+
'transfer-encoding',
328+
'upgrade',
329+
'www-authenticate',
330+
'x-content-security-policy',
331+
'x-dns-prefetch-control',
332+
'x-download-options',
333+
'x-frame-options',
334+
'x-permitted-cross-domain-policies',
335+
'x-sendfile',
336+
'x-ua-compatible',
337+
'x-webkit-csp',
338+
'x-xss-protection',
339+
])
340+
341+
const RESERVED_RESPONSE_HEADER_PREFIXES = [
342+
'access-control-',
343+
'content-security-policy',
344+
'cross-origin-',
345+
'sec-',
346+
'x-accel-',
347+
'x-middleware-',
348+
] as const
349+
296350
export const createHttpResponseFromBlock = async (
297351
executionResult: Pick<ExecutionResult, 'output'>,
298352
context?: ExecutionMaterializationContext
299353
): Promise<NextResponse> => {
300354
const { data = {}, status = 200, headers = {} } = executionResult.output
301355
const responseData = await materializeInlineExecutionValue(data, context)
302356

303-
const responseHeaders = new Headers({
304-
'Content-Type': 'application/json',
305-
...headers,
306-
})
357+
const responseHeaders = new Headers()
358+
for (const [name, value] of new Headers(headers)) {
359+
if (
360+
!RESERVED_RESPONSE_HEADERS.has(name) &&
361+
!RESERVED_RESPONSE_HEADER_PREFIXES.some((prefix) => name.startsWith(prefix))
362+
) {
363+
responseHeaders.set(name, value)
364+
}
365+
}
366+
367+
// JSON serialization does not escape HTML; enforce the MIME type after normalizing header names.
368+
responseHeaders.set('Content-Type', 'application/json')
369+
responseHeaders.set('X-Content-Type-Options', 'nosniff')
307370

308371
return NextResponse.json(responseData, {
309372
status: status,

0 commit comments

Comments
 (0)