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
75 changes: 75 additions & 0 deletions apps/sim/app/api/files/multipart/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ vi.mock('@/lib/uploads/providers/blob/client', () => ({

vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)

const { mockCheckStorageQuota, mockInitiateS3MultipartUpload } = vi.hoisted(() => ({
mockCheckStorageQuota: vi.fn(),
mockInitiateS3MultipartUpload: vi.fn(),
}))

vi.mock('@/lib/billing/storage', () => ({
checkStorageQuota: mockCheckStorageQuota,
}))

import { POST } from '@/app/api/files/multipart/route'

const tokenPayload = {
Expand Down Expand Up @@ -200,3 +209,69 @@ describe('POST /api/files/multipart action=complete', () => {
expect(mockCompleteS3MultipartUpload).toHaveBeenCalledTimes(2)
})
})

describe('POST /api/files/multipart action=initiate quota enforcement', () => {
const makeInitiateRequest = (body: unknown) =>
new NextRequest('http://localhost/api/files/multipart?action=initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})

beforeEach(() => {
vi.clearAllMocks()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write')
mockIsUsingCloudStorage.mockReturnValue(true)
mockGetStorageProvider.mockReturnValue('s3')
mockGetStorageConfig.mockReturnValue({ bucket: 'b', region: 'r' })
mockSignUploadToken.mockReturnValue('signed-token')
mockCheckStorageQuota.mockResolvedValue({ allowed: true })
mockInitiateS3MultipartUpload.mockResolvedValue({ uploadId: 'up-1', key: 'k/file.bin' })
})

it('blocks upload when fileSize: 0 exceeds quota', async () => {
mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' })

const res = await makeInitiateRequest({
fileName: 'file.bin',
contentType: 'application/octet-stream',
fileSize: 0,
workspaceId: 'ws-1',
context: 'knowledge-base',
})

const response = await POST(res)
expect(response.status).toBe(413)
const body = await response.json()
expect(body.error).toContain('Storage limit exceeded')
})

it('does not check quota for quota-exempt contexts (og-images)', async () => {
const res = await makeInitiateRequest({
fileName: 'img.png',
contentType: 'image/png',
fileSize: 99999,
workspaceId: 'ws-1',
context: 'og-images',
})

const response = await POST(res)
expect(mockCheckStorageQuota).not.toHaveBeenCalled()
})

it('rejects logs context — not allowed via the multipart endpoint', async () => {
const res = await makeInitiateRequest({
fileName: 'exec.log',
contentType: 'text/plain',
fileSize: 1000,
workspaceId: 'ws-1',
context: 'logs',
})

const response = await POST(res)
expect(response.status).toBe(400)
const body = await response.json()
expect(body.error).toMatch(/invalid storage context/i)
})
})
26 changes: 15 additions & 11 deletions apps/sim/app/api/files/multipart/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
type UploadTokenPayload,
verifyUploadToken,
} from '@/lib/uploads/core/upload-token'
import type { StorageConfig } from '@/lib/uploads/shared/types'
import { QUOTA_EXEMPT_STORAGE_CONTEXTS, type StorageConfig } from '@/lib/uploads/shared/types'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('MultipartUploadAPI')
Expand All @@ -36,7 +36,6 @@ const ALLOWED_UPLOAD_CONTEXTS = new Set<StorageContext>([
'workspace',
'profile-pictures',
'og-images',
'logs',
'workspace-logos',
])

Expand Down Expand Up @@ -135,6 +134,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

const config = getStorageConfig(storageContext)

if (
!QUOTA_EXEMPT_STORAGE_CONTEXTS.has(context as StorageContext) &&
typeof fileSize === 'number'
) {
const { checkStorageQuota } = await import('@/lib/billing/storage')
const quotaCheck = await checkStorageQuota(userId, fileSize)
if (!quotaCheck.allowed) {
return NextResponse.json(
{ error: quotaCheck.error || 'Storage limit exceeded' },
{ status: 413 }
)
}
}

let customKey: string | undefined
if (context === 'workspace' || context === 'mothership') {
const { MAX_WORKSPACE_FILE_SIZE } = await import('@/lib/uploads/shared/types')
Expand All @@ -149,15 +162,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
'@/lib/uploads/contexts/workspace/workspace-file-manager'
)
customKey = generateWorkspaceFileKey(workspaceId, fileName)

const { checkStorageQuota } = await import('@/lib/billing/storage')
const quotaCheck = await checkStorageQuota(userId, fileSize)
if (!quotaCheck.allowed) {
return NextResponse.json(
{ error: quotaCheck.error || 'Storage limit exceeded' },
{ status: 413 }
)
}
} else if (context === 'execution') {
const workflowId = (data as { workflowId?: unknown }).workflowId
const executionId = (data as { executionId?: unknown }).executionId
Expand Down
45 changes: 7 additions & 38 deletions apps/sim/app/api/tools/sharepoint/site/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { sharepointSiteQuerySchema } from '@/lib/api/contracts/selectors/sharepoint'
import { getValidationErrorMessage } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
import { validateMicrosoftGraphId } from '@/lib/core/security/input-validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'

export const dynamic = 'force-dynamic'

Expand All @@ -19,11 +16,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const requestId = generateId().slice(0, 8)

try {
const session = await getSession()
if (!session?.user?.id) {
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
}

const { searchParams } = new URL(request.url)
const validation = sharepointSiteQuerySchema.safeParse({
credentialId: searchParams.get('credentialId') ?? '',
Expand All @@ -42,37 +34,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: siteIdValidation.error }, { status: 400 })
}

const resolved = await resolveOAuthAccountId(credentialId)
if (!resolved) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
const authz = await authorizeCredentialUse(request, { credentialId })
if (!authz.ok || !authz.credentialOwnerUserId || !authz.resolvedCredentialId) {
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
}

if (resolved.workspaceId) {
const { getUserEntityPermissions } = await import('@/lib/workspaces/permissions/utils')
const perm = await getUserEntityPermissions(
session.user.id,
'workspace',
resolved.workspaceId
)
if (perm === null) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}

const credentials = await db
.select()
.from(account)
.where(eq(account.id, resolved.accountId))
.limit(1)
if (!credentials.length) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}

const accountRow = credentials[0]

const accessToken = await refreshAccessTokenIfNeeded(
resolved.accountId,
accountRow.userId,
authz.resolvedCredentialId,
authz.credentialOwnerUserId,
requestId
)
if (!accessToken) {
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/app/api/tools/ssh/read-file-content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {

const content = await new Promise<string>((resolve, reject) => {
const chunks: Buffer[] = []
let totalBytes = 0
const readStream = sftp.createReadStream(filePath)

readStream.on('data', (chunk: Buffer) => {
totalBytes += chunk.length
if (totalBytes > maxBytes) {
readStream.destroy()
reject(new Error(`File exceeds maximum allowed size of ${params.maxSize}MB`))
return
}
chunks.push(chunk)
})

Expand Down
108 changes: 108 additions & 0 deletions apps/sim/app/api/workflows/[id]/log/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* @vitest-environment node
*/
import { authMockFns, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'

// Override global db mock with the configurable chain mock
vi.mock('@sim/db', () => dbChainMock)

const { mockValidateWorkflowAccess, mockGetWorkspaceBilledAccountUserId } = vi.hoisted(() => ({
mockValidateWorkflowAccess: vi.fn(),
mockGetWorkspaceBilledAccountUserId: vi.fn(),
}))

vi.mock('@/app/api/workflows/middleware', () => ({
validateWorkflowAccess: mockValidateWorkflowAccess,
}))

vi.mock('@/lib/workspaces/utils', () => ({
getWorkspaceBilledAccountUserId: mockGetWorkspaceBilledAccountUserId,
}))

vi.mock('@/lib/logs/execution/logging-session', () => ({
LoggingSession: vi.fn().mockImplementation(() => ({
start: vi.fn().mockResolvedValue(undefined),
markAsFailed: vi.fn().mockResolvedValue(undefined),
safeCompleteWithError: vi.fn().mockResolvedValue(undefined),
safeComplete: vi.fn().mockResolvedValue(undefined),
})),
}))

vi.mock('@/lib/logs/execution/trace-spans/trace-spans', () => ({
buildTraceSpans: vi.fn().mockReturnValue([]),
}))

import { POST } from './route'

const makeRequest = (workflowId: string, body: unknown) =>
new NextRequest(`http://localhost/api/workflows/${workflowId}/log`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})

const validResult = { success: true, output: { value: 42 } }

describe('POST /api/workflows/[id]/log cross-tenant guard', () => {
const OWNER_WORKFLOW_ID = 'wf-owner'
const ATTACKER_WORKFLOW_ID = 'wf-attacker'
const VICTIM_EXECUTION_ID = 'exec-victim-uuid'

beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockValidateWorkflowAccess.mockResolvedValue({ error: null })
mockGetWorkspaceBilledAccountUserId.mockResolvedValue('user-1')
// Default: no existing log (fresh execution)
dbChainMockFns.limit.mockResolvedValue([])
})

it('returns 404 when executionId belongs to a different workflow', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }])

const res = await POST(
makeRequest(ATTACKER_WORKFLOW_ID, {
executionId: VICTIM_EXECUTION_ID,
result: validResult,
}),
{ params: Promise.resolve({ id: ATTACKER_WORKFLOW_ID }) }
)

expect(res.status).toBe(404)
const body = await res.json()
expect(body.error).toBe('Execution not found')
})

it('proceeds when executionId belongs to the same workflow', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([{ workflowId: OWNER_WORKFLOW_ID }])

const res = await POST(
makeRequest(OWNER_WORKFLOW_ID, {
executionId: VICTIM_EXECUTION_ID,
result: validResult,
}),
{ params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
)

expect(res.status).not.toBe(404)
expect(res.status).not.toBe(403)
})

it('proceeds when executionId has no existing log row (fresh execution)', async () => {
dbChainMockFns.limit.mockResolvedValueOnce([])

const res = await POST(
makeRequest(OWNER_WORKFLOW_ID, {
executionId: 'brand-new-execution-id',
result: validResult,
}),
{ params: Promise.resolve({ id: OWNER_WORKFLOW_ID }) }
)

expect(res.status).not.toBe(404)
expect(res.status).not.toBe(403)
})
})
16 changes: 16 additions & 0 deletions apps/sim/app/api/workflows/[id]/log/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { db } from '@sim/db'
import { workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { workflowLogContract } from '@/lib/api/contracts/workflows'
import { parseRequest } from '@/lib/api/server'
Expand Down Expand Up @@ -40,6 +43,19 @@ export const POST = withRouteHandler(
return createErrorResponse('executionId is required when logging results', 400)
}

const [existingLog] = await db
.select({ workflowId: workflowExecutionLogs.workflowId })
.from(workflowExecutionLogs)
.where(eq(workflowExecutionLogs.executionId, executionId))
.limit(1)

if (existingLog && existingLog.workflowId !== id) {
logger.warn(
`[${requestId}] executionId ${executionId} belongs to workflow ${existingLog.workflowId}, not ${id}`
)
return createErrorResponse('Execution not found', 404)
}

logger.info(`[${requestId}] Persisting execution result for workflow: ${id}`, {
executionId,
success: result.success,
Expand Down
Loading
Loading