diff --git a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts index ebce3426622..87715f0b514 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.async.test.ts @@ -194,6 +194,26 @@ describe('workflow execute async route', () => { ) }) + it('rejects cross-origin 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('rejects oversized request bodies before authorization work', async () => { const req = createMockRequest( 'POST', diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 34868195dc1..6b048ac144a 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -20,6 +20,7 @@ import { getTimeoutErrorMessage, isTimeoutError, } from '@/lib/core/execution-limits' +import { isCrossOriginSessionRequest } from '@/lib/core/security/same-origin' import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { @@ -393,6 +394,17 @@ async function handleExecutePost( try { const auth = await checkHybridAuth(req, { requireWorkflowId: false }) + + // CSRF guard: reject session-cookie execution that is provably cross-origin + // (a different site driving the user's browser). Scoped to session auth — + // API-key / public-API / internal-JWT callers don't use cookies. This is not + // a defense against a non-browser client forging headers; that surface is + // covered by the credit and execution rate-limit gates. + if (auth.success && auth.authType === AuthType.SESSION && isCrossOriginSessionRequest(req)) { + reqLogger.warn('Rejected cross-origin session-authenticated execute request') + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + const isMcpBridgeRequest = auth.authType === AuthType.INTERNAL_JWT && req.headers.get(MCP_TOOL_BRIDGE_HEADER) === 'true' const useMcpBridgeAuthenticatedUserAsActor = diff --git a/apps/sim/lib/core/security/same-origin.test.ts b/apps/sim/lib/core/security/same-origin.test.ts new file mode 100644 index 00000000000..4a73e6050bf --- /dev/null +++ b/apps/sim/lib/core/security/same-origin.test.ts @@ -0,0 +1,53 @@ +/** + * @vitest-environment node + */ +import type { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { isCrossOriginSessionRequest } from '@/lib/core/security/same-origin' +import { getBaseUrl } from '@/lib/core/utils/urls' + +function makeRequest(headers: Record): NextRequest { + return { headers: new Headers(headers) } as unknown as NextRequest +} + +describe('isCrossOriginSessionRequest', () => { + it('allows a same-origin browser fetch', () => { + expect(isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'same-origin' }))).toBe( + false + ) + }) + + it('rejects same-site requests (sibling subdomains are not our origin)', () => { + expect(isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'same-site' }))).toBe(true) + }) + + it('rejects cross-site requests', () => { + expect(isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'cross-site' }))).toBe(true) + }) + + it('rejects navigations not initiated from our front-end', () => { + expect(isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'none' }))).toBe(true) + }) + + it('falls back to the Origin header when Sec-Fetch-Site is absent (same-origin allowed)', () => { + const origin = new URL(getBaseUrl()).origin + expect(isCrossOriginSessionRequest(makeRequest({ origin }))).toBe(false) + }) + + it('rejects a foreign Origin when Sec-Fetch-Site is absent', () => { + expect(isCrossOriginSessionRequest(makeRequest({ origin: 'https://evil.example.com' }))).toBe( + true + ) + }) + + it('allows requests where the origin cannot be determined (no Sec-Fetch-Site, no Origin)', () => { + expect(isCrossOriginSessionRequest(makeRequest({}))).toBe(false) + }) + + it('trusts the unforgeable Sec-Fetch-Site over a spoofed same-origin Origin', () => { + const origin = new URL(getBaseUrl()).origin + expect( + isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'cross-site', origin })) + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/core/security/same-origin.ts b/apps/sim/lib/core/security/same-origin.ts new file mode 100644 index 00000000000..ab976f0ca51 --- /dev/null +++ b/apps/sim/lib/core/security/same-origin.ts @@ -0,0 +1,36 @@ +import type { NextRequest } from 'next/server' +import { isSameOrigin } from '@/lib/core/utils/validation' + +/** + * Returns true when a request is provably cross-origin — a browser fetch driven + * from a different site than our own. Used to reject session-cookie CSRF on + * state-changing routes: a cross-site browser request always carries + * `Sec-Fetch-Site: cross-site` or a mismatched `Origin`, and neither header can + * be set by in-browser attacker JavaScript (both are forbidden headers). + * + * `Sec-Fetch-Site` is the primary signal; only `same-origin` is treated as our + * own front-end. The app is single-origin, so `same-site` (sibling subdomains), + * `cross-site`, and `none` are all rejected. When it is absent, fall back to an + * `Origin` same-origin check. When neither header is present the origin cannot + * be determined, so the request is allowed — a genuine cross-site browser attack + * cannot omit these headers. + * + * This is CSRF protection only. It does not defend against a non-browser client + * that forges headers directly (no header-based check can); that surface is + * covered by the credit and execution rate-limit gates. + */ +export function isCrossOriginSessionRequest(req: NextRequest): boolean { + const secFetchSite = req.headers.get('sec-fetch-site') + if (secFetchSite) { + return secFetchSite !== 'same-origin' + } + + const origin = req.headers.get('origin') + if (!origin) return false + + try { + return !isSameOrigin(origin) + } catch { + return false + } +}