fix(agent-bff): sanitize action result html before relaying - #1859
Conversation
4 new issues
|
|
Coverage Impact This PR will not change total coverage. Modified Files with Diff Coverage (5)
🛟 Help
|
267e8bf to
9a63eca
Compare
nbouliol
left a comment
There was a problem hiding this comment.
The /form route still relays layout verbatim, and htmlBlock.content (action-form-mapper.ts:56, via agent-action-client.ts:58) is the same untrusted agent HTML - same vector, still open. Sanitize it here too, or a follow-up ticket?
|
Fixed, in this PR rather than a follow-up: same trust boundary, same untrusted agent string, and the fix is one call.
Two other things on this push:
On the qlty comment ( |
|
Two more pushes, both from things this PR introduced rather than from the ticket. Explicit sanitize options. The library defaults drop Size cap. Before this PR the agent's html was relayed without being parsed at all; sanitizing hands the agent a CPU cost. Parsing is super-linear in nesting depth — measured on 2.17.5, The drop is logged rather than silent, which is why Also: a malformed agent layout (non-array
|
nbouliol
left a comment
There was a problem hiding this comment.
Round-2 pass. The three round-1 findings are addressed: htmlBlock content is sanitized (recursion included), a fully stripped html now relays as no html, and the options are explicit. The third is only half closed though - see the allowedTags comment. Five findings on the new code below.
| ]; | ||
|
|
||
| const OPTIONS: sanitizeHtml.IOptions = { | ||
| allowedAttributes: { ...sanitizeHtml.defaults.allowedAttributes, '*': ['style'] }, |
There was a problem hiding this comment.
allowedTags is still the library default, so img is dropped and class/id stripped - an existing htmlBlock logo or Forest class regresses here. Allow img and class explicitly?
| } | ||
|
|
||
| if (element?.component === 'page' && Array.isArray(element.elements)) { | ||
| if (depth >= MAX_LAYOUT_DEPTH) { |
There was a problem hiding this comment.
DynamicLayoutElementPage.elements is DynamicFormElement, which excludes Page (datasource-customizer/src/decorators/actions/types/fields.ts:277), so a page cannot nest a page - is this cap guarding a reachable shape?
There was a problem hiding this comment.
Still open - a page cannot nest a page, so this cap guards an unreachable shape. Drop it, or is there a path I am missing?
There was a problem hiding this comment.
The customizer type is right — a typed Node form cannot nest Page. This helper does not see that type. It walks ForestServerActionFormLayoutElement from the agent's hook JSON, and that union is recursive (page.elements includes page). Toolkit ActionLayoutElement is the same. ensureFormIsCorrect only forbids mixing pages at the root; parseLayout / buildLayoutSchema will serialize a nested page if one shows up. Rails / Python / a custom getForm never go through the customizer types. Dropping the cap makes a deep page chain a RangeError on /form, which is why it is here.
nbouliol
left a comment
There was a problem hiding this comment.
Sanitizes all three surfaces at the boundary, and the style policy now names the URL carriers instead of banning parentheses - verified on 2.17.5 that rgb()/calc()/var() survive while URL(, url (, \75 rl(, image-set( and -moz-binding( do not. Boundary tests are exact-valued. The only thing left is the layout depth cap guarding an unreachable shape, which is dead code rather than a defect.
## @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) ### Bug Fixes * **agent-bff:** sanitize action result html before relaying ([#1859](#1859)) ([695e160](695e160))
Combine isEnumFieldType validation with sanitizeActionLayout from #1859. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep typed ActionFormResponse/ActionResult schemas and add sanitize descriptions from #1859. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep typed ActionFormResponse/ActionResult schemas and add sanitize descriptions from #1859.
Combine isEnumFieldType validation with sanitizeActionLayout from #1859.

What
The BFF sanitizes the agent's HTML at its own boundary, on the three surfaces that relayed it verbatim:
html,action_errordetails.html,htmlBlockcontentin the/formlayout, including inside apage.One helper, three call sites. The
/formand/execute200 descriptions say so, in both the generic and the unfolded OpenAPI document.fixes PRD-1095
Why
Rendering the old verbatim html through
innerHTMLexecuted arbitrary script in any consumer. A contract note alone leaves that live for whoever does not read it, and PRD-1097 owns the response typing anyway. So we sanitize at the boundary and no consumer has to know.The pin is exact on purpose. 2.17.6+ resolves htmlparser2
^12, which is ESM-only with no CJS build, and Jest 29 cannot run it. Below 2.17.2 the zero-padded numeric reference fix (ajavascript:URL detection bypass) is missing. 2.17.5 is the only version with both.The policy
Default tag allowlist — rich text (p, b, strong, a, tables, lists) survives;
script,svg,iframe,img,on*attributes andjavascript:URLs do not.On top of it,
styleis allowed on any tag but filtered: ~38 presentational properties (color, background, font, padding, margin, border, width, text-align…) with values matching/^[^;{}()]*$/. No parentheses means nourl(...)and noexpression(...);position,top,leftandz-indexare not on the list, so styled output cannot overlay the host page.The default allowlist alone was not viable: it drops
styleentirely, and this repo's own demo (datasource-demo-fintech/src/customizations/kyc_cases.ts) builds its whole result html out of inline styles.Size cap
Parsing is super-linear in nesting depth, and the agent's html was previously relayed without being parsed at all — so sanitizing introduces a CPU cost the agent controls. Measured on 2.17.5,
<div>nested N deep:An action interpolating a record field into its html — the exact pattern behind this ticket — would let a user write that field and block the event loop. So html over 256 KB is dropped rather than sanitized, and the drop is logged
Warnwith the length. At the cap the parse costs ~120 ms.The layout traversal is bounded the same way: past 10 nested
pagelevels the innerelementsare dropped to[]and logged, so a deep layout cannot blow the stack. Dropping rather than relaying the subtree is deliberate — an unrecursed subtree would ship unsanitizedhtmlBlockcontent.messageis not sanitized, on either surface. It is plain text by contract; sanitizing it would mangle a legitimate<or&.Contract changes a consumer can see
html: null(it sent""before), and the error omitsdetailsrather than relaying{ html: "" }.null/ no details, as before.<img>is dropped, element and all. Nothing in the repo needs it and allowing it buys a tracking-pixel surface for no demonstrated use.Scope and safety
ActionExecuteSuccessBody.htmlwas alreadystring | nullanddetailswas already optional.layout, a null element, apagewhoseelementsis not an array) passes through instead of throwing. The sanitizer walks agent-controlled JSON, so it does not trust its own types.sanitize-html@2.17.5and its transitive deps, nothing re-resolved.Warn), and a sanitizer throw (Error, dead at the pinned version but the execute path runs after the action committed, so a 500 there means a retry and a double execution).How to test
cd packages/agent-bff && yarn jest test/action test/openapi<p>ok</p><img src=x onerror=alert(1)>— it comes back as<p>ok</p>. Run the fintech demo's KYC Approved action — the green banner and the table keep their styling.Full package suite: 1367 green.
Known limitation
Agent html size stays unbounded, as before. The exact pin stops Dependabot until Jest has an ESM story.
Definition of Done
General
Security