Skip to content

Commit 12cc14d

Browse files
committed
Fix Mass Assignment in Execution Update Endpoint
`PUT /api/v1/executions/:id` previously accepted the raw request body and applied it to the matched Execution row via `Object.assign(new Execution(), req.body)` followed by `repository.merge(existing, new)` and `save()`, with no allowlist of writable fields. A workspace user could overwrite any column on an execution they owned, including: - `isPublic`: flipping this to `true` exposes the row through the unauthenticated `GET /api/v1/public-executions/:id` route (whitelisted in `utils/constants.ts::WHITELIST_URLS`), which leaks the full LLM / tool transcript stored in `executionData`. `_removeCredentialId` only strips `credentialId` fields; the rest of the transcript is returned verbatim. - `workspaceId`: reparents the execution into another workspace. - `agentflowId`: re-points the row at a chatflow the attacker does not own, confusing the `leftJoinAndSelect('execution.agentflow', ...)` join in `getAllExecutions`. - `executionData`, `state`, `action`, `stoppedDate`: lets an attacker rewrite the audit trail of their own executions. The UI only ever calls this endpoint to toggle `isPublic` (`packages/ui/src/views/agentexecutions/ExecutionDetails.jsx`, `packages/ui/src/views/agentexecutions/ShareExecutionDialog.jsx`, `packages/observe/src/infrastructure/api/executions.ts`), so the writable surface is narrowed to a single explicit field. Internal callers that need to mutate `executionData`, `state`, `stoppedDate`, etc. use the dedicated `updateExecution` helper in `utils/buildAgentflow.ts`, which does not pass through this service function and is therefore unaffected. Adds a co-located regression test that exercises the legitimate `isPublic` toggle and verifies that `workspaceId`, `agentflowId`, `state`, `action`, `executionData`, `stoppedDate`, and `id` cannot be mass-assigned through this service. Refs: GHSA-v2cc-6h2j-w847 Signed-off-by: tonghuaroot <tonghuaroot@gmail.com>
1 parent 9c85c14 commit 12cc14d

2 files changed

Lines changed: 268 additions & 1 deletion

