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
38 changes: 38 additions & 0 deletions apps/sim/app/api/workflows/[id]/execute/route.async.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,44 @@ describe('workflow execute async route', () => {
)
})

it('rejects cross-site session requests before authorization work', async () => {
const req = createMockRequest(
'POST',
{ input: { hello: 'world' } },
{
'Content-Type': 'application/json',
'Sec-Fetch-Site': 'cross-site',
}
)
const params = Promise.resolve({ id: 'workflow-1' })

const response = await POST(req, { params })
const body = await response.json()

expect(response.status).toBe(403)
expect(body.error).toBe('Access denied')
expect(mockAuthorizeWorkflowByWorkspacePermission).not.toHaveBeenCalled()
expect(mockEnqueue).not.toHaveBeenCalled()
})

it('allows same-site session requests (multi-subdomain Run, e.g. www.<domain>)', async () => {
const req = createMockRequest(
'POST',
{ input: { hello: 'world' } },
{
'Content-Type': 'application/json',
'X-Execution-Mode': 'async',
'Sec-Fetch-Site': 'same-site',
}
)
const params = Promise.resolve({ id: 'workflow-1' })

const response = await POST(req, { params })

expect(response.status).toBe(202)
expect(mockEnqueue).toHaveBeenCalled()
})

it('rejects oversized request bodies before authorization work', async () => {
const req = createMockRequest(
'POST',
Expand Down
13 changes: 13 additions & 0 deletions apps/sim/app/api/workflows/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
getTimeoutErrorMessage,
isTimeoutError,
} from '@/lib/core/execution-limits'
import { isCrossSiteSessionRequest } from '@/lib/core/security/same-origin'
import { generateRequestId } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import {
Expand Down Expand Up @@ -393,6 +394,18 @@ async function handleExecutePost(

try {
const auth = await checkHybridAuth(req, { requireWorkflowId: false })

// CSRF guard: reject session-cookie execution that is provably cross-site
// (a different site driving the user's browser). same-origin and same-site
// are allowed so multi-subdomain deployments (e.g. www.<domain> calling
// <domain>) keep working. Scoped to session auth — API-key / public-API /
// internal-JWT callers don't use cookies. Not a defense against a non-browser
// client forging headers; that's covered by the credit/rate-limit gates.
if (auth.success && auth.authType === AuthType.SESSION && isCrossSiteSessionRequest(req)) {
reqLogger.warn('Rejected cross-site session-authenticated execute request')
return NextResponse.json({ error: 'Access denied' }, { status: 403 })
}
Comment thread
TheodoreSpeaks marked this conversation as resolved.

const isMcpBridgeRequest =
auth.authType === AuthType.INTERNAL_JWT && req.headers.get(MCP_TOOL_BRIDGE_HEADER) === 'true'
const useMcpBridgeAuthenticatedUserAsActor =
Expand Down
32 changes: 32 additions & 0 deletions apps/sim/lib/core/security/same-origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* @vitest-environment node
*/
import type { NextRequest } from 'next/server'
import { describe, expect, it } from 'vitest'
import { isCrossSiteSessionRequest } from '@/lib/core/security/same-origin'

function makeRequest(headers: Record<string, string>): NextRequest {
return { headers: new Headers(headers) } as unknown as NextRequest
}

describe('isCrossSiteSessionRequest', () => {
it('rejects cross-site requests', () => {
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'cross-site' }))).toBe(true)
})

it('allows same-origin browser fetches', () => {
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'same-origin' }))).toBe(false)
})

it('allows same-site fetches (sibling subdomains, e.g. www.<domain> -> <domain>)', () => {
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'same-site' }))).toBe(false)
})

it('allows user-initiated requests (Sec-Fetch-Site: none)', () => {
expect(isCrossSiteSessionRequest(makeRequest({ 'sec-fetch-site': 'none' }))).toBe(false)
})

it('allows requests with no Sec-Fetch-Site header (older clients)', () => {
expect(isCrossSiteSessionRequest(makeRequest({}))).toBe(false)
})
})
23 changes: 23 additions & 0 deletions apps/sim/lib/core/security/same-origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { NextRequest } from 'next/server'

/**
* Returns true when a request is provably cross-site — a browser fetch driven
* from a different site than our own. Used to reject session-cookie CSRF on
* state-changing routes.
*
* `Sec-Fetch-Site` is browser-set and a forbidden header, so page JavaScript
* cannot forge it. A cross-site browser request (the CSRF threat) always reports
* `cross-site`. We deliberately accept `same-origin`, `same-site`, and `none`:
* the app is served across sibling subdomains (e.g. `www.<domain>` calling
* `<domain>`), so a legitimate `same-site` fetch must NOT be blocked — rejecting
* it 403s real "Run" requests on those origins. An absent header (older clients)
* is also allowed; the conventional CSRF posture is to reject only a provable
* cross-site request.
*
* This is CSRF protection only. It does not defend against a non-browser client
* that forges headers directly (no header check can); that surface is covered by
* the credit and execution rate-limit gates.
*/
export function isCrossSiteSessionRequest(req: NextRequest): boolean {
return req.headers.get('sec-fetch-site') === 'cross-site'
}
Loading