fix(execute): reject only cross-site session execution (CSRF guard) (#5068)

This commit is contained in:
Theodore Li
2026-06-15 16:19:43 -04:00
committed by GitHub
parent 318cb5e414
commit 2cf51725c9
4 changed files with 106 additions and 0 deletions
@@ -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',
@@ -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.<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 })
}
const isMcpBridgeRequest =
auth.authType === AuthType.INTERNAL_JWT && req.headers.get(MCP_TOOL_BRIDGE_HEADER) === 'true'
const useMcpBridgeAuthenticatedUserAsActor =
@@ -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
View File
@@ -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'
}