File tree

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { StatusCodes } from 'http-status-codes'
2+
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
3+
4+
// typeorm is not directly resolvable under pnpm strict hoisting; provide a
5+
// virtual mock so entity decorators are no-ops in this test context.
6+
jest.mock(
7+
'typeorm',
8+
() => {
9+
const noop = () => (_target: any, _key?: any) => {}
10+
return {
11+
Entity: () => noop(),
12+
Column: () => noop(),
13+
PrimaryGeneratedColumn: () => noop(),
14+
CreateDateColumn: () => noop(),
15+
UpdateDateColumn: () => noop(),
16+
ManyToOne: () => noop(),
17+
OneToMany: () => noop(),
18+
OneToOne: () => noop(),
19+
JoinColumn: () => noop(),
20+
Index: () => noop(),
21+
Unique: () => noop(),
22+
In: (vals: any) => ({ _type: 'in', _value: vals })
23+
}
24+
},
25+
{ virtual: true }
26+
)
27+
28+
// ── Mocks ───────────────────────────────────────────────────────────────────
29+
30+
const mockSave = jest.fn()
31+
const mockFindOneBy = jest.fn()
32+
const mockMerge = jest.fn()
33+
const mockFindOne = jest.fn()
34+
const mockDelete = jest.fn()
35+
const mockUpdate = jest.fn()
36+
const mockGetManyAndCount = jest.fn()
37+
const mockQueryBuilder = {
38+
leftJoinAndSelect: jest.fn().mockReturnThis(),
39+
orderBy: jest.fn().mockReturnThis(),
40+
skip: jest.fn().mockReturnThis(),
41+
take: jest.fn().mockReturnThis(),
42+
where: jest.fn().mockReturnThis(),
43+
andWhere: jest.fn().mockReturnThis(),
44+
getManyAndCount: mockGetManyAndCount
45+
}
46+
const mockGetRepository = jest.fn().mockReturnValue({
47+
save: mockSave,
48+
findOneBy: mockFindOneBy,
49+
findOne: mockFindOne,
50+
merge: mockMerge,
51+
delete: mockDelete,
52+
update: mockUpdate,
53+
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder)
54+
})
55+
56+
jest.mock('../../utils/getRunningExpressApp', () => ({
57+
getRunningExpressApp: jest.fn(() => ({
58+
AppDataSource: { getRepository: mockGetRepository }
59+
}))
60+
}))
61+
62+
jest.mock('../../utils', () => ({
63+
_removeCredentialId: jest.fn((data: any) => data)
64+
}))
65+
66+
// Import after the virtual mock and module mocks are registered
67+
import executionsService from './index'
68+
69+
// ── Helpers ─────────────────────────────────────────────────────────────────
70+
71+
const ATTACKER_WS = '40b8ad7b-e687-4e23-8148-ade5bb4805a8'
72+
const VICTIM_WS = '11111111-2222-3333-4444-555555555555'
73+
const EXEC_ID = 'bbbbbbbb-cccc-dddd-eeee-333333333333'
74+
const VICTIM_AGENTFLOW_ID = 'cccccccc-dddd-eeee-ffff-444444444444'
75+
const ORIGINAL_AGENTFLOW_ID = 'aaaaaaaa-bbbb-cccc-dddd-111111111111'
76+
77+
const makeExecution = (overrides: Record<string, any> = {}) => ({
78+
id: EXEC_ID,
79+
executionData: '[{"nodeId":"llmAgent_0","data":{"output":{"content":"original transcript"}}}]',
80+
state: 'FINISHED',
81+
agentflowId: ORIGINAL_AGENTFLOW_ID,
82+
sessionId: 'sess-1',
83+
action: null,
84+
isPublic: false,
85+
createdDate: new Date('2026-01-01'),
86+
updatedDate: new Date('2026-01-01'),
87+
stoppedDate: new Date('2026-01-01'),
88+
workspaceId: ATTACKER_WS,
89+
...overrides
90+
})
91+
92+
describe('executionsService.updateExecution', () => {
93+
beforeEach(() => {
94+
jest.clearAllMocks()
95+
// Default: merge applies properties from second arg onto first arg (TypeORM semantics)
96+
mockMerge.mockImplementation((target: any, source: any) => {
97+
Object.assign(target, source)
98+
return target
99+
})
100+
})
101+
102+
it('persists isPublic when toggled by an owning workspace user', async () => {
103+
const stored = makeExecution()
104+
mockFindOneBy.mockResolvedValue(stored)
105+
mockSave.mockImplementation(async (e: any) => e)
106+
107+
const result: any = await executionsService.updateExecution(EXEC_ID, { isPublic: true } as any, ATTACKER_WS)
108+
109+
expect(mockFindOneBy).toHaveBeenCalledWith({ id: EXEC_ID, workspaceId: ATTACKER_WS })
110+
expect(result.isPublic).toBe(true)
111+
expect(mockSave).toHaveBeenCalled()
112+
})
113+
114+
// Regression — GHSA-v2cc-6h2j-w847
115+
//
116+
// The pre-patch service accepted the raw request body and ran
117+
// `Object.assign(new Execution(), req.body)` followed by
118+
// `merge + save`, with no allowlist. A workspace user could overwrite
119+
// any column on an execution they owned, including columns that gate
120+
// the unauthenticated `GET /api/v1/public-executions/:id` endpoint
121+
// (`isPublic`) and the cross-workspace ownership key (`workspaceId`).
122+
describe('mass assignment regression (GHSA-v2cc-6h2j-w847)', () => {
123+
it('ignores attacker-supplied workspaceId in the request body', async () => {
124+
const stored = makeExecution()
125+
mockFindOneBy.mockResolvedValue(stored)
126+
mockSave.mockImplementation(async (e: any) => e)
127+
128+
const result: any = await executionsService.updateExecution(
129+
EXEC_ID,
130+
{ isPublic: true, workspaceId: VICTIM_WS } as any,
131+
ATTACKER_WS
132+
)
133+
134+
expect(result.workspaceId).toBe(ATTACKER_WS)
135+
// Confirm the merged entity that was saved did not pick up the victim workspace id
136+
const savedEntity = mockSave.mock.calls[0][0]
137+
expect(savedEntity.workspaceId).toBe(ATTACKER_WS)
138+
expect(savedEntity.workspaceId).not.toBe(VICTIM_WS)
139+
})
140+
141+
it('ignores attacker-supplied agentflowId in the request body', async () => {
142+
const stored = makeExecution()
143+
mockFindOneBy.mockResolvedValue(stored)
144+
mockSave.mockImplementation(async (e: any) => e)
145+
146+
const result: any = await executionsService.updateExecution(EXEC_ID, { agentflowId: VICTIM_AGENTFLOW_ID } as any, ATTACKER_WS)
147+
148+
expect(result.agentflowId).toBe(ORIGINAL_AGENTFLOW_ID)
149+
expect(result.agentflowId).not.toBe(VICTIM_AGENTFLOW_ID)
150+
})
151+
152+
it('ignores attacker-supplied state, action, executionData and stoppedDate', async () => {
153+
const stored = makeExecution()
154+
mockFindOneBy.mockResolvedValue(stored)
155+
mockSave.mockImplementation(async (e: any) => e)
156+
157+
const result: any = await executionsService.updateExecution(
158+
EXEC_ID,
159+
{
160+
state: 'ERROR',
161+
action: 'rewritten-by-attacker',
162+
executionData: '[{"nodeId":"noop","output":"sponsored-content-injected"}]',
163+
stoppedDate: new Date('2099-01-01T00:00:00.000Z')
164+
} as any,
165+
ATTACKER_WS
166+
)
167+
168+
expect(result.state).toBe('FINISHED')
169+
expect(result.action).toBeNull()
170+
expect(result.executionData).toBe('[{"nodeId":"llmAgent_0","data":{"output":{"content":"original transcript"}}}]')
171+
expect(result.stoppedDate).toEqual(new Date('2026-01-01'))
172+
})
173+
174+
it('ignores attacker-supplied id in the request body', async () => {
175+
const stored = makeExecution()
176+
mockFindOneBy.mockResolvedValue(stored)
177+
mockSave.mockImplementation(async (e: any) => e)
178+
179+
const result: any = await executionsService.updateExecution(
180+
EXEC_ID,
181+
{ id: '99999999-9999-9999-9999-999999999999' } as any,
182+
ATTACKER_WS
183+
)
184+
185+
expect(result.id).toBe(EXEC_ID)
186+
})
187+
188+
it('still allows the legitimate isPublic toggle when mixed with malicious fields', async () => {
189+
const stored = makeExecution()
190+
mockFindOneBy.mockResolvedValue(stored)
191+
mockSave.mockImplementation(async (e: any) => e)
192+
193+
const result: any = await executionsService.updateExecution(
194+
EXEC_ID,
195+
{
196+
isPublic: true,
197+
workspaceId: VICTIM_WS,
198+
agentflowId: VICTIM_AGENTFLOW_ID,
199+
state: 'ERROR'
200+
} as any,
201+
ATTACKER_WS
202+
)
203+
204+
expect(result.isPublic).toBe(true)
205+
expect(result.workspaceId).toBe(ATTACKER_WS)
206+
expect(result.agentflowId).toBe(ORIGINAL_AGENTFLOW_ID)
207+
expect(result.state).toBe('FINISHED')
208+
})
209+
})
210+
211+
it('throws NOT_FOUND when no execution matches the scoped query', async () => {
212+
mockFindOneBy.mockResolvedValue(null)
213+
214+
await expect(executionsService.updateExecution(EXEC_ID, { isPublic: true } as any, ATTACKER_WS)).rejects.toThrow(
215+
InternalFlowiseError
216+
)
217+
await expect(executionsService.updateExecution(EXEC_ID, { isPublic: true } as any, ATTACKER_WS)).rejects.toMatchObject({
218+
statusCode: StatusCodes.INTERNAL_SERVER_ERROR
219+
})
220+
expect(mockSave).not.toHaveBeenCalled()
221+
})
222+
223+
it('scopes the lookup with workspaceId when one is supplied', async () => {
224+
mockFindOneBy.mockResolvedValue(makeExecution())
225+
mockSave.mockImplementation(async (e: any) => e)
226+
227+
await executionsService.updateExecution(EXEC_ID, { isPublic: true } as any, ATTACKER_WS)
228+
229+
expect(mockFindOneBy).toHaveBeenCalledWith({ id: EXEC_ID, workspaceId: ATTACKER_WS })
230+
})
231+
232+
it('omits workspaceId from the lookup when none is supplied', async () => {
233+
mockFindOneBy.mockResolvedValue(makeExecution())
234+
mockSave.mockImplementation(async (e: any) => e)
235+
236+
await executionsService.updateExecution(EXEC_ID, { isPublic: true } as any)
237+
238+
expect(mockFindOneBy).toHaveBeenCalledWith({ id: EXEC_ID })
239+
})
240+
})

