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:
christopherholland-workday
2026-03-31 23:02:05 +08:00
committed by GitHub
co-authored by christopherholland-workday
parent 24e713b99e
commit faa571c961
4 changed files with 128 additions and 13 deletions
@@ -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)
})
})