mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
Mass Assignment in Assistant Update Endpoint (#5945)
* Mass Assignment in Assistant Update Endpoint Allows Cross-Workspace Resource Reassignment * Mass Assignment in Assistant Update Endpoint Allows Cross-Workspace Resource Reassignment * Mass Assignment in Assistant Update Endpoint Allows Cross-Workspace Resource Reassignment * Mass Assignment in Assistant Update Endpoint --------- Co-authored-by: christopherholland-workday <[email protected]>
This commit is contained in:
co-authored by
christopherholland-workday
parent
24e713b99e
commit
faa571c961
@@ -36,7 +36,7 @@ const createAssistant = async (req: Request, res: Response, next: NextFunction)
|
||||
await checkUsageLimit('flows', subscriptionId, getRunningExpressApp().usageCacheManager, existingAssistantCount + newAssistantCount)
|
||||
|
||||
body.workspaceId = workspaceId
|
||||
const apiResponse = await assistantsService.createAssistant(body, orgId)
|
||||
const apiResponse = await assistantsService.createAssistant(body, orgId, workspaceId)
|
||||
|
||||
return res.json(apiResponse)
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { stripProtectedFields } from '../../utils/stripProtectedFields'
|
||||
import { extractResponseContent, ICommonObject } from 'flowise-components'
|
||||
import { StatusCodes } from 'http-status-codes'
|
||||
import { cloneDeep, isEqual, uniqWith } from 'lodash'
|
||||
@@ -20,7 +21,7 @@ import { ASSISTANT_PROMPT_GENERATOR } from '../../utils/prompt'
|
||||
import { checkUsageLimit } from '../../utils/quotaUsage'
|
||||
import nodesService from '../nodes'
|
||||
|
||||
const createAssistant = async (requestBody: any, orgId: string): Promise<Assistant> => {
|
||||
const createAssistant = async (requestBody: any, orgId: string, workspaceId: string): Promise<Assistant> => {
|
||||
try {
|
||||
const appServer = getRunningExpressApp()
|
||||
if (!requestBody.details) {
|
||||
@@ -29,8 +30,11 @@ const createAssistant = async (requestBody: any, orgId: string): Promise<Assista
|
||||
const assistantDetails = JSON.parse(requestBody.details)
|
||||
|
||||
if (requestBody.type === 'CUSTOM') {
|
||||
// For CUSTOM assistants the credential field is a client-generated UUID used as an
|
||||
// internal identifier, not a reference to the Credential entity, so no lookup is needed.
|
||||
const newAssistant = new Assistant()
|
||||
Object.assign(newAssistant, requestBody)
|
||||
Object.assign(newAssistant, stripProtectedFields(requestBody))
|
||||
newAssistant.workspaceId = workspaceId
|
||||
|
||||
const assistant = appServer.AppDataSource.getRepository(Assistant).create(newAssistant)
|
||||
const dbResponse = await appServer.AppDataSource.getRepository(Assistant).save(assistant)
|
||||
@@ -51,7 +55,8 @@ const createAssistant = async (requestBody: any, orgId: string): Promise<Assista
|
||||
|
||||
try {
|
||||
const credential = await appServer.AppDataSource.getRepository(Credential).findOneBy({
|
||||
id: requestBody.credential
|
||||
id: requestBody.credential,
|
||||
workspaceId: workspaceId
|
||||
})
|
||||
|
||||
if (!credential) {
|
||||
@@ -297,12 +302,24 @@ const updateAssistant = async (assistantId: string, requestBody: any, workspaceI
|
||||
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Assistant ${assistantId} not found`)
|
||||
}
|
||||
|
||||
if (requestBody.details !== undefined) {
|
||||
if (!requestBody.details) {
|
||||
throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Details cannot be empty`)
|
||||
}
|
||||
let parsedDetails: any
|
||||
try {
|
||||
parsedDetails = JSON.parse(requestBody.details)
|
||||
} catch (e) {
|
||||
throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Details must be valid JSON`)
|
||||
}
|
||||
if (assistant.type === 'CUSTOM' && !parsedDetails?.name) {
|
||||
throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Details must include a name field`)
|
||||
}
|
||||
}
|
||||
|
||||
if (assistant.type === 'CUSTOM') {
|
||||
const body = requestBody
|
||||
const updateAssistant = new Assistant()
|
||||
Object.assign(updateAssistant, body)
|
||||
Object.assign(assistant, stripProtectedFields(requestBody))
|
||||
|
||||
appServer.AppDataSource.getRepository(Assistant).merge(assistant, updateAssistant)
|
||||
const dbResponse = await appServer.AppDataSource.getRepository(Assistant).save(assistant)
|
||||
return dbResponse
|
||||
}
|
||||
@@ -312,7 +329,8 @@ const updateAssistant = async (assistantId: string, requestBody: any, workspaceI
|
||||
const body = requestBody
|
||||
const assistantDetails = JSON.parse(body.details)
|
||||
const credential = await appServer.AppDataSource.getRepository(Credential).findOneBy({
|
||||
id: body.credential
|
||||
id: body.credential,
|
||||
workspaceId: workspaceId
|
||||
})
|
||||
|
||||
if (!credential) {
|
||||
@@ -376,11 +394,12 @@ const updateAssistant = async (assistantId: string, requestBody: any, workspaceI
|
||||
}
|
||||
if (savedToolResources) newAssistantDetails.tool_resources = savedToolResources
|
||||
|
||||
const updateAssistant = new Assistant()
|
||||
body.details = JSON.stringify(newAssistantDetails)
|
||||
Object.assign(updateAssistant, body)
|
||||
// Explicit allowlist — mutate only allowed fields on the fetched entity (same
|
||||
// reasoning as the CUSTOM path above: avoid merge() with an intermediate entity).
|
||||
assistant.details = JSON.stringify(newAssistantDetails)
|
||||
if (body.credential !== undefined) assistant.credential = body.credential
|
||||
if (body.iconSrc !== undefined) assistant.iconSrc = body.iconSrc
|
||||
|
||||
appServer.AppDataSource.getRepository(Assistant).merge(assistant, updateAssistant)
|
||||
const dbResponse = await appServer.AppDataSource.getRepository(Assistant).save(assistant)
|
||||
return dbResponse
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Fields that are managed exclusively by the server and must never be
|
||||
* overwritten by user-supplied request bodies.
|
||||
*/
|
||||
export const PROTECTED_FIELDS = ['id', 'createdDate', 'updatedDate', 'workspaceId', 'organizationId'] as const
|
||||
|
||||
export type ProtectedField = (typeof PROTECTED_FIELDS)[number]
|
||||
|
||||
/**
|
||||
* Returns a shallow copy of `body` with all server-managed fields removed.
|
||||
* Use this before assigning a request body to a database entity to prevent
|
||||
* mass assignment of fields such as `workspaceId`, `id`, and timestamps.
|
||||
*
|
||||
* @example
|
||||
* Object.assign(entity, stripProtectedFields(req.body))
|
||||
*/
|
||||
export function stripProtectedFields<T extends Record<string, unknown>>(body: T): Omit<T, ProtectedField> {
|
||||
const sanitized = { ...body }
|
||||
for (const field of PROTECTED_FIELDS) {
|
||||
delete sanitized[field]
|
||||
}
|
||||
return sanitized as Omit<T, ProtectedField>
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { PROTECTED_FIELDS, stripProtectedFields } from '../../src/utils/stripProtectedFields'
|
||||
|
||||
describe('stripProtectedFields', () => {
|
||||
it('removes id from input', () => {
|
||||
const result = stripProtectedFields({ id: 'abc-123', name: 'test' })
|
||||
expect(result).not.toHaveProperty('id')
|
||||
expect(result).toHaveProperty('name', 'test')
|
||||
})
|
||||
|
||||
it('removes createdDate from input', () => {
|
||||
const result = stripProtectedFields({ createdDate: new Date().toISOString(), name: 'test' })
|
||||
expect(result).not.toHaveProperty('createdDate')
|
||||
})
|
||||
|
||||
it('removes updatedDate from input', () => {
|
||||
const result = stripProtectedFields({ updatedDate: new Date().toISOString(), name: 'test' })
|
||||
expect(result).not.toHaveProperty('updatedDate')
|
||||
})
|
||||
|
||||
it('removes workspaceId from input', () => {
|
||||
const result = stripProtectedFields({ workspaceId: 'ws-999', details: '{}' })
|
||||
expect(result).not.toHaveProperty('workspaceId')
|
||||
expect(result).toHaveProperty('details', '{}')
|
||||
})
|
||||
|
||||
it('removes organizationId from input', () => {
|
||||
const result = stripProtectedFields({ organizationId: 'org-456', credential: 'cred-uuid' })
|
||||
expect(result).not.toHaveProperty('organizationId')
|
||||
expect(result).toHaveProperty('credential', 'cred-uuid')
|
||||
})
|
||||
|
||||
it('removes all protected fields when all are present', () => {
|
||||
const body = {
|
||||
id: 'abc',
|
||||
createdDate: '2026-01-01T00:00:00.000Z',
|
||||
updatedDate: '2026-01-02T00:00:00.000Z',
|
||||
workspaceId: '11111111-2222-3333-4444-555555555555',
|
||||
organizationId: 'org-789',
|
||||
details: '{"name":"my assistant"}',
|
||||
credential: 'cred-uuid',
|
||||
iconSrc: null
|
||||
}
|
||||
const result = stripProtectedFields(body)
|
||||
for (const field of PROTECTED_FIELDS) {
|
||||
expect(result).not.toHaveProperty(field)
|
||||
}
|
||||
expect(result).toEqual({ details: '{"name":"my assistant"}', credential: 'cred-uuid', iconSrc: null })
|
||||
})
|
||||
|
||||
it('preserves all non-protected fields', () => {
|
||||
const body = { details: '{}', credential: 'cred-1', iconSrc: 'https://example.com/icon.png', type: 'CUSTOM' }
|
||||
const result = stripProtectedFields(body)
|
||||
expect(result).toEqual(body)
|
||||
})
|
||||
|
||||
it('returns an empty object when given an empty object', () => {
|
||||
const result = stripProtectedFields({})
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('returns an equal shallow copy when no protected fields are present', () => {
|
||||
const body = { name: 'tool', description: 'does stuff', color: '#ff0000' }
|
||||
const result = stripProtectedFields(body)
|
||||
expect(result).toEqual(body)
|
||||
})
|
||||
|
||||
it('does not mutate the original input object', () => {
|
||||
const original = { id: 'abc', workspaceId: 'ws-1', name: 'assistant' }
|
||||
const copy = { ...original }
|
||||
stripProtectedFields(original)
|
||||
expect(original).toEqual(copy)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user