mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(execute): reject only cross-site session execution (CSRF guard) (#5068)
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
Reference in New Issue
Block a user