mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(chat): close SSO auth bypass via checkSSOAccess body flag (#4408)
* fix(chat): close SSO auth bypass via checkSSOAccess body flag - Remove checkSSOAccess short-circuit; SSO branch always validates via getSession() - Skip chat_auth cookie issuance/validation for SSO deployments to prevent replay - Split eligibility pre-flight into dedicated POST /api/chat/[identifier]/sso route - Drop .passthrough() and checkSSOAccess from deployed chat contracts - Add SSO branch test coverage in chat utils * fix(chat): cast allowedEmails to string[] for SSO eligibility check * fix(chat): close SSO GET cookie replay and add eligibility rate limit - Skip chat_auth cookie validation for SSO in GET handler (replay vector for pre-fix cookies) - Route SSO GET through getSession() instead of always returning auth_required_sso so post-IdP config fetch works - Add per-IP rate limiting to /api/chat/[identifier]/sso to prevent allowlist enumeration
This commit is contained in:
@@ -149,7 +149,9 @@ export const POST = withRouteHandler(
|
||||
request
|
||||
)
|
||||
|
||||
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
|
||||
if (deployment.authType !== 'sso') {
|
||||
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
@@ -358,6 +360,7 @@ export const GET = withRouteHandler(
|
||||
|
||||
if (
|
||||
deployment.authType !== 'public' &&
|
||||
deployment.authType !== 'sso' &&
|
||||
authCookie &&
|
||||
validateAuthToken(authCookie.value, deployment.id, deployment.password)
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { chatSSOContract } from '@/lib/api/contracts/chats'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
|
||||
import { RateLimiter } from '@/lib/core/rate-limiter'
|
||||
import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment'
|
||||
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
|
||||
|
||||
const logger = createLogger('ChatSSOAPI')
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const rateLimiter = new RateLimiter()
|
||||
|
||||
const SSO_IP_RATE_LIMIT: TokenBucketConfig = {
|
||||
maxTokens: 20,
|
||||
refillRate: 20,
|
||||
refillIntervalMs: 15 * 60_000,
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(
|
||||
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
|
||||
const requestId = generateRequestId()
|
||||
|
||||
const ip = getClientIp(request)
|
||||
if (ip !== 'unknown') {
|
||||
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
|
||||
`chat-sso:ip:${ip}`,
|
||||
SSO_IP_RATE_LIMIT
|
||||
)
|
||||
if (!ipRateLimit.allowed) {
|
||||
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
|
||||
const retryAfter = Math.ceil(
|
||||
(ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000
|
||||
)
|
||||
const response = createErrorResponse('Too many requests. Please try again later.', 429)
|
||||
response.headers.set('Retry-After', String(retryAfter))
|
||||
return addCorsHeaders(response, request)
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = await parseRequest(chatSSOContract, request, context)
|
||||
if (!parsed.success) return parsed.response
|
||||
|
||||
const { identifier } = parsed.data.params
|
||||
const { email } = parsed.data.body
|
||||
|
||||
const [deployment] = await db
|
||||
.select({
|
||||
authType: chat.authType,
|
||||
allowedEmails: chat.allowedEmails,
|
||||
isActive: chat.isActive,
|
||||
})
|
||||
.from(chat)
|
||||
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
if (!deployment || !deployment.isActive) {
|
||||
logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`)
|
||||
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
|
||||
}
|
||||
|
||||
if (deployment.authType !== 'sso') {
|
||||
return addCorsHeaders(
|
||||
createErrorResponse('Chat is not configured for SSO authentication', 400),
|
||||
request
|
||||
)
|
||||
}
|
||||
|
||||
const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || [])
|
||||
|
||||
return addCorsHeaders(createSuccessResponse({ eligible }), request)
|
||||
}
|
||||
)
|
||||
@@ -19,6 +19,7 @@ const {
|
||||
mockSetDeploymentAuthCookie,
|
||||
mockAddCorsHeaders,
|
||||
mockIsEmailAllowed,
|
||||
mockGetSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
|
||||
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
|
||||
@@ -26,6 +27,12 @@ const {
|
||||
mockSetDeploymentAuthCookie: vi.fn(),
|
||||
mockAddCorsHeaders: vi.fn((response: unknown) => response),
|
||||
mockIsEmailAllowed: vi.fn(),
|
||||
mockGetSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/auth', () => ({
|
||||
auth: { api: { getSession: vi.fn() } },
|
||||
getSession: mockGetSession,
|
||||
}))
|
||||
|
||||
const mockDecryptSecret = encryptionMockFns.mockDecryptSecret
|
||||
@@ -285,6 +292,68 @@ describe('Chat API Utils', () => {
|
||||
expect(result3.authorized).toBe(false)
|
||||
expect(result3.error).toBe('Email not authorized')
|
||||
})
|
||||
|
||||
describe('SSO auth', () => {
|
||||
const ssoDeployment = {
|
||||
id: 'chat-id',
|
||||
authType: 'sso',
|
||||
allowedEmails: ['user@example.com', '@company.com'],
|
||||
}
|
||||
|
||||
const postRequest = {
|
||||
method: 'POST',
|
||||
cookies: { get: vi.fn().mockReturnValue(null) },
|
||||
} as any
|
||||
|
||||
it('rejects when no session is present', async () => {
|
||||
mockGetSession.mockResolvedValue(null)
|
||||
|
||||
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
|
||||
input: 'hello',
|
||||
})
|
||||
|
||||
expect(result.authorized).toBe(false)
|
||||
expect(result.error).toBe('auth_required_sso')
|
||||
})
|
||||
|
||||
it('ignores body-supplied email and uses the session email', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { email: 'session@example.com' } })
|
||||
mockIsEmailAllowed.mockReturnValue(true)
|
||||
|
||||
await validateChatAuth('request-id', ssoDeployment, postRequest, {
|
||||
email: 'attacker@evil.com',
|
||||
input: 'hello',
|
||||
})
|
||||
|
||||
expect(mockIsEmailAllowed).toHaveBeenCalledWith(
|
||||
'session@example.com',
|
||||
ssoDeployment.allowedEmails
|
||||
)
|
||||
})
|
||||
|
||||
it('authorizes execution when session email is allowlisted', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } })
|
||||
mockIsEmailAllowed.mockReturnValue(true)
|
||||
|
||||
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
|
||||
input: 'hello',
|
||||
})
|
||||
|
||||
expect(result.authorized).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects execution when session email is not allowlisted', async () => {
|
||||
mockGetSession.mockResolvedValue({ user: { email: 'stranger@other.com' } })
|
||||
mockIsEmailAllowed.mockReturnValue(false)
|
||||
|
||||
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
|
||||
input: 'hello',
|
||||
})
|
||||
|
||||
expect(result.authorized).toBe(false)
|
||||
expect(result.error).toBe('Your email is not authorized to access this chat')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Execution Result Processing', () => {
|
||||
|
||||
@@ -95,11 +95,13 @@ export async function validateChatAuth(
|
||||
return { authorized: true }
|
||||
}
|
||||
|
||||
const cookieName = `chat_auth_${deployment.id}`
|
||||
const authCookie = request.cookies.get(cookieName)
|
||||
if (authType !== 'sso') {
|
||||
const cookieName = `chat_auth_${deployment.id}`
|
||||
const authCookie = request.cookies.get(cookieName)
|
||||
|
||||
if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
|
||||
return { authorized: true }
|
||||
if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
|
||||
return { authorized: true }
|
||||
}
|
||||
}
|
||||
|
||||
if (authType === 'password') {
|
||||
@@ -173,35 +175,11 @@ export async function validateChatAuth(
|
||||
}
|
||||
|
||||
if (authType === 'sso') {
|
||||
if (request.method === 'GET') {
|
||||
return { authorized: false, error: 'auth_required_sso' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (!parsedBody) {
|
||||
if (request.method !== 'GET' && !parsedBody) {
|
||||
return { authorized: false, error: 'SSO authentication is required' }
|
||||
}
|
||||
|
||||
const { email, input, checkSSOAccess } = parsedBody
|
||||
|
||||
if (input && !checkSSOAccess) {
|
||||
return { authorized: false, error: 'auth_required_sso' }
|
||||
}
|
||||
|
||||
if (checkSSOAccess) {
|
||||
if (!email) {
|
||||
return { authorized: false, error: 'Email is required' }
|
||||
}
|
||||
|
||||
const allowedEmails = deployment.allowedEmails || []
|
||||
|
||||
if (isEmailAllowed(email, allowedEmails)) {
|
||||
return { authorized: true }
|
||||
}
|
||||
|
||||
return { authorized: false, error: 'Email not authorized for SSO access' }
|
||||
}
|
||||
|
||||
const { getSession } = await import('@/lib/auth')
|
||||
const session = await getSession()
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { Input, Label, Loader } from '@/components/emcn'
|
||||
import { ApiClientError } from '@/lib/api/client/errors'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { authenticateDeployedChatContract } from '@/lib/api/contracts/chats'
|
||||
import { chatSSOContract } from '@/lib/api/contracts/chats'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import AuthBackground from '@/app/(auth)/components/auth-background'
|
||||
@@ -69,11 +69,18 @@ export default function SSOAuth({ identifier }: SSOAuthProps) {
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
await requestJson(authenticateDeployedChatContract, {
|
||||
const { eligible } = await requestJson(chatSSOContract, {
|
||||
params: { identifier },
|
||||
body: { email, checkSSOAccess: true },
|
||||
body: { email },
|
||||
})
|
||||
|
||||
if (!eligible) {
|
||||
setEmailErrors(['Email not authorized for this chat'])
|
||||
setShowEmailValidationError(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const callbackUrl = `/chat/${identifier}`
|
||||
const ssoUrl = `/sso?email=${encodeURIComponent(email)}&callbackUrl=${encodeURIComponent(callbackUrl)}`
|
||||
router.push(ssoUrl)
|
||||
|
||||
@@ -104,13 +104,10 @@ export const deployedChatConfigSchema = z.object({
|
||||
})
|
||||
export type DeployedChatConfig = z.output<typeof deployedChatConfigSchema>
|
||||
|
||||
export const deployedChatAuthBodySchema = z
|
||||
.object({
|
||||
password: z.string().optional(),
|
||||
email: z.string().email('Invalid email format').optional().or(z.literal('')),
|
||||
checkSSOAccess: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
export const deployedChatAuthBodySchema = z.object({
|
||||
password: z.string().optional(),
|
||||
email: z.string().email('Invalid email format').optional().or(z.literal('')),
|
||||
})
|
||||
export type DeployedChatAuthBody = z.input<typeof deployedChatAuthBodySchema>
|
||||
|
||||
export const deployedChatFileSchema = z.object({
|
||||
@@ -125,12 +122,20 @@ export const deployedChatPostBodySchema = z.object({
|
||||
input: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
email: z.string().email('Invalid email format').optional().or(z.literal('')),
|
||||
checkSSOAccess: z.boolean().optional(),
|
||||
conversationId: z.string().optional(),
|
||||
files: z.array(deployedChatFileSchema).optional().default([]),
|
||||
})
|
||||
export type DeployedChatPostBody = z.input<typeof deployedChatPostBodySchema>
|
||||
|
||||
export const chatSSOBodySchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
})
|
||||
|
||||
export const chatSSOResponseSchema = z.object({
|
||||
eligible: z.boolean(),
|
||||
})
|
||||
export type ChatSSOResponse = z.output<typeof chatSSOResponseSchema>
|
||||
|
||||
export const chatEmailOtpRequestBodySchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
})
|
||||
@@ -198,6 +203,17 @@ export const deployedChatPostContract = defineRouteContract({
|
||||
},
|
||||
})
|
||||
|
||||
export const chatSSOContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/chat/[identifier]/sso',
|
||||
params: chatIdentifierParamsSchema,
|
||||
body: chatSSOBodySchema,
|
||||
response: {
|
||||
mode: 'json',
|
||||
schema: chatSSOResponseSchema,
|
||||
},
|
||||
})
|
||||
|
||||
export const requestChatEmailOtpContract = defineRouteContract({
|
||||
method: 'POST',
|
||||
path: '/api/chat/[identifier]/otp',
|
||||
|
||||
@@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries')
|
||||
const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors')
|
||||
|
||||
const BASELINE = {
|
||||
totalRoutes: 717,
|
||||
zodRoutes: 717,
|
||||
totalRoutes: 718,
|
||||
zodRoutes: 718,
|
||||
nonZodRoutes: 0,
|
||||
} as const
|
||||
|
||||
|
||||
Reference in New Issue
Block a user