mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user