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:
Waleed
2026-05-02 11:16:25 -07:00
committed by GitHub
parent 1a76a22a17
commit 66bab935db
7 changed files with 197 additions and 43 deletions
+4 -1
View File
@@ -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)
}
)
+69
View File
@@ -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', () => {
+7 -29
View File
@@ -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()
+10 -3
View File
@@ -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)
+24 -8
View File
@@ -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',
+2 -2
View File
@@ -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