packages/server/src/services/executions/index.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,21 @@ const getAllExecutions = async (filters: ExecutionFilters = {}): Promise<{ data:
105105
}
106106
}
107107

108+
/**
109+
* Fields that the HTTP `PUT /api/v1/executions/:id` endpoint is allowed to
110+
* change on an Execution row. The endpoint is only used by the UI to toggle
111+
* an execution's share state (see `packages/ui/src/views/agentexecutions`),
112+
* so the writable surface is intentionally limited to `isPublic`.
113+
*
114+
* Every other column (`id`, `executionData`, `state`, `agentflowId`,
115+
* `sessionId`, `action`, `createdDate`, `updatedDate`, `stoppedDate`,
116+
* `workspaceId`) is server-managed and must not be settable through this
117+
* route. Internal callers that need to mutate those columns use the
118+
* dedicated `updateExecution` helper in `utils/buildAgentflow.ts`, which
119+
* does not pass through this service function.
120+
*/
121+
const EXECUTION_UPDATABLE_FIELDS = ['isPublic'] as const
122+
108123
const updateExecution = async (executionId: string, data: Partial<Execution>, workspaceId?: string): Promise<Execution | null> => {
109124
try {
110125
const appServer = getRunningExpressApp()
@@ -117,8 +132,20 @@ const updateExecution = async (executionId: string, data: Partial<Execution>, wo
117132
if (!execution) {
118133
throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Execution ${executionId} not found`)
119134
}
135+
136+
// Build a sanitized partial that only carries fields in the
137+
// explicit allowlist; prevents mass assignment of server-managed
138+
// columns such as `workspaceId`, `agentflowId`, `executionData`,
139+
// `state`, and so on via the request body.
140+
const sanitized: Partial<Execution> = {}
141+
for (const field of EXECUTION_UPDATABLE_FIELDS) {
142+
if (data && Object.prototype.hasOwnProperty.call(data, field)) {
143+
;(sanitized as any)[field] = (data as any)[field]
144+
}
145+
}
146+
120147
const updateExecution = new Execution()
121-
Object.assign(updateExecution, data)
148+
Object.assign(updateExecution, sanitized)
122149
await appServer.AppDataSource.getRepository(Execution).merge(execution, updateExecution)
123150
const dbResponse = await appServer.AppDataSource.getRepository(Execution).save(execution)
124151
return dbResponse

0 commit comments

Comments
 (0)