From 5679e8e6906e89d926f22fe03faded56a3638063 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 11:46:07 -0700 Subject: [PATCH 1/2] fix(execute): block cross-origin session-authenticated workflow runs --- .../app/api/workflows/[id]/execute/route.ts | 12 +++++ .../sim/lib/core/security/same-origin.test.ts | 51 +++++++++++++++++++ apps/sim/lib/core/security/same-origin.ts | 29 +++++++++++ 3 files changed, 92 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.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 34868195dc1..4f636dfb4f0 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 { isSameOriginBrowserRequest } 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 }) + + // Session-cookie execution must originate from our own front-end (the Run + // button). Reject cross-origin / non-browser callers replaying a session + // cookie — this closes a CSRF hole and blocks cookie-replay automation. + // API-key, public-API, and internal-JWT callers don't use cookies, so the + // guard is scoped strictly to session auth. + if (auth.success && auth.authType === AuthType.SESSION && !isSameOriginBrowserRequest(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..53968b7dc78 --- /dev/null +++ b/apps/sim/lib/core/security/same-origin.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import type { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { isSameOriginBrowserRequest } 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('isSameOriginBrowserRequest', () => { + it('accepts a same-origin browser fetch', () => { + expect(isSameOriginBrowserRequest(makeRequest({ 'sec-fetch-site': 'same-origin' }))).toBe(true) + }) + + it('accepts a same-site browser fetch', () => { + expect(isSameOriginBrowserRequest(makeRequest({ 'sec-fetch-site': 'same-site' }))).toBe(true) + }) + + it('rejects cross-site requests', () => { + expect(isSameOriginBrowserRequest(makeRequest({ 'sec-fetch-site': 'cross-site' }))).toBe(false) + }) + + it('rejects navigations not initiated from our front-end', () => { + expect(isSameOriginBrowserRequest(makeRequest({ 'sec-fetch-site': 'none' }))).toBe(false) + }) + + it('falls back to the Origin header when Sec-Fetch-Site is absent', () => { + const origin = new URL(getBaseUrl()).origin + expect(isSameOriginBrowserRequest(makeRequest({ origin }))).toBe(true) + }) + + it('rejects a foreign Origin when Sec-Fetch-Site is absent', () => { + expect(isSameOriginBrowserRequest(makeRequest({ origin: 'https://evil.example.com' }))).toBe( + false + ) + }) + + it('rejects non-browser callers with neither Sec-Fetch-Site nor Origin', () => { + expect(isSameOriginBrowserRequest(makeRequest({}))).toBe(false) + }) + + it('trusts the unforgeable Sec-Fetch-Site over a spoofed same-origin Origin', () => { + const origin = new URL(getBaseUrl()).origin + expect( + isSameOriginBrowserRequest(makeRequest({ 'sec-fetch-site': 'cross-site', origin })) + ).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..7bb9f2302d8 --- /dev/null +++ b/apps/sim/lib/core/security/same-origin.ts @@ -0,0 +1,29 @@ +import type { NextRequest } from 'next/server' +import { isSameOrigin } from '@/lib/core/utils/validation' + +/** + * Returns true when a request demonstrably originates from the application's own + * front-end (a same-origin browser fetch), and false for cross-site or + * non-browser callers — e.g. a script replaying a leaked/borrowed session cookie. + * + * `Sec-Fetch-Site` is computed by the browser and is a forbidden header, so it + * cannot be set by `fetch`, `curl`, or a server-side HTTP client. It is therefore + * the primary, unforgeable signal. When it is absent (rare; older clients), we + * fall back to an `Origin` same-origin check — a browser `fetch` POST always + * sends `Origin`, so a missing `Origin` here indicates a non-browser caller and + * is rejected (secure default). + * + * Intended to guard session-cookie-authenticated, state-changing routes against + * cross-site request forgery and cookie-replay automation. API-key / public-API + * / internal-JWT callers do not use cookies and must not be gated by this. + */ +export function isSameOriginBrowserRequest(req: NextRequest): boolean { + const secFetchSite = req.headers.get('sec-fetch-site') + if (secFetchSite) { + return secFetchSite === 'same-origin' || secFetchSite === 'same-site' + } + + const origin = req.headers.get('origin') + if (!origin) return false + return isSameOrigin(origin) +} From 80c0ebf8342a6838096ec470d1a1e6a81a1a9eb1 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 12:28:01 -0700 Subject: [PATCH 2/2] 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. --- .../[id]/execute/route.async.test.ts | 20 ++++++++++ .../app/api/workflows/[id]/execute/route.ts | 14 +++---- .../sim/lib/core/security/same-origin.test.ts | 34 +++++++++-------- apps/sim/lib/core/security/same-origin.ts | 37 +++++++++++-------- 4 files changed, 67 insertions(+), 38 deletions(-) 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 4f636dfb4f0..6b048ac144a 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -20,7 +20,7 @@ import { getTimeoutErrorMessage, isTimeoutError, } from '@/lib/core/execution-limits' -import { isSameOriginBrowserRequest } from '@/lib/core/security/same-origin' +import { isCrossOriginSessionRequest } from '@/lib/core/security/same-origin' import { generateRequestId } from '@/lib/core/utils/request' import { SSE_HEADERS } from '@/lib/core/utils/sse' import { @@ -395,12 +395,12 @@ async function handleExecutePost( try { const auth = await checkHybridAuth(req, { requireWorkflowId: false }) - // Session-cookie execution must originate from our own front-end (the Run - // button). Reject cross-origin / non-browser callers replaying a session - // cookie — this closes a CSRF hole and blocks cookie-replay automation. - // API-key, public-API, and internal-JWT callers don't use cookies, so the - // guard is scoped strictly to session auth. - if (auth.success && auth.authType === AuthType.SESSION && !isSameOriginBrowserRequest(req)) { + // 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 }) } diff --git a/apps/sim/lib/core/security/same-origin.test.ts b/apps/sim/lib/core/security/same-origin.test.ts index 53968b7dc78..4a73e6050bf 100644 --- a/apps/sim/lib/core/security/same-origin.test.ts +++ b/apps/sim/lib/core/security/same-origin.test.ts @@ -3,49 +3,51 @@ */ import type { NextRequest } from 'next/server' import { describe, expect, it } from 'vitest' -import { isSameOriginBrowserRequest } from '@/lib/core/security/same-origin' +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('isSameOriginBrowserRequest', () => { - it('accepts a same-origin browser fetch', () => { - expect(isSameOriginBrowserRequest(makeRequest({ 'sec-fetch-site': 'same-origin' }))).toBe(true) +describe('isCrossOriginSessionRequest', () => { + it('allows a same-origin browser fetch', () => { + expect(isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'same-origin' }))).toBe( + false + ) }) - it('accepts a same-site browser fetch', () => { - expect(isSameOriginBrowserRequest(makeRequest({ 'sec-fetch-site': 'same-site' }))).toBe(true) + 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(isSameOriginBrowserRequest(makeRequest({ 'sec-fetch-site': 'cross-site' }))).toBe(false) + expect(isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'cross-site' }))).toBe(true) }) it('rejects navigations not initiated from our front-end', () => { - expect(isSameOriginBrowserRequest(makeRequest({ 'sec-fetch-site': 'none' }))).toBe(false) + expect(isCrossOriginSessionRequest(makeRequest({ 'sec-fetch-site': 'none' }))).toBe(true) }) - it('falls back to the Origin header when Sec-Fetch-Site is absent', () => { + it('falls back to the Origin header when Sec-Fetch-Site is absent (same-origin allowed)', () => { const origin = new URL(getBaseUrl()).origin - expect(isSameOriginBrowserRequest(makeRequest({ origin }))).toBe(true) + expect(isCrossOriginSessionRequest(makeRequest({ origin }))).toBe(false) }) it('rejects a foreign Origin when Sec-Fetch-Site is absent', () => { - expect(isSameOriginBrowserRequest(makeRequest({ origin: 'https://evil.example.com' }))).toBe( - false + expect(isCrossOriginSessionRequest(makeRequest({ origin: 'https://evil.example.com' }))).toBe( + true ) }) - it('rejects non-browser callers with neither Sec-Fetch-Site nor Origin', () => { - expect(isSameOriginBrowserRequest(makeRequest({}))).toBe(false) + 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( - isSameOriginBrowserRequest(makeRequest({ 'sec-fetch-site': 'cross-site', origin })) - ).toBe(false) + 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 index 7bb9f2302d8..ab976f0ca51 100644 --- a/apps/sim/lib/core/security/same-origin.ts +++ b/apps/sim/lib/core/security/same-origin.ts @@ -2,28 +2,35 @@ import type { NextRequest } from 'next/server' import { isSameOrigin } from '@/lib/core/utils/validation' /** - * Returns true when a request demonstrably originates from the application's own - * front-end (a same-origin browser fetch), and false for cross-site or - * non-browser callers — e.g. a script replaying a leaked/borrowed session cookie. + * 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 computed by the browser and is a forbidden header, so it - * cannot be set by `fetch`, `curl`, or a server-side HTTP client. It is therefore - * the primary, unforgeable signal. When it is absent (rare; older clients), we - * fall back to an `Origin` same-origin check — a browser `fetch` POST always - * sends `Origin`, so a missing `Origin` here indicates a non-browser caller and - * is rejected (secure default). + * `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. * - * Intended to guard session-cookie-authenticated, state-changing routes against - * cross-site request forgery and cookie-replay automation. API-key / public-API - * / internal-JWT callers do not use cookies and must not be gated by this. + * 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 isSameOriginBrowserRequest(req: NextRequest): boolean { +export function isCrossOriginSessionRequest(req: NextRequest): boolean { const secFetchSite = req.headers.get('sec-fetch-site') if (secFetchSite) { - return secFetchSite === 'same-origin' || secFetchSite === 'same-site' + return secFetchSite !== 'same-origin' } const origin = req.headers.get('origin') if (!origin) return false - return isSameOrigin(origin) + + try { + return !isSameOrigin(origin) + } catch { + return false + } }