Skip to content

Commit 2cf5172

Browse files
fix(execute): reject only cross-site session execution (CSRF guard) (#5068)
1 parent 318cb5e commit 2cf5172

4 files changed

Lines changed: 106 additions & 0 deletions

File tree

apps/sim/app/api/workflows/[id]/execute/route.async.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,44 @@ describe('workflow execute async route', () => {
194194
)
195195
})
196196

197+
it('rejects cross-site session requests before authorization work', async () => {
198+
const req = createMockRequest(
199+
'POST',
200+
{ input: { hello: 'world' } },
201+
{
202+
'Content-Type': 'application/json',
203+
'Sec-Fetch-Site': 'cross-site',
204+
}
205+
)
206+
const params = Promise.resolve({ id: 'workflow-1' })
207+
208+
const response = await POST(req, { params })
209+
const body = await response.json()
210+
211+
expect(response.status).toBe(403)
212+
expect(body.error).toBe('Access denied')
213+
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
214+
expect(mockEnqueue).not.toHaveBeenCalled()
215+
})
216+
217+
it('allows same-site session requests (multi-subdomain Run, e.g. www.<domain>)', async () => {
218+
const req = createMockRequest(
219+
'POST',
220+
{ input: { hello: 'world' } },
221+
{
222+
'Content-Type': 'application/json',
223+
'X-Execution-Mode': 'async',
224+
'Sec-Fetch-Site': 'same-site',
225+
}
226+
)
227+
const params = Promise.resolve({ id: 'workflow-1' })
228+
229+
const response = await POST(req, { params })
230+
231+
expect(response.status).toBe(202)
232+
expect(mockEnqueue).toHaveBeenCalled()
233+
})
234+
197235
it('rejects oversized request bodies before authorization work', async () => {
198236
const req = createMockRequest(
199237
'POST',

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
getTimeoutErrorMessage,
2121
isTimeoutError,
2222
} from '@/lib/core/execution-limits'
23+
import { isCrossSiteSessionRequest } from '@/lib/core/security/same-origin'
2324
import { generateRequestId } from '@/lib/core/utils/request'
2425
import { SSE_HEADERS } from '@/lib/core/utils/sse'
2526
import {
@@ -393,6 +394,18 @@ async function handleExecutePost(
393394

394395
try {
395396
const auth = await checkHybridAuth(req, { requireWorkflowId: false })
397+
398+
// CSRF guard: reject session-cookie execution that is provably cross-site
399+
// (a different site driving the user's browser). same-origin and same-site
400+
// are allowed so multi-subdomain deployments (e.g. www.<domain> calling
401+
// <domain>) keep working. Scoped to session auth — API-key / public-API /
402+
// internal-JWT callers don't use cookies. Not a defense against a non-browser
403+
// client forging headers; that's covered by the credit/rate-limit gates.
404+
if (auth.success && auth.authType === AuthType.SESSION && isCrossSiteSessionRequest(req)) {
405+
reqLogger.warn('Rejected cross-site session-authenticated execute request')
406+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
407+
}
408+
396409
const isMcpBridgeRequest =
397410
auth.authType === AuthType.INTERNAL_JWT && req.headers.get(MCP_TOOL_BRIDGE_HEADER) === 'true'
398411
const useMcpBridgeAuthenticatedUserAsActor =
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { NextRequest } from 'next/server'
5+
import { describe, expect, it } from 'vitest'
6+
import { isCrossSiteSessionRequest } from '@/lib/core/security/same-origin'
7+
8+
function makeRequest(headers: Record<string, string>): NextRequest {
9+
return { headers: new Headers(headers) } as unknown as NextRequest
10+
}
11+
12+
describe('isCrossSiteSessionRequest', () => {
13+
it('rejects cross-site requests', () => {
14+
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'cross-site' }))).toBe(true)
15+
})
16+
17+
it('allows same-origin browser fetches', () => {
18+
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'same-origin' }))).toBe(false)
19+
})
20+
21+
it('allows same-site fetches (sibling subdomains, e.g. www.<domain> -> <domain>)', () => {
22+
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'same-site' }))).toBe(false)
23+
})
24+
25+
it('allows user-initiated requests (Sec-Fetch-Site: none)', () => {
26+
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'none' }))).toBe(false)
27+
})
28+
29+
it('allows requests with no Sec-Fetch-Site header (older clients)', () => {
30+
expect(isCrossSiteSessionRequest(makeRequest({}))).toBe(false)
31+
})
32+
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { NextRequest } from 'next/server'
2+
3+
/**
4+
* Returns true when a request is provably cross-site — a browser fetch driven
5+
* from a different site than our own. Used to reject session-cookie CSRF on
6+
* state-changing routes.
7+
*
8+
* `Sec-Fetch-Site` is browser-set and a forbidden header, so page JavaScript
9+
* cannot forge it. A cross-site browser request (the CSRF threat) always reports
10+
* `cross-site`. We deliberately accept `same-origin`, `same-site`, and `none`:
11+
* the app is served across sibling subdomains (e.g. `www.<domain>` calling
12+
* `<domain>`), so a legitimate `same-site` fetch must NOT be blocked — rejecting
13+
* it 403s real "Run" requests on those origins. An absent header (older clients)
14+
* is also allowed; the conventional CSRF posture is to reject only a provable
15+
* cross-site request.
16+
*
17+
* This is CSRF protection only. It does not defend against a non-browser client
18+
* that forges headers directly (no header check can); that surface is covered by
19+
* the credit and execution rate-limit gates.
20+
*/
21+
export function isCrossSiteSessionRequest(req: NextRequest): boolean {
22+
return req.headers.get('sec-fetch-site') === 'cross-site'
23+
}

0 commit comments

Comments
 (0)