Skip to content

Commit fb0f49c

Browse files
Tonourscursoragent
andcommitted
chore(agent-bff): merge main into action input validation branch
Combine isEnumFieldType validation with sanitizeActionLayout from #1859. Co-authored-by: Cursor <cursoragent@cursor.com>
2 parents 9eb9588 + 809395d commit fb0f49c

17 files changed

Lines changed: 704 additions & 35 deletions

packages/agent-bff/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## @forestadmin/agent-bff [1.23.5](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-bff@1.23.4...@forestadmin/agent-bff@1.23.5) (2026-09-02)
2+
3+
4+
### Bug Fixes
5+
6+
* **agent-bff:** sanitize action result html before relaying ([#1859](https://github.com/ForestAdmin/agent-nodejs/issues/1859)) ([695e160](https://github.com/ForestAdmin/agent-nodejs/commit/695e160361be95d9ef90da051f1e35e1de841f53))
7+
18
## @forestadmin/agent-bff [1.23.4](https://github.com/ForestAdmin/agent-nodejs/compare/@forestadmin/agent-bff@1.23.3...@forestadmin/agent-bff@1.23.4) (2026-09-01)
29

310

packages/agent-bff/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@forestadmin/agent-bff",
3-
"version": "1.23.4",
3+
"version": "1.23.5",
44
"main": "dist/index.js",
55
"bin": {
66
"forest-bff": "dist/cli.js"
@@ -39,6 +39,7 @@
3939
"@koa/bodyparser": "^6.1.0",
4040
"jsonwebtoken": "^9.0.3",
4141
"koa": "^3.0.1",
42+
"sanitize-html": "2.17.5",
4243
"zod": "4.3.6"
4344
},
4445
"devDependencies": {
@@ -48,6 +49,7 @@
4849
"@redocly/cli": "2.35.1",
4950
"@types/jsonwebtoken": "^9.0.1",
5051
"@types/koa": "^2.13.5",
52+
"@types/sanitize-html": "^2.16.1",
5153
"@types/supertest": "^6.0.2",
5254
"openapi3-ts": "4.6.1",
5355
"redoc": "2.5.3",

packages/agent-bff/src/action/action-execute-mapper.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import type { Logger } from '../ports/logger-port';
2+
3+
import sanitizeActionHtml from './sanitize-action-html';
4+
15
export interface ActionExecuteSuccessBody {
26
type: 'success';
37
message: string | null;
@@ -63,7 +67,7 @@ function isRefresh(value: unknown): value is { relationships: unknown[] } {
6367
// untyped at the BFF boundary (`Action.execute(): Promise<unknown>`), so we discriminate on the
6468
// agent HTTP payload shape. A File result streams a binary with no JSON marker, so any unrecognized
6569
// 200 body falls through to a structured 501 rather than being mislabelled.
66-
export function mapActionExecuteResult(raw: unknown): ActionExecuteMapped {
70+
export function mapActionExecuteResult(raw: unknown, logger: Logger): ActionExecuteMapped {
6771
const body = (typeof raw === 'object' && raw !== null ? raw : {}) as Record<string, unknown>;
6872

6973
// Each branch validates the value shape, not just key presence: a malformed payload
@@ -90,7 +94,7 @@ export function mapActionExecuteResult(raw: unknown): ActionExecuteMapped {
9094
type: 'success',
9195
message: typeof body.success === 'string' ? body.success : null,
9296
invalidated: relationships.filter((name): name is string => typeof name === 'string'),
93-
html: typeof body.html === 'string' ? body.html : null,
97+
html: sanitizeActionHtml(body.html, logger),
9498
},
9599
};
96100
}

packages/agent-bff/src/action/action-form-mapper.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type { ActionForm, ActionFormField } from './agent-action-client';
2+
import type { Logger } from '../ports/logger-port';
23
import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadmin-client';
34

5+
import { sanitizeActionLayout } from './sanitize-action-html';
46
import { isEnumFieldType } from '../read-model/field-type';
57

68
export interface ActionFormFieldResponse {
@@ -33,6 +35,7 @@ export function mapActionForm(
3335
action: ActionForm,
3436
skippedFields: string[],
3537
layout: ForestServerActionFormLayoutElement[],
38+
logger: Logger,
3639
): ActionFormResponse {
3740
const fields = action.getFields();
3841

@@ -57,6 +60,6 @@ export function mapActionForm(
5760
canExecute: requiredFields.length === 0,
5861
requiredFields,
5962
skippedFields,
60-
layout,
63+
layout: sanitizeActionLayout(layout, logger),
6164
};
6265
}

packages/agent-bff/src/action/action-routes-middleware.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { mapActionExecuteResult } from './action-execute-mapper';
1919
import { mapActionForm } from './action-form-mapper';
2020
import assertActionValuesExecutable from './action-values-validator';
2121
import defaultCreateAgentActionClient, { extractRawLayout } from './agent-action-client';
22+
import sanitizeActionHtml from './sanitize-action-html';
2223
import { mapAgentError } from '../http/agent-error-mapper';
2324
import {
2425
callAgent,
@@ -115,7 +116,7 @@ async function handleForm({
115116
const skippedFields = await callAgent(() => action.tryToSetFields(values), logger);
116117

117118
ctx.status = 200;
118-
ctx.body = mapActionForm(action, skippedFields, extractRawLayout(action));
119+
ctx.body = mapActionForm(action, skippedFields, extractRawLayout(action), logger);
119120
}
120121

121122
async function handleExecute({
@@ -154,13 +155,15 @@ async function handleExecute({
154155
}
155156

156157
if (error instanceof ActionFormValidationError) {
157-
throw actionError(error.message, error.html !== undefined ? { html: error.html } : undefined);
158+
const html = sanitizeActionHtml(error.html, logger);
159+
160+
throw actionError(error.message, html === null ? undefined : { html });
158161
}
159162

160163
throw mapAgentError(error, { logger });
161164
}
162165

163-
const { status, body } = mapActionExecuteResult(raw);
166+
const { status, body } = mapActionExecuteResult(raw, logger);
164167

165168
// An unrecognized payload (a File stream, or a new agent result type) maps to a generic 501 with
166169
// no trace of what it was; log a short shape hint so the case can be diagnosed without the body.

packages/agent-bff/src/action/agent-action-client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export interface AgentActionClientOptions {
5656
export function extractRawLayout(action: ActionForm): ForestServerActionFormLayoutElement[] {
5757
const root = action.getLayout() as { layout?: ForestServerActionFormLayoutElement[] };
5858

59-
return root.layout ?? [];
59+
return Array.isArray(root.layout) ? root.layout : [];
6060
}
6161

6262
/**
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import type { Logger } from '../ports/logger-port';
2+
import type { ForestServerActionFormLayoutElement } from '@forestadmin/forestadmin-client';
3+
4+
import sanitizeHtml from 'sanitize-html';
5+
6+
const MAX_HTML_CHARACTERS = 256 * 1024;
7+
8+
const MAX_LAYOUT_DEPTH = 10;
9+
10+
const SAFE_STYLE_VALUE =
11+
/^(?![^;{}\\]*(?:url|expression|image-set|element|-moz-binding)\s*\()[^;{}\\]*$/i;
12+
13+
const ALLOWED_STYLE_PROPERTIES = [
14+
'background',
15+
'background-color',
16+
'border',
17+
'border-bottom',
18+
'border-collapse',
19+
'border-left',
20+
'border-radius',
21+
'border-right',
22+
'border-top',
23+
'color',
24+
'display',
25+
'font-family',
26+
'font-size',
27+
'font-style',
28+
'font-weight',
29+
'height',
30+
'letter-spacing',
31+
'line-height',
32+
'margin',
33+
'margin-bottom',
34+
'margin-left',
35+
'margin-right',
36+
'margin-top',
37+
'max-width',
38+
'min-width',
39+
'opacity',
40+
'overflow',
41+
'padding',
42+
'padding-bottom',
43+
'padding-left',
44+
'padding-right',
45+
'padding-top',
46+
'text-align',
47+
'text-decoration',
48+
'text-transform',
49+
'vertical-align',
50+
'white-space',
51+
'width',
52+
];
53+
54+
const OPTIONS: sanitizeHtml.IOptions = {
55+
allowedAttributes: { ...sanitizeHtml.defaults.allowedAttributes, '*': ['style'] },
56+
allowedClasses: { '*': ['c-*', 'l-*'] },
57+
allowedStyles: {
58+
'*': Object.fromEntries(ALLOWED_STYLE_PROPERTIES.map(name => [name, [SAFE_STYLE_VALUE]])),
59+
},
60+
};
61+
62+
function sanitize(html: unknown, logger: Logger): string | null {
63+
if (typeof html !== 'string') return null;
64+
65+
let bounded = html;
66+
67+
if (bounded.length > MAX_HTML_CHARACTERS) {
68+
logger('Warn', 'Action html truncated: longer than the sanitizable size', {
69+
characters: bounded.length,
70+
limit: MAX_HTML_CHARACTERS,
71+
});
72+
73+
bounded = bounded.slice(0, MAX_HTML_CHARACTERS);
74+
}
75+
76+
try {
77+
return sanitizeHtml(bounded, OPTIONS) || null;
78+
} catch (error) {
79+
logger('Error', 'Action html dropped: sanitization failed', { cause: String(error) });
80+
81+
return null;
82+
}
83+
}
84+
85+
export default function sanitizeActionHtml(html: unknown, logger: Logger): string | null {
86+
return sanitize(html, logger);
87+
}
88+
89+
export function sanitizeActionLayout(
90+
layout: ForestServerActionFormLayoutElement[],
91+
logger: Logger,
92+
depth = 0,
93+
budget: { remaining: number } = { remaining: MAX_HTML_CHARACTERS },
94+
): ForestServerActionFormLayoutElement[] {
95+
return layout.map(element => {
96+
if (element?.component === 'htmlBlock') {
97+
const { content } = element;
98+
99+
if (typeof content !== 'string') {
100+
return { ...element, content: sanitize(content, logger) ?? '' };
101+
}
102+
103+
if (content.length > budget.remaining) {
104+
logger(
105+
'Warn',
106+
'Action layout html truncated: total layout html longer than the sanitizable size',
107+
{
108+
characters: content.length,
109+
remaining: budget.remaining,
110+
limit: MAX_HTML_CHARACTERS,
111+
},
112+
);
113+
}
114+
115+
const bounded = content.slice(0, budget.remaining);
116+
117+
budget.remaining -= bounded.length;
118+
119+
return { ...element, content: sanitize(bounded, logger) ?? '' };
120+
}
121+
122+
if (element?.component === 'page' && Array.isArray(element.elements)) {
123+
if (depth >= MAX_LAYOUT_DEPTH) {
124+
logger('Warn', 'Action layout elements dropped: nested deeper than the sanitizable depth', {
125+
limit: MAX_LAYOUT_DEPTH,
126+
});
127+
128+
return { ...element, elements: [] };
129+
}
130+
131+
return {
132+
...element,
133+
elements: sanitizeActionLayout(element.elements, logger, depth + 1, budget),
134+
};
135+
}
136+
137+
return element;
138+
});
139+
}

packages/agent-bff/src/openapi/openapi-document.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,8 @@ const ROUTES: RouteDefinition[] = [
268268
summary: 'Load the form of a custom action',
269269
request: ActionRequestSchema,
270270
response: z.unknown(),
271-
responseDescription: 'The action form fields',
271+
responseDescription:
272+
'The action form fields; htmlBlock layout content is sanitized server-side against an allowlist before relaying',
272273
params: ['collection', 'action'],
273274
bodyRequired: true,
274275
},
@@ -278,7 +279,8 @@ const ROUTES: RouteDefinition[] = [
278279
summary: 'Execute a custom action',
279280
request: ActionRequestSchema,
280281
response: z.unknown(),
281-
responseDescription: 'The normalized action result',
282+
responseDescription:
283+
'The normalized action result; a success result html field is sanitized server-side against an allowlist before relaying',
282284
params: ['collection', 'action'],
283285
bodyRequired: true,
284286
executeResults: true,

packages/agent-bff/src/openapi/unfolded-paths.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,8 @@ function registerActionOperations(deps: Deps, plan: CollectionPlan, namer: Namer
573573
description: `Loads the form of the custom action. ${identity} An unknown submitted field is skipped here, not rejected.`,
574574
request,
575575
response: {},
576-
responseDescription: 'The action form fields',
576+
responseDescription:
577+
'The action form fields; htmlBlock layout content is sanitized server-side against an allowlist before relaying',
577578
bodyRequired: true,
578579
});
579580

@@ -585,7 +586,8 @@ function registerActionOperations(deps: Deps, plan: CollectionPlan, namer: Namer
585586
description: `Executes the custom action. ${identity} A submitted field the loaded form does not carry is rejected with 400.`,
586587
request,
587588
response: {},
588-
responseDescription: 'The normalized action result',
589+
responseDescription:
590+
'The normalized action result; a success result html field is sanitized server-side against an allowlist before relaying',
589591
bodyRequired: true,
590592
executeResults: true,
591593
});

0 commit comments

Comments
 (0)