Skip to content

Commit 67e02fa

Browse files
fix(execute): block cross-origin session-authenticated workflow runs (#5062)
* fix(execute): block cross-origin session-authenticated workflow runs * fix(execute): scope session origin guard to provable cross-origin Address review on #5062: - Reject session-cookie execution only when provably cross-origin (Sec-Fetch-Site cross-site/same-site/none, or a mismatched Origin) instead of failing closed on absent headers. Fixes route tests that 403'd on header-less session requests, and reflects that this is CSRF protection, not anti-cookie-replay. - Drop same-site from the trusted set: only same-origin is our front-end. - Guard the Origin fallback in try/catch so a getBaseUrl() throw can't escape. - Add a route-level cross-origin rejection test.
1 parent 18edc94 commit 67e02fa

4 files changed

Lines changed: 121 additions & 0 deletions

File tree

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

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

197+
it('rejects cross-origin 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+
197217
it('rejects oversized request bodies before authorization work', async () => {
198218
const req = createMockRequest(
199219
'POST',

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

Lines changed: 12 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 { isCrossOriginSessionRequest } 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,17 @@ 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-origin
399+
// (a different site driving the user's browser). Scoped to session auth —
400+
// API-key / public-API / internal-JWT callers don't use cookies. This is not
401+
// a defense against a non-browser client forging headers; that surface is
402+
// covered by the credit and execution rate-limit gates.
403+
if (auth.success && auth.authType === AuthType.SESSION && isCrossOriginSessionRequest(req)) {
404+
reqLogger.warn('Rejected cross-origin session-authenticated execute request')
405+
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
406+
}
407+
396408
const isMcpBridgeRequest =
397409
auth.authType === AuthType.INTERNAL_JWT && req.headers.get(MCP_TOOL_BRIDGE_HEADER) === 'true'
398410
const useMcpBridgeAuthenticatedUserAsActor =
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import type { NextRequest } from 'next/server'
5+
import { describe, expect, it } from 'vitest'
6+
import { isCrossOriginSessionRequest } from '@/lib/core/security/same-origin'
7+
import { getBaseUrl } from '@/lib/core/utils/urls'
8+
9+
function makeRequest(headers: Record<string, string>): NextRequest {
10+
return { headers: new Headers(headers) } as unknown as NextRequest
11+
}
12+
13+
describe('isCrossOriginSessionRequest', () => {
14+
it('allows a same-origin browser fetch', () => {
15+
expect(isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'same-origin' }))).toBe(
16+
false
17+
)
18+
})
19+
20+
it('rejects same-site requests (sibling subdomains are not our origin)', () => {
21+
expect(isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'same-site' }))).toBe(true)
22+
})
23+
24+
it('rejects cross-site requests', () => {
25+
expect(isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'cross-site' }))).toBe(true)
26+
})
27+
28+
it('rejects navigations not initiated from our front-end', () => {
29+
expect(isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'none' }))).toBe(true)
30+
})
31+
32+
it('falls back to the Origin header when Sec-Fetch-Site is absent (same-origin allowed)', () => {
33+
const origin = new URL(getBaseUrl()).origin
34+
expect(isCrossOriginSessionRequest(makeRequest({ origin }))).toBe(false)
35+
})
36+
37+
it('rejects a foreign Origin when Sec-Fetch-Site is absent', () => {
38+
expect(isCrossOriginSessionRequest(makeRequest({ origin: 'https://evil.example.com' }))).toBe(
39+
true
40+
)
41+
})
42+
43+
it('allows requests where the origin cannot be determined (no Sec-Fetch-Site, no Origin)', () => {
44+
expect(isCrossOriginSessionRequest(makeRequest({}))).toBe(false)
45+
})
46+
47+
it('trusts the unforgeable Sec-Fetch-Site over a spoofed same-origin Origin', () => {
48+
const origin = new URL(getBaseUrl()).origin
49+
expect(
50+
isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'cross-site', origin }))
51+
).toBe(true)
52+
})
53+
})
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { NextRequest } from 'next/server'
2+
import { isSameOrigin } from '@/lib/core/utils/validation'
3+
4+
/**
5+
* Returns true when a request is provably cross-origin — a browser fetch driven
6+
* from a different site than our own. Used to reject session-cookie CSRF on
7+
* state-changing routes: a cross-site browser request always carries
8+
* `Sec-Fetch-Site: cross-site` or a mismatched `Origin`, and neither header can
9+
* be set by in-browser attacker JavaScript (both are forbidden headers).
10+
*
11+
* `Sec-Fetch-Site` is the primary signal; only `same-origin` is treated as our
12+
* own front-end. The app is single-origin, so `same-site` (sibling subdomains),
13+
* `cross-site`, and `none` are all rejected. When it is absent, fall back to an
14+
* `Origin` same-origin check. When neither header is present the origin cannot
15+
* be determined, so the request is allowed — a genuine cross-site browser attack
16+
* cannot omit these headers.
17+
*
18+
* This is CSRF protection only. It does not defend against a non-browser client
19+
* that forges headers directly (no header-based check can); that surface is
20+
* covered by the credit and execution rate-limit gates.
21+
*/
22+
export function isCrossOriginSessionRequest(req: NextRequest): boolean {
23+
const secFetchSite = req.headers.get('sec-fetch-site')
24+
if (secFetchSite) {
25+
return secFetchSite !== 'same-origin'
26+
}
27+
28+
const origin = req.headers.get('origin')
29+
if (!origin) return false
30+
31+
try {
32+
return !isSameOrigin(origin)
33+
} catch {
34+
return false
35+
}
36+
}

0 commit comments

Comments
 (0)