Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions 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
166 changes: 10 additions & 156 deletions apps/sim/app/api/chat/utils.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,21 @@
import { db } from '@sim/db'
import { chat, workflow } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { authorizeWorkflowByWorkspacePermission } from '@sim/workflow-authz'
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