fix(security): patch copilot tool & multipart upload IDORs (#4304)

* fix(security): patch copilot tool & multipart upload IDORs

- multipart upload: bind upload session to (userId, workspaceId, key)
  via short-lived HMAC-signed token; require workspace write access at
  initiate; source key/uploadId/context from verified token (never
  client) at get-part-urls/complete/abort
- copilot knowledge-base tools: gate all 11 read/write/tag/connector
  ops with checkKnowledgeBaseAccess / checkKnowledgeBaseWriteAccess
- copilot user-table tools: add workspace-id check to get, get_schema,
  add/rename/delete/update_column to match existing op pattern
- copilot manage-credential: add full ownership/write-permission auth
  via getCredentialActorContext (previously had no auth)
- copilot restore-resource: verify workspace ownership and write
  permission for workflow, table, knowledgebase, file, and folder
  restores
- copilot folder rename/move: verify both folderId and parentId belong
  to the caller's workspace via new verifyFolderWorkspace helper
- copilot get-job-logs: verify schedule belongs to caller's workspace

* fix(security): address PR review — document IDOR, log count, token split

- knowledge-base delete_document/update_document: verify each document
  belongs to the claimed knowledgeBaseId via checkDocumentWriteAccess
  (was: trusted args.knowledgeBaseId without binding it to the document)
- multipart batch complete: log verifiedEntries.length instead of raw
  client-supplied data.uploads.length
- upload-token: reject tokens with !=2 dot-delimited segments

* fix(security): close folder workspace bypass when workspaceId is falsy
This commit is contained in:
Waleed
2026-04-27 11:05:22 -07:00
committed by GitHub
parent 50e74f75ef
commit c32c1cb917
10 changed files with 517 additions and 107 deletions
+130 -72
View File
@@ -8,21 +8,61 @@ import {
isUsingCloudStorage,
type StorageContext,
} from '@/lib/uploads'
import {
signUploadToken,
type UploadTokenPayload,
verifyUploadToken,
} from '@/lib/uploads/core/upload-token'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('MultipartUploadAPI')
const ALLOWED_UPLOAD_CONTEXTS = new Set<StorageContext>([
'knowledge-base',
'chat',
'copilot',
'mothership',
'execution',
'workspace',
'profile-pictures',
'og-images',
'logs',
'workspace-logos',
])
interface InitiateMultipartRequest {
fileName: string
contentType: string
fileSize: number
workspaceId: string
context?: StorageContext
}
interface GetPartUrlsRequest {
uploadId: string
key: string
interface TokenBoundRequest {
uploadToken: string
}
interface GetPartUrlsRequest extends TokenBoundRequest {
partNumbers: number[]
context?: StorageContext
}
interface CompleteSingleRequest extends TokenBoundRequest {
parts: unknown
}
interface CompleteBatchRequest {
uploads: Array<TokenBoundRequest & { parts: unknown }>
}
const verifyTokenForUser = (token: string | undefined, userId: string) => {
if (!token || typeof token !== 'string') {
return null
}
const result = verifyUploadToken(token)
if (!result.valid || result.payload.userId !== userId) {
return null
}
return result.payload
}
export const POST = withRouteHandler(async (request: NextRequest) => {
@@ -31,6 +71,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const userId = session.user.id
const action = request.nextUrl.searchParams.get('action')
@@ -45,32 +86,34 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
switch (action) {
case 'initiate': {
const data: InitiateMultipartRequest = await request.json()
const { fileName, contentType, fileSize, context = 'knowledge-base' } = data
const data = (await request.json()) as InitiateMultipartRequest
const { fileName, contentType, fileSize, workspaceId, context = 'knowledge-base' } = data
if (!workspaceId || typeof workspaceId !== 'string') {
return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 })
}
if (!ALLOWED_UPLOAD_CONTEXTS.has(context)) {
return NextResponse.json({ error: 'Invalid storage context' }, { status: 400 })
}
const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId)
if (permission !== 'write' && permission !== 'admin') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
const config = getStorageConfig(context)
let uploadId: string
let key: string
if (storageProvider === 's3') {
const { initiateS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client')
const result = await initiateS3MultipartUpload({
fileName,
contentType,
fileSize,
})
logger.info(
`Initiated S3 multipart upload for ${fileName} (context: ${context}): ${result.uploadId}`
)
return NextResponse.json({
uploadId: result.uploadId,
key: result.key,
})
}
if (storageProvider === 'blob') {
const result = await initiateS3MultipartUpload({ fileName, contentType, fileSize })
uploadId = result.uploadId
key = result.key
} else if (storageProvider === 'blob') {
const { initiateMultipartUpload } = await import('@/lib/uploads/providers/blob/client')
const result = await initiateMultipartUpload({
fileName,
contentType,
@@ -82,46 +125,55 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
connectionString: config.connectionString,
},
})
logger.info(
`Initiated Azure multipart upload for ${fileName} (context: ${context}): ${result.uploadId}`
uploadId = result.uploadId
key = result.key
} else {
return NextResponse.json(
{ error: `Unsupported storage provider: ${storageProvider}` },
{ status: 400 }
)
return NextResponse.json({
uploadId: result.uploadId,
key: result.key,
})
}
return NextResponse.json(
{ error: `Unsupported storage provider: ${storageProvider}` },
{ status: 400 }
const uploadToken = signUploadToken({
uploadId,
key,
userId,
workspaceId,
context,
})
logger.info(
`Initiated ${storageProvider} multipart upload for ${fileName} (context: ${context}, workspace: ${workspaceId}): ${uploadId}`
)
return NextResponse.json({ uploadId, key, uploadToken })
}
case 'get-part-urls': {
const data: GetPartUrlsRequest = await request.json()
const { uploadId, key, partNumbers, context = 'knowledge-base' } = data
const data = (await request.json()) as GetPartUrlsRequest
const { partNumbers } = data
const tokenPayload = verifyTokenForUser(data.uploadToken, userId)
if (!tokenPayload) {
return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
}
const { uploadId, key, context } = tokenPayload
const config = getStorageConfig(context)
if (storageProvider === 's3') {
const { getS3MultipartPartUrls } = await import('@/lib/uploads/providers/s3/client')
const presignedUrls = await getS3MultipartPartUrls(key, uploadId, partNumbers)
return NextResponse.json({ presignedUrls })
}
if (storageProvider === 'blob') {
const { getMultipartPartUrls } = await import('@/lib/uploads/providers/blob/client')
const presignedUrls = await getMultipartPartUrls(key, partNumbers, {
containerName: config.containerName!,
accountName: config.accountName!,
accountKey: config.accountKey,
connectionString: config.connectionString,
})
return NextResponse.json({ presignedUrls })
}
@@ -132,24 +184,32 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
case 'complete': {
const data = await request.json()
const context: StorageContext = data.context || 'knowledge-base'
const data = (await request.json()) as CompleteSingleRequest | CompleteBatchRequest
const config = getStorageConfig(context)
if ('uploads' in data && Array.isArray(data.uploads)) {
const verified = data.uploads.map((upload) => {
const payload = verifyTokenForUser(upload.uploadToken, userId)
return payload ? { payload, parts: upload.parts } : null
})
if (verified.some((entry) => entry === null)) {
return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
}
const verifiedEntries = verified.filter(
(entry): entry is { payload: UploadTokenPayload; parts: unknown } => entry !== null
)
if ('uploads' in data) {
const results = await Promise.all(
data.uploads.map(async (upload: any) => {
const { uploadId, key } = upload
verifiedEntries.map(async ({ payload, parts }) => {
const { uploadId, key, context } = payload
const config = getStorageConfig(context)
if (storageProvider === 's3') {
const { completeS3MultipartUpload } = await import(
'@/lib/uploads/providers/s3/client'
)
const parts = upload.parts // S3 format: { ETag, PartNumber }
const result = await completeS3MultipartUpload(key, uploadId, parts)
const result = await completeS3MultipartUpload(key, uploadId, parts as any)
return {
success: true,
location: result.location,
@@ -161,15 +221,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const { completeMultipartUpload } = await import(
'@/lib/uploads/providers/blob/client'
)
const parts = upload.parts // Azure format: { blockId, partNumber }
const result = await completeMultipartUpload(key, parts, {
const result = await completeMultipartUpload(key, parts as any, {
containerName: config.containerName!,
accountName: config.accountName!,
accountKey: config.accountKey,
connectionString: config.connectionString,
})
return {
success: true,
location: result.location,
@@ -182,19 +239,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
)
logger.info(`Completed ${data.uploads.length} multipart uploads (context: ${context})`)
logger.info(`Completed ${verifiedEntries.length} multipart uploads`)
return NextResponse.json({ results })
}
const { uploadId, key, parts } = data
const single = data as CompleteSingleRequest
const tokenPayload = verifyTokenForUser(single.uploadToken, userId)
if (!tokenPayload) {
return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
}
const { uploadId, key, context } = tokenPayload
const config = getStorageConfig(context)
if (storageProvider === 's3') {
const { completeS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client')
const result = await completeS3MultipartUpload(key, uploadId, parts)
const result = await completeS3MultipartUpload(key, uploadId, single.parts as any)
logger.info(`Completed S3 multipart upload for key ${key} (context: ${context})`)
return NextResponse.json({
success: true,
location: result.location,
@@ -204,16 +265,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
if (storageProvider === 'blob') {
const { completeMultipartUpload } = await import('@/lib/uploads/providers/blob/client')
const result = await completeMultipartUpload(key, parts, {
const result = await completeMultipartUpload(key, single.parts as any, {
containerName: config.containerName!,
accountName: config.accountName!,
accountKey: config.accountKey,
connectionString: config.connectionString,
})
logger.info(`Completed Azure multipart upload for key ${key} (context: ${context})`)
return NextResponse.json({
success: true,
location: result.location,
@@ -229,27 +287,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
case 'abort': {
const data = await request.json()
const { uploadId, key, context = 'knowledge-base' } = data
const data = (await request.json()) as TokenBoundRequest
const tokenPayload = verifyTokenForUser(data.uploadToken, userId)
if (!tokenPayload) {
return NextResponse.json({ error: 'Invalid or expired upload token' }, { status: 403 })
}
const config = getStorageConfig(context as StorageContext)
const { uploadId, key, context } = tokenPayload
const config = getStorageConfig(context)
if (storageProvider === 's3') {
const { abortS3MultipartUpload } = await import('@/lib/uploads/providers/s3/client')
await abortS3MultipartUpload(key, uploadId)
logger.info(`Aborted S3 multipart upload for key ${key} (context: ${context})`)
} else if (storageProvider === 'blob') {
const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client')
await abortMultipartUpload(key, {
containerName: config.containerName!,
accountName: config.accountName!,
accountKey: config.accountKey,
connectionString: config.connectionString,
})
logger.info(`Aborted Azure multipart upload for key ${key} (context: ${context})`)
} else {
return NextResponse.json(
@@ -604,6 +604,10 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
const startTime = getHighResTime()
try {
if (!options.workspaceId) {
throw new Error('workspaceId is required for multipart upload')
}
const initiateResponse = await fetch('/api/files/multipart?action=initiate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -611,6 +615,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
fileName: file.name,
contentType: getFileContentType(file),
fileSize: file.size,
workspaceId: options.workspaceId,
}),
})
@@ -618,7 +623,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
throw new Error(`Failed to initiate multipart upload: ${initiateResponse.statusText}`)
}
const { uploadId, key } = await initiateResponse.json()
const { uploadId, key, uploadToken } = await initiateResponse.json()
logger.info(`Initiated multipart upload with ID: ${uploadId}`)
const chunkSize = UPLOAD_CONFIG.CHUNK_SIZE
@@ -629,8 +634,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uploadId,
key,
uploadToken,
partNumbers,
}),
})
@@ -639,7 +643,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
await fetch('/api/files/multipart?action=abort', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uploadId, key }),
body: JSON.stringify({ uploadToken }),
})
throw new Error(`Failed to get part URLs: ${partUrlsResponse.statusText}`)
}
@@ -723,8 +727,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
uploadId,
key,
uploadToken,
parts: uploadedParts,
}),
})
@@ -3,10 +3,11 @@ import { credential } from '@sim/db/schema'
import { toError } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { getCredentialActorContext } from '@/lib/credentials/access'
export function executeManageCredential(
rawParams: Record<string, unknown>,
_context: ExecutionContext
context: ExecutionContext
): Promise<ToolCallResult> {
const params = rawParams as {
operation: string
@@ -17,26 +18,30 @@ export function executeManageCredential(
const { operation, displayName } = params
return (async () => {
try {
if (!context?.userId) {
return { success: false, error: 'Authentication required' }
}
switch (operation) {
case 'rename': {
const credentialId = params.credentialId
if (!credentialId) return { success: false, error: 'credentialId is required for rename' }
if (!displayName) return { success: false, error: 'displayName is required for rename' }
const [row] = await db
.select({
id: credential.id,
type: credential.type,
displayName: credential.displayName,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)
if (!row) return { success: false, error: 'Credential not found' }
if (row.type !== 'oauth')
const actor = await getCredentialActorContext(credentialId, context.userId)
if (!actor.credential || !actor.hasWorkspaceAccess) {
return { success: false, error: 'Credential not found' }
}
if (actor.credential.type !== 'oauth') {
return {
success: false,
error: 'Only OAuth credentials can be managed with this tool.',
}
}
if (!actor.canWriteWorkspace && !actor.isAdmin) {
return { success: false, error: 'Write access required to rename this credential' }
}
await db
.update(credential)
.set({ displayName, updatedAt: new Date() })
@@ -53,12 +58,16 @@ export function executeManageCredential(
const failed: string[] = []
for (const id of ids) {
const [row] = await db
.select({ id: credential.id, type: credential.type })
.from(credential)
.where(eq(credential.id, id))
.limit(1)
if (!row || row.type !== 'oauth') {
const actor = await getCredentialActorContext(id, context.userId)
if (
!actor.credential ||
!actor.hasWorkspaceAccess ||
actor.credential.type !== 'oauth'
) {
failed.push(id)
continue
}
if (!actor.canWriteWorkspace && !actor.isAdmin) {
failed.push(id)
continue
}
@@ -1,6 +1,9 @@
import { db } from '@sim/db'
import { knowledgeBase } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { eq } from 'drizzle-orm'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { restoreKnowledgeBase } from '@/lib/knowledge/service'
import { getTableById, restoreTable } from '@/lib/table/service'
@@ -10,6 +13,8 @@ import {
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { restoreWorkflow } from '@/lib/workflows/lifecycle'
import { performRestoreFolder } from '@/lib/workflows/orchestration/folder-lifecycle'
import { getWorkflowById } from '@/lib/workflows/utils'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
const logger = createLogger('RestoreResource')
@@ -33,10 +38,25 @@ export async function executeRestoreResource(
}
const requestId = generateId().slice(0, 8)
const callerWorkspaceId = context.workspaceId
const hasWriteAccess = async (resourceWorkspaceId: string | null | undefined) => {
if (!resourceWorkspaceId || resourceWorkspaceId !== callerWorkspaceId) return false
const permission = await getUserEntityPermissions(
context.userId,
'workspace',
resourceWorkspaceId
)
return permission === 'write' || permission === 'admin'
}
try {
switch (type) {
case 'workflow': {
const existing = await getWorkflowById(id, { includeArchived: true })
if (!existing || !(await hasWriteAccess(existing.workspaceId))) {
return { success: false, error: 'Workflow not found' }
}
const result = await restoreWorkflow(id, { requestId })
if (!result.restored) {
return { success: false, error: 'Workflow not found or not archived' }
@@ -50,9 +70,13 @@ export async function executeRestoreResource(
}
case 'table': {
const existing = await getTableById(id, { includeArchived: true })
if (!existing || !(await hasWriteAccess(existing.workspaceId))) {
return { success: false, error: 'Table not found' }
}
await restoreTable(id, requestId)
const table = await getTableById(id)
const tableName = table?.name || id
const tableName = table?.name || existing.name
logger.info('Table restored via copilot', { tableId: id, name: tableName })
return {
success: true,
@@ -62,6 +86,9 @@ export async function executeRestoreResource(
}
case 'file': {
if (!(await hasWriteAccess(context.workspaceId))) {
return { success: false, error: 'File not found' }
}
await restoreWorkspaceFile(context.workspaceId, id)
const fileRecord = await getWorkspaceFile(context.workspaceId, id)
const fileName = fileRecord?.name || id
@@ -74,6 +101,14 @@ export async function executeRestoreResource(
}
case 'knowledgebase': {
const [existing] = await db
.select({ workspaceId: knowledgeBase.workspaceId })
.from(knowledgeBase)
.where(eq(knowledgeBase.id, id))
.limit(1)
if (!existing || !(await hasWriteAccess(existing.workspaceId))) {
return { success: false, error: 'Knowledge base not found' }
}
await restoreKnowledgeBase(id, requestId)
logger.info('Knowledge base restored via copilot', { knowledgeBaseId: id })
return {
@@ -83,6 +118,9 @@ export async function executeRestoreResource(
}
case 'folder': {
if (!(await hasWriteAccess(context.workspaceId))) {
return { success: false, error: 'Folder not found' }
}
const result = await performRestoreFolder({
folderId: id,
workspaceId: context.workspaceId,
@@ -28,6 +28,7 @@ import {
setWorkflowVariables,
updateFolderRecord,
updateWorkflowRecord,
verifyFolderWorkspace,
} from '@/lib/workflows/utils'
import { hasExecutionResult } from '@/executor/utils/errors'
import type { BlockState, WorkflowState } from '@/stores/workflows/workflow/types'
@@ -522,7 +523,13 @@ export async function executeMoveWorkflow(
for (const workflowId of workflowIds) {
try {
await ensureWorkflowAccess(workflowId, context.userId, 'write')
const { workspaceId } = await ensureWorkflowAccess(workflowId, context.userId, 'write')
if (folderId) {
if (!workspaceId || !(await verifyFolderWorkspace(folderId, workspaceId))) {
failed.push(workflowId)
continue
}
}
assertWorkflowMutationNotAborted(context)
await updateWorkflowRecord(workflowId, { folderId })
moved.push(workflowId)
@@ -562,6 +569,14 @@ export async function executeMoveFolder(
const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId))
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
if (!(await verifyFolderWorkspace(folderId, workspaceId))) {
return { success: false, error: 'Folder not found' }
}
if (parentId && !(await verifyFolderWorkspace(parentId, workspaceId))) {
return { success: false, error: 'Parent folder not found' }
}
assertWorkflowMutationNotAborted(context)
await updateFolderRecord(folderId, { parentId })
@@ -1007,6 +1022,11 @@ export async function executeRenameFolder(
const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId))
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
if (!(await verifyFolderWorkspace(folderId, workspaceId))) {
return { success: false, error: 'Folder not found' }
}
assertWorkflowMutationNotAborted(context)
await updateFolderRecord(folderId, { name })
@@ -105,11 +105,12 @@ export const getJobLogsServerTool: BaseServerTool<GetJobLogsArgs, JobLogEntry[]>
}
const wsId = workspaceId || context.workspaceId
if (wsId) {
const access = await checkWorkspaceAccess(wsId, context.userId)
if (!access.hasAccess) {
throw new Error('Unauthorized workspace access')
}
if (!wsId) {
throw new Error('Workspace context required')
}
const access = await checkWorkspaceAccess(wsId, context.userId)
if (!access.hasAccess) {
throw new Error('Unauthorized workspace access')
}
const clampedLimit = Math.min(Math.max(1, limit), 5)
@@ -121,7 +122,10 @@ export const getJobLogsServerTool: BaseServerTool<GetJobLogsArgs, JobLogEntry[]>
includeDetails,
})
const conditions = [eq(jobExecutionLogs.scheduleId, jobId)]
const conditions = [
eq(jobExecutionLogs.scheduleId, jobId),
eq(jobExecutionLogs.workspaceId, wsId),
]
if (executionId) {
conditions.push(eq(jobExecutionLogs.executionId, executionId))
}
@@ -37,6 +37,11 @@ import {
import { StorageService } from '@/lib/uploads'
import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getQueryStrategy, handleVectorOnlySearch } from '@/app/api/knowledge/search/utils'
import {
checkDocumentWriteAccess,
checkKnowledgeBaseAccess,
checkKnowledgeBaseWriteAccess,
} from '@/app/api/knowledge/utils'
const logger = createLogger('KnowledgeBaseServerTool')
@@ -141,6 +146,14 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
const access = await checkKnowledgeBaseAccess(args.knowledgeBaseId, context.userId)
if (!access.hasAccess) {
return {
success: false,
message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`,
}
}
const knowledgeBase = await getKnowledgeBaseById(args.knowledgeBaseId)
if (!knowledgeBase) {
return {
@@ -187,6 +200,14 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
const access = await checkKnowledgeBaseAccess(args.knowledgeBaseId, context.userId)
if (!access.hasAccess) {
return {
success: false,
message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`,
}
}
const kb = await getKnowledgeBaseById(args.knowledgeBaseId)
if (!kb) {
return {
@@ -257,6 +278,17 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
const writeAccess = await checkKnowledgeBaseWriteAccess(
args.knowledgeBaseId,
context.userId
)
if (!writeAccess.hasAccess) {
return {
success: false,
message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`,
}
}
const targetKb = await getKnowledgeBaseById(args.knowledgeBaseId)
if (!targetKb || !targetKb.workspaceId) {
return {
@@ -363,6 +395,17 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
const writeAccess = await checkKnowledgeBaseWriteAccess(
args.knowledgeBaseId,
context.userId
)
if (!writeAccess.hasAccess) {
return {
success: false,
message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`,
}
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const updatedKb = await updateKnowledgeBase(args.knowledgeBaseId, updates, requestId)
@@ -400,6 +443,12 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
const notFound: string[] = []
for (const kbId of kbIds) {
const writeAccess = await checkKnowledgeBaseWriteAccess(kbId, context.userId)
if (!writeAccess.hasAccess) {
notFound.push(kbId)
continue
}
const kbToDelete = await getKnowledgeBaseById(kbId)
if (!kbToDelete) {
notFound.push(kbId)
@@ -444,8 +493,17 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
const failed: string[] = []
for (const docId of docIds) {
const requestId = generateId().slice(0, 8)
assertNotAborted()
const docAccess = await checkDocumentWriteAccess(
args.knowledgeBaseId,
docId,
context.userId
)
if (!docAccess.hasAccess) {
failed.push(docId)
continue
}
const requestId = generateId().slice(0, 8)
const result = await deleteDocument(docId, requestId)
if (result.success) {
deleted.push(docId)
@@ -481,6 +539,17 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
message: 'At least one of filename or enabled is required for update_document',
}
}
const docAccess = await checkDocumentWriteAccess(
args.knowledgeBaseId,
args.documentId,
context.userId
)
if (!docAccess.hasAccess) {
return {
success: false,
message: `Document with ID "${args.documentId}" not found`,
}
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
await updateDocument(args.documentId, updateData, requestId)
@@ -503,6 +572,14 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
const access = await checkKnowledgeBaseAccess(args.knowledgeBaseId, context.userId)
if (!access.hasAccess) {
return {
success: false,
message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`,
}
}
const tagDefinitions = await getDocumentTagDefinitions(args.knowledgeBaseId)
logger.info('Tag definitions listed via copilot', {
@@ -537,6 +614,18 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
message: 'tagDisplayName is required for create_tag operation',
}
}
const writeAccess = await checkKnowledgeBaseWriteAccess(
args.knowledgeBaseId,
context.userId
)
if (!writeAccess.hasAccess) {
return {
success: false,
message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`,
}
}
const fieldType = args.tagFieldType || 'text'
const tagSlot = await getNextAvailableSlot(args.knowledgeBaseId, fieldType)
@@ -606,6 +695,17 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
const writeAccess = await checkKnowledgeBaseWriteAccess(
existingTag.knowledgeBaseId,
context.userId
)
if (!writeAccess.hasAccess) {
return {
success: false,
message: `Tag definition with ID "${args.tagDefinitionId}" not found`,
}
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const updatedTag = await updateTagDefinition(args.tagDefinitionId, updateData, requestId)
@@ -643,6 +743,17 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
const writeAccess = await checkKnowledgeBaseWriteAccess(
args.knowledgeBaseId,
context.userId
)
if (!writeAccess.hasAccess) {
return {
success: false,
message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`,
}
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const deleted = await deleteTagDefinition(
@@ -677,6 +788,14 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
const access = await checkKnowledgeBaseAccess(args.knowledgeBaseId, context.userId)
if (!access.hasAccess) {
return {
success: false,
message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`,
}
}
const requestId = generateId().slice(0, 8)
const stats = await getTagUsageStats(args.knowledgeBaseId, requestId)
@@ -702,6 +821,17 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
}
}
const writeAccess = await checkKnowledgeBaseWriteAccess(
args.knowledgeBaseId,
context.userId
)
if (!writeAccess.hasAccess) {
return {
success: false,
message: `Knowledge base with ID "${args.knowledgeBaseId}" not found`,
}
}
const createBody: Record<string, unknown> = {
connectorType: args.connectorType,
sourceConfig: args.sourceConfig ?? {},
@@ -762,6 +892,11 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
return { success: false, message: `Connector "${args.connectorId}" not found` }
}
const writeAccess = await checkKnowledgeBaseWriteAccess(kbId, context.userId)
if (!writeAccess.hasAccess) {
return { success: false, message: `Connector "${args.connectorId}" not found` }
}
const updateBody: Record<string, unknown> = {}
if (args.sourceConfig !== undefined) updateBody.sourceConfig = args.sourceConfig
if (args.syncIntervalMinutes !== undefined)
@@ -810,6 +945,11 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
return { success: false, message: `Connector "${args.connectorId}" not found` }
}
const writeAccess = await checkKnowledgeBaseWriteAccess(deleteKbId, context.userId)
if (!writeAccess.hasAccess) {
return { success: false, message: `Connector "${args.connectorId}" not found` }
}
assertNotAborted()
const deleteRes = await connectorApiCall(
context.userId,
@@ -843,6 +983,11 @@ export const knowledgeBaseServerTool: BaseServerTool<KnowledgeBaseArgs, Knowledg
return { success: false, message: `Connector "${args.connectorId}" not found` }
}
const writeAccess = await checkKnowledgeBaseWriteAccess(syncKbId, context.userId)
if (!writeAccess.hasAccess) {
return { success: false, message: `Connector "${args.connectorId}" not found` }
}
assertNotAborted()
const syncRes = await connectorApiCall(
context.userId,
@@ -223,9 +223,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table) {
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
@@ -240,9 +243,12 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const table = await getTableById(args.tableId)
if (!table) {
if (!table || table.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
@@ -816,6 +822,9 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const col = (args as Record<string, unknown>).column as
| {
name: string
@@ -830,6 +839,10 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
message: 'column with name and type is required for add_column',
}
}
const tableForAdd = await getTableById(args.tableId)
if (!tableForAdd || tableForAdd.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const updated = await addTableColumn(args.tableId, col, requestId)
@@ -844,11 +857,18 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const colName = (args as Record<string, unknown>).columnName as string | undefined
const newColName = (args as Record<string, unknown>).newName as string | undefined
if (!colName || !newColName) {
return { success: false, message: 'columnName and newName are required' }
}
const tableForRename = await getTableById(args.tableId)
if (!tableForRename || tableForRename.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
assertNotAborted()
const updated = await renameColumn(
@@ -866,12 +886,19 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const colName = (args as Record<string, unknown>).columnName as string | undefined
const colNames = (args as Record<string, unknown>).columnNames as string[] | undefined
const names = colNames ?? (colName ? [colName] : null)
if (!names || names.length === 0) {
return { success: false, message: 'columnName or columnNames is required' }
}
const tableForDelete = await getTableById(args.tableId)
if (!tableForDelete || tableForDelete.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
if (names.length === 1) {
assertNotAborted()
@@ -901,6 +928,9 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
if (!args.tableId) {
return { success: false, message: 'Table ID is required' }
}
if (!workspaceId) {
return { success: false, message: 'Workspace ID is required' }
}
const colName = (args as Record<string, unknown>).columnName as string | undefined
if (!colName) {
return { success: false, message: 'columnName is required' }
@@ -913,6 +943,10 @@ export const userTableServerTool: BaseServerTool<UserTableArgs, UserTableResult>
message: 'At least one of newType or unique must be provided',
}
}
const tableForUpdate = await getTableById(args.tableId)
if (!tableForUpdate || tableForUpdate.workspaceId !== workspaceId) {
return { success: false, message: `Table not found: ${args.tableId}` }
}
const requestId = generateId().slice(0, 8)
let result: TableDefinition | undefined
if (newType !== undefined) {
+87
View File
@@ -0,0 +1,87 @@
import { safeCompare } from '@sim/security/compare'
import { hmacSha256Base64 } from '@sim/security/hmac'
import { env } from '@/lib/core/config/env'
import type { StorageContext } from '@/lib/uploads/shared/types'
export interface UploadTokenPayload {
uploadId: string
key: string
userId: string
workspaceId: string
context: StorageContext
}
interface SignedPayload extends UploadTokenPayload {
exp: number
v: 1
}
const toBase64Url = (input: string): string => Buffer.from(input, 'utf8').toString('base64url')
const fromBase64Url = (input: string): string => Buffer.from(input, 'base64url').toString('utf8')
const sign = (payload: string): string => hmacSha256Base64(payload, env.INTERNAL_API_SECRET)
/**
* Sign an upload session token binding (uploadId, key, userId, workspaceId, context).
* Used to prevent IDOR on multipart upload follow-up calls (get-part-urls, complete, abort).
*/
export function signUploadToken(payload: UploadTokenPayload, expiresInSeconds = 60 * 60): string {
const signed: SignedPayload = {
...payload,
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
v: 1,
}
const encoded = toBase64Url(JSON.stringify(signed))
return `${encoded}.${sign(encoded)}`
}
export type UploadTokenVerification =
| { valid: true; payload: UploadTokenPayload }
| { valid: false }
export function verifyUploadToken(token: string): UploadTokenVerification {
if (typeof token !== 'string') {
return { valid: false }
}
const parts = token.split('.')
if (parts.length !== 2) return { valid: false }
const [encoded, signature] = parts
if (!encoded || !signature) return { valid: false }
const expected = sign(encoded)
if (!safeCompare(signature, expected)) {
return { valid: false }
}
let parsed: SignedPayload
try {
parsed = JSON.parse(fromBase64Url(encoded)) as SignedPayload
} catch {
return { valid: false }
}
if (
parsed.v !== 1 ||
typeof parsed.exp !== 'number' ||
parsed.exp < Math.floor(Date.now() / 1000) ||
typeof parsed.uploadId !== 'string' ||
typeof parsed.key !== 'string' ||
typeof parsed.userId !== 'string' ||
typeof parsed.workspaceId !== 'string' ||
typeof parsed.context !== 'string'
) {
return { valid: false }
}
return {
valid: true,
payload: {
uploadId: parsed.uploadId,
key: parsed.key,
userId: parsed.userId,
workspaceId: parsed.workspaceId,
context: parsed.context as StorageContext,
},
}
}
+12
View File
@@ -564,6 +564,18 @@ export async function updateFolderRecord(
await db.update(workflowFolder).set(setData).where(eq(workflowFolder.id, folderId))
}
export async function verifyFolderWorkspace(
folderId: string,
workspaceId: string
): Promise<boolean> {
const [row] = await db
.select({ id: workflowFolder.id })
.from(workflowFolder)
.where(and(eq(workflowFolder.id, folderId), eq(workflowFolder.workspaceId, workspaceId)))
.limit(1)
return Boolean(row)
}
export async function deleteFolderRecord(folderId: string): Promise<boolean> {
const [folder] = await db
.select({ parentId: workflowFolder.parentId })