Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/sim/app/api/chat/[identifier]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ export const GET = withRouteHandler(
deployment.authType !== 'public' &&
deployment.authType !== 'sso' &&
authCookie &&
validateAuthToken(authCookie.value, deployment.id, deployment.password)
validateAuthToken(authCookie.value, deployment.id, deployment.authType, deployment.password)
) {
return createSuccessResponse(toChatConfigResponse(deployment))
}
Expand Down
4 changes: 3 additions & 1 deletion apps/sim/app/api/chat/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ vi.mock('@/lib/core/security/deployment', () => ({
validateAuthToken: mockValidateAuthToken,
setDeploymentAuthCookie: mockSetDeploymentAuthCookie,
isEmailAllowed: mockIsEmailAllowed,
deploymentAuthCookieName: (prefix: string, id: string) => `${prefix}_auth_${id}`,
}))

vi.mock('@/lib/core/config/env-flags', () => ({
Expand Down Expand Up @@ -134,6 +135,7 @@ describe('Chat API Utils', () => {
expect(mockValidateAuthToken).toHaveBeenCalledWith(
'valid-token',
'chat-id',
'password',
'encrypted-password'
)
expect(result.authorized).toBe(true)
Expand Down Expand Up @@ -407,7 +409,7 @@ describe('Chat API Utils', () => {
})

expect(result.authorized).toBe(false)
expect(result.error).toBe('Your email is not authorized to access this chat')
expect(result.error).toBe('Your email is not authorized to access this resource')
})
})
})
Expand Down
166 changes: 10 additions & 156 deletions apps/sim/app/api/chat/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,20 @@ import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { safeCompare } from '@sim/security/compare'
import { and, eq, isNull } from 'drizzle-orm'
import type { NextRequest, NextResponse } from 'next/server'
import { isWorkspaceApiExecutionEntitled } from '@/lib/billing/core/api-access'
import { getEnv } from '@/lib/core/config/env'
import { isBillingEnabled, isFreeApiDeploymentGateEnabled } from '@/lib/core/config/env-flags'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { setDeploymentAuthCookie } from '@/lib/core/security/deployment'
import {
isEmailAllowed,
setDeploymentAuthCookie,
validateAuthToken,
} from '@/lib/core/security/deployment'
import { decryptSecret } from '@/lib/core/security/encryption'
import { getClientIp } from '@/lib/core/utils/request'
type DeploymentAuthResult,
validateDeploymentAuth,
} from '@/lib/core/security/deployment-auth'
import { createErrorResponse } from '@/app/api/workflows/utils'

const logger = createLogger('ChatAuthUtils')

const rateLimiter = new RateLimiter()

/**
* Throttles unauthenticated password guesses per client IP against a single
* deployment, mirroring the OTP/SSO IP limits.
*/
const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = {
maxTokens: 10,
refillRate: 10,
refillIntervalMs: 15 * 60_000,
}

export function setChatAuthCookie(
response: NextResponse,
chatId: string,
Expand Down Expand Up @@ -157,144 +140,15 @@ export async function checkChatAccess(
: { hasAccess: false }
}

/**
* Validates auth for a deployed chat. Thin wrapper over the shared
* {@link validateDeploymentAuth} with the `'chat'` cookie/rate-limit namespace.
*/
export async function validateChatAuth(
requestId: string,
deployment: any,
request: NextRequest,
parsedBody?: any
): Promise<{ authorized: boolean; error?: string; status?: number; retryAfterMs?: number }> {
const authType = deployment.authType || 'public'

if (authType === 'public') {
return { authorized: true }
}

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 (authType === 'password') {
if (request.method === 'GET') {
return { authorized: false, error: 'auth_required_password' }
}

try {
if (!parsedBody) {
return { authorized: false, error: 'Password is required' }
}

const { password, input } = parsedBody

if (input && !password) {
return { authorized: false, error: 'auth_required_password' }
}

if (!password) {
return { authorized: false, error: 'Password is required' }
}

if (!deployment.password) {
logger.error(`[${requestId}] No password set for password-protected chat: ${deployment.id}`)
return { authorized: false, error: 'Authentication configuration error' }
}

const ip = getClientIp(request)
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
`chat-password:ip:${deployment.id}:${ip}`,
PASSWORD_IP_RATE_LIMIT
)
if (!ipRateLimit.allowed) {
logger.warn(
`[${requestId}] Password attempt IP rate limit exceeded for chat ${deployment.id} from ${ip}`
)
return {
authorized: false,
error: 'Too many attempts. Please try again later.',
status: 429,
retryAfterMs: ipRateLimit.retryAfterMs ?? PASSWORD_IP_RATE_LIMIT.refillIntervalMs,
}
}

const { decrypted } = await decryptSecret(deployment.password)
if (!safeCompare(password, decrypted)) {
return { authorized: false, error: 'Invalid password' }
}

return { authorized: true }
} catch (error) {
logger.error(`[${requestId}] Error validating password:`, error)
return { authorized: false, error: 'Authentication error' }
}
}

if (authType === 'email') {
if (request.method === 'GET') {
return { authorized: false, error: 'auth_required_email' }
}

try {
if (!parsedBody) {
return { authorized: false, error: 'Email is required' }
}

const { email, input } = parsedBody

if (input && !email) {
return { authorized: false, error: 'auth_required_email' }
}

if (!email) {
return { authorized: false, error: 'Email is required' }
}

const allowedEmails = deployment.allowedEmails || []

if (isEmailAllowed(email, allowedEmails)) {
return { authorized: false, error: 'otp_required' }
}

return { authorized: false, error: 'Email not authorized' }
} catch (error) {
logger.error(`[${requestId}] Error validating email:`, error)
return { authorized: false, error: 'Authentication error' }
}
}

if (authType === 'sso') {
try {
if (request.method !== 'GET' && !parsedBody) {
return { authorized: false, error: 'SSO authentication is required' }
}

const { getSession } = await import('@/lib/auth')
const session = await getSession()

if (!session || !session.user) {
return { authorized: false, error: 'auth_required_sso' }
}

const userEmail = session.user.email
if (!userEmail) {
return { authorized: false, error: 'SSO session does not contain email' }
}

const allowedEmails = deployment.allowedEmails || []

if (isEmailAllowed(userEmail, allowedEmails)) {
return { authorized: true }
}

return { authorized: false, error: 'Your email is not authorized to access this chat' }
} catch (error) {
logger.error(`[${requestId}] Error validating SSO:`, error)
return { authorized: false, error: 'SSO authentication error' }
}
}

return { authorized: false, error: 'Unsupported authentication type' }
): Promise<DeploymentAuthResult> {
return validateDeploymentAuth(requestId, deployment, request, parsedBody, 'chat')
}
90 changes: 90 additions & 0 deletions apps/sim/app/api/files/public/[token]/content/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* @vitest-environment node
*/
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
mockResolveActiveShareByToken,
mockEnforceRateLimit,
mockValidateDeploymentAuth,
mockDownloadFile,
mockResolveServableDoc,
} = vi.hoisted(() => ({
mockResolveActiveShareByToken: vi.fn(),
mockEnforceRateLimit: vi.fn(),
mockValidateDeploymentAuth: vi.fn(),
mockDownloadFile: vi.fn(),
mockResolveServableDoc: vi.fn(),
}))

vi.mock('@/lib/public-shares/share-manager', () => ({
resolveActiveShareByToken: mockResolveActiveShareByToken,
}))

vi.mock('@/lib/public-shares/rate-limit', () => ({
enforcePublicFileRateLimit: mockEnforceRateLimit,
}))

vi.mock('@/lib/core/security/deployment-auth', () => ({
validateDeploymentAuth: mockValidateDeploymentAuth,
}))

vi.mock('@/lib/uploads/core/storage-service', () => ({
downloadFile: mockDownloadFile,
}))

vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({
resolveServableDoc: mockResolveServableDoc,
}))

import { GET } from '@/app/api/files/public/[token]/content/route'

const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) })
const request = (token = 'tok_1') =>
new NextRequest(`http://localhost/api/files/public/${token}/content`)

const passwordShare = {
share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' },
file: {
id: 'wf_1',
key: 'workspace/ws/secret-key.pdf',
workspaceId: 'ws-1',
originalName: 'report.pdf',
contentType: 'application/pdf',
size: 4,
},
workspaceName: 'Acme',
ownerName: 'Jane',
}

describe('GET /api/files/public/[token]/content', () => {
beforeEach(() => {
vi.clearAllMocks()
mockEnforceRateLimit.mockResolvedValue(null)
mockResolveActiveShareByToken.mockResolvedValue(passwordShare)
mockDownloadFile.mockResolvedValue(Buffer.from('data'))
mockResolveServableDoc.mockResolvedValue({ kind: 'passthrough' })
})

it('returns 401 and never reads storage when a password share is unauthorized', async () => {
mockValidateDeploymentAuth.mockResolvedValueOnce({
authorized: false,
error: 'auth_required_password',
})
const res = await GET(request(), params())
expect(res.status).toBe(401)
expect((await res.json()).error).toBe('auth_required_password')
expect(mockDownloadFile).not.toHaveBeenCalled()
})

it('serves the bytes once authorized', async () => {
mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true })
const res = await GET(request(), params())
expect(res.status).toBe(200)
expect(mockDownloadFile).toHaveBeenCalledWith({
key: passwordShare.file.key,
context: 'workspace',
})
})
})
15 changes: 15 additions & 0 deletions apps/sim/app/api/files/public/[token]/content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { NextResponse } from 'next/server'
import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares'
import { parseRequest } from '@/lib/api/server'
import { resolveServableDoc } from '@/lib/copilot/tools/server/files/doc-compile'
import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager'
Expand All @@ -28,6 +30,8 @@ const logger = createLogger('PublicFileContentAPI')
*/
export const GET = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ token: string }> }) => {
const requestId = generateRequestId()

try {
const limited = await enforcePublicFileRateLimit(request, 'content')
if (limited) return limited
Expand All @@ -41,6 +45,17 @@ export const GET = withRouteHandler(
throw new FileNotFoundError('Not found')
}

const auth = await validateDeploymentAuth(
requestId,
resolved.share,
request,
undefined,
'file'
)
if (!auth.authorized) {
return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 })
}

const { file } = resolved
const raw = await downloadFile({ key: file.key, context: 'workspace' })

Expand Down
Loading
Loading