From 79160d7224a551145f35d0f3e4219c75b5478186 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 13:11:26 -0700 Subject: [PATCH] fix(execute): reject only cross-site session execution (CSRF guard) --- .../[id]/execute/route.async.test.ts | 38 +++++++++++++++++++ .../app/api/workflows/[id]/execute/route.ts | 13 +++++++ .../sim/lib/core/security/same-origin.test.ts | 32 ++++++++++++++++ apps/sim/lib/core/security/same-origin.ts | 23 +++++++++++ 4 files changed, 106 insertions(+) create mode 100644 apps/sim/lib/core/security/same-origin.test.ts create mode 100644 apps/sim/lib/core/security/same-origin.ts 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..17f90047c77 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,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.)', 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', diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 34868195dc1..0ac748e12e2 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 { isCrossSiteSessionRequest } 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,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. calling + // ) 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 }) + } + 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..6b4c9f4f993 --- /dev/null +++ b/apps/sim/lib/core/security/same-origin.test.ts @@ -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): 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. -> )', () => { + 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) + }) +}) 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..1fb605ef297 --- /dev/null +++ b/apps/sim/lib/core/security/same-origin.ts @@ -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.` calling + * ``), 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' +}