Skip to content

Commit da36bab

Browse files
authored
Check where an agent address redirects to, not just where it starts (#38)
* Check where an agent address redirects to, not just where it starts `checkAgentEndpoint` decides whether this deployment is willing to talk to an address, and then the request was handed to a fetch that follows redirects. The address that was checked and the address that was dialled were therefore only the same address while nobody redirected. A registrable agent at `https://agent.example.com/ag-ui` answering `307 Location: http://169.254.169.254/latest/meta-data/` put the server on its own cloud metadata endpoint, which the check refuses under every configuration. Both places that dial an agent are affected, and the second is the worse one. The connection test runs once at registration; the runtime dials the stored endpoint on every single run, carrying whatever auth header the registration supplied, so a redirect added after approval is an ongoing exposure rather than a one-off. `createAgentFetch` applies the check to each hop. Redirects are followed rather than refused, because a deployment that puts its agent behind one has done nothing wrong and `http` to `https` is the ordinary case; each destination goes through `checkAgentEndpoint` first, so following one can only reach somewhere registering it directly would have reached. Three hops, then it gives up. Method and body are carried across hops. A browser turns a redirected POST into a GET, and doing that here would only ever produce a confusing "that is not an AG-UI endpoint" from an agent that is one. The stall guard already accepted an inner fetch, so the two compose: a deployment with a timeout configured gets the watch and the redirect check rather than whichever was wired last. * Stop a redirect carrying the credentials on to the next host A hop that leaves the host the request was authorised for now arrives with nothing that proves who we are. The customer's key was given to us for their host, and the signed run assertion is this deployment's own capability: whatever holds it can call back as that Bot, for that person, and it rides in the body, so dropping headers alone would leave the more valuable of the two travelling. Only the two protocol headers survive the hop, and the run is taken out of the body. Once dropped they stay dropped, so a chain that wanders off and comes back does not collect them again. A scheme upgrade on the same host is not a different party and keeps both, which is the shape a deployment behind a redirect actually has; the downgrade is treated as one. The stored address is checked before it is dialled, not only the hops after it. A row written before this guard existed is dialled on every run, and that was the one address a check reading only Location headers never looked at. `EndpointRedirectError` is now `EndpointNotAllowedError`, because it answers for the stored address as well as the hops. * Put a refused agent dial on the audit trail, and say what changed in the changelog A refused hop threw and nothing else happened. The person whose run failed found out immediately and the deployment found out nothing, which is the wrong way round for this particular failure: a registration is one person at one moment, but a stored agent that has quietly begun redirecting somewhere it should not is a fact about an endpoint, happening on every run, with nobody watching. It reads as an agent being flaky until somebody can count it. `createAgentFetch` now reports refusals to its caller and `index.ts` turns that into an `agent.dial_refused` row naming the address and the reason. The callback rather than an audit store keeps `endpoint.ts` deciding and nothing else, which is the same reason it reuses the navigation target check instead of growing a second one. Reporting cannot take a refusal down with it. A reporter that throws is swallowed and a row that cannot be written is logged, because the request is already refused by the time either runs and the alternative is trading a lost record for a dialled request. All four refusal paths report: the stored address, a redirect destination, a body that cannot be stripped for a cross-host hop, and the redirect cap. The last is not a trust decision and is counted anyway, since an endpoint that loops is another thing only the trail can show is happening repeatedly. Three tests, and the negative one earns its place: removing the report turns two red, and reporting on a permitted hop turns the third red, so neither direction is vacuous.
1 parent 140919b commit da36bab

9 files changed

Lines changed: 787 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,28 @@ host port.
5555

5656
Nothing changes for a default deployment. `scripts/start.sh` already reached it on `localhost`.
5757

58+
### An agent's address is checked where it ends up, not only where it starts
59+
60+
`checkAgentEndpoint` decides whether this deployment will dial an address, and the request was then
61+
handed to a fetch that followed redirects. The checked address and the dialled address were the same
62+
address only while nobody redirected. An agent answering `307 Location: http://169.254.169.254/` put
63+
the server on its own cloud metadata endpoint, on every run rather than once.
64+
65+
Every hop is now checked before it is followed, capped at three. Redirects are still followed,
66+
because a deployment that puts its agent behind one has done nothing wrong, and each destination has
67+
to be somewhere registering it directly would have been allowed to reach. The stored address is
68+
checked before it is dialled too, which is the one address a check reading only `Location` headers
69+
never looked at.
70+
71+
A hop that leaves the host the request was authorised for arrives with nothing that proves who we
72+
are. The customer's key was given to us for their host, and this deployment's signed run assertion
73+
names the Bot and the person and can spend their grants, so both stop at that boundary and do not
74+
come back if the chain returns. A scheme upgrade to the same host and port keeps them.
75+
76+
Refusals are now on the audit trail as `agent.dial_refused`, with the address and the reason. A
77+
refused run already told the person what happened; nothing told the deployment, and an agent that
78+
has quietly started redirecting somewhere it should not is worth being able to count.
79+
5880
## 0.0.4
5981

6082
### A click citing a ref this deployment cannot resolve is refused

server/src/agents/connection-test.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { checkAgentEndpoint } from "./endpoint";
1+
import {
2+
checkAgentEndpoint,
3+
createAgentFetch,
4+
EndpointNotAllowedError,
5+
} from "./endpoint";
26

37
/**
48
* Ask an endpoint whether it is really an agent before it is stored.
@@ -97,7 +101,14 @@ export async function testAgentConnection(
97101
});
98102
if (!verdict.allowed) return { ok: false, reason: verdict.reason };
99103

100-
const doFetch = options.fetchImpl ?? fetch;
104+
// Wrapped rather than called directly, so the address the request finally lands on is checked too.
105+
// Checking only what the person typed leaves the redirect as the way around it.
106+
const doFetch = createAgentFetch({
107+
...(options.allowPrivateHosts !== undefined
108+
? { allowPrivateHosts: options.allowPrivateHosts }
109+
: {}),
110+
...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
111+
});
101112
let response: Response;
102113
try {
103114
response = await doFetch(verdict.url, {
@@ -111,6 +122,11 @@ export async function testAgentConnection(
111122
signal: AbortSignal.timeout(options.timeoutMs ?? TEST_TIMEOUT_MS),
112123
});
113124
} catch (error) {
125+
// An address this deployment will not dial is a specific thing that happened, and the person
126+
// registering can act on it: it names the address, or the hop their address sent us to.
127+
if (error instanceof EndpointNotAllowedError) {
128+
return { ok: false, reason: error.message };
129+
}
114130
const timedOut = error instanceof Error && error.name === "TimeoutError";
115131
return {
116132
ok: false,

server/src/agents/endpoint.ts

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,220 @@ export function checkAgentEndpoint(
5555

5656
return { allowed: true, url: verdict.url };
5757
}
58+
59+
/**
60+
* How many redirects an agent is allowedbefore we stop believing it has somewhere to be.
61+
*
62+
* Three, which covers the ordinary shapes (`http` to `https`, a host rename, a trailing-slash
63+
* canonicalisation) and stops a chain that has no end.
64+
*/
65+
const MAX_REDIRECTS = 3;
66+
67+
/** An address this deployment will not dial, named so the person registering sees which hop. */
68+
export class EndpointNotAllowedError extends Error {
69+
constructor(message: string) {
70+
super(message);
71+
this.name = "EndpointNotAllowedError";
72+
}
73+
}
74+
75+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
76+
77+
/**
78+
* The headers an AG-UI POST needs to be an AG-UI POST. Everything else on one of these requests is
79+
* the registered agent's own configuration, which is to say its key.
80+
*/
81+
const PROTOCOL_HEADERS = new Set(["content-type", "accept"]);
82+
83+
/**
84+
* Whether a hop stays inside the authorisation the request was carrying credentials for.
85+
*
86+
* Host and port must match, because a different one is a different party however similar the name.
87+
* A scheme upgrade is the exception in the permissive direction: `http` to `https` on the same host
88+
* is the ordinary shape of a deployment behind a redirect, and the credential ends up somewhere
89+
* strictly better protected than where it started. The downgrade is not the same trade and is
90+
* treated as a different party.
91+
*/
92+
function sameCredentialScope(from: string, to: string): boolean {
93+
const a = new URL(from);
94+
const b = new URL(to);
95+
if (a.hostname !== b.hostname || a.port !== b.port) return false;
96+
return (
97+
a.protocol === b.protocol ||
98+
(a.protocol === "http:" && b.protocol === "https:")
99+
);
100+
}
101+
102+
/** The request with everything that proves who we are taken out of it. */
103+
function withoutCredentials(init: RequestInit | undefined): RequestInit {
104+
const kept = new Headers();
105+
for (const [name, value] of new Headers(init?.headers)) {
106+
if (PROTOCOL_HEADERS.has(name.toLowerCase())) kept.set(name, value);
107+
}
108+
return { ...init, headers: kept, ...strippedBody(init?.body) };
109+
}
110+
111+
/**
112+
* The body with this deployment's own signed run taken out of it.
113+
*
114+
* The run assertion is a bearer capability: it names the Bot and the person, and whatever holds it
115+
* can call back and spend that person's grants. Stripping the headers and forwarding the body would
116+
* leave the more valuable of the two credentials travelling.
117+
*
118+
* A body this cannot read is not forwarded at all. A stream or a form is not a shape this deployment
119+
* sends here, so the choice is between refusing an unreachable case and forwarding something
120+
* unexamined to a host the request was not authorised for, and only one of those fails safely.
121+
*/
122+
function strippedBody(body: BodyInit | null | undefined): { body?: BodyInit } {
123+
if (body === null || body === undefined) return {};
124+
if (typeof body !== "string") {
125+
throw new EndpointNotAllowedError(
126+
"That address redirected to another host, and this deployment will not forward the run to it.",
127+
);
128+
}
129+
130+
let parsed: unknown;
131+
try {
132+
parsed = JSON.parse(body);
133+
} catch {
134+
// Not ours to sanitise and not ours to leak. The same reasoning as the non-string case.
135+
throw new EndpointNotAllowedError(
136+
"That address redirected to another host, and this deployment will not forward the run to it.",
137+
);
138+
}
139+
if (parsed === null || typeof parsed !== "object") return { body };
140+
141+
const run = parsed as { forwardedProps?: Record<string, unknown> };
142+
if (!run.forwardedProps || typeof run.forwardedProps !== "object") {
143+
return { body };
144+
}
145+
const { openbotRun: _dropped, ...rest } = run.forwardedProps;
146+
return { body: JSON.stringify({ ...run, forwardedProps: rest }) };
147+
}
148+
149+
/**
150+
* `fetch`, with the endpoint check applied to every hop rather than only the address a person typed.
151+
*
152+
* Checking the URL once and then handing it to a fetch that follows redirects is a check with a hole
153+
* in it: `https://agent.example.com/ag-ui` passes, answers `307`, and the request lands wherever the
154+
* `Location` header says, which is how a registrable agent becomes a way to read the deployment's own
155+
* cloud metadata. The address that gets dialled is the one that must be allowed, and a redirect makes
156+
* those two different addresses.
157+
*
158+
* The stored address is checked too, not only the hops after it. A row written before this guard
159+
* existed, or under a rule that has since changed, is dialled on every run, and that is the one
160+
* address a check that only reads `Location` headers never looks at.
161+
*
162+
* Redirects are followed rather than refused, because a deployment that puts its agent behind one has
163+
* done nothing wrong and `http` to `https` is the common case. Each destination goes through
164+
* {@link checkAgentEndpoint} first, so following one can only ever reach somewhere registering it
165+
* directly would have been allowed to reach.
166+
*
167+
* A hop that leaves the host the request was authorised for arrives with nothing that proves who we
168+
* are: the customer's key is theirs and was given to us for their host, and the run assertion is
169+
* this deployment's own capability. Once dropped they stay dropped, so a chain that wanders off and
170+
* comes back does not collect them again.
171+
*
172+
* The method and body are carried across every hop. A browser turns a redirected `POST` into a `GET`;
173+
* doing that here would only ever produce a confusing "that is not an AG-UI endpoint" from an agent
174+
* that is one, because AG-UI is a POST protocol and this is a server talking to an API, not a person
175+
* following a link.
176+
*/
177+
export function createAgentFetch(
178+
options: {
179+
allowPrivateHosts?: boolean;
180+
fetchImpl?: typeof fetch;
181+
/**
182+
* Told about every address this refused to dial, and why.
183+
*
184+
* A refusal is the one thing on this path an operator cannot otherwise learn. The person who
185+
* registered the agent finds out immediately, because their run fails and says why; the
186+
* deployment finds out nothing, and a stored agent that has quietly begun redirecting to the
187+
* metadata address is precisely the event worth being able to count.
188+
*
189+
* A callback rather than an audit store, so this module keeps deciding and nothing else. It is
190+
* the same reason `checkAgentEndpoint` reuses the navigation target check instead of growing a
191+
* second one: a file that decides is testable without the machinery that records.
192+
*
193+
* Reporting must never be able to stop a refusal, so a throwing reporter is swallowed. The
194+
* refusal is the security property and the row is the record of it; losing the record is bad and
195+
* turning it into a dialled request would be worse.
196+
*/
197+
onRefusal?: (refusal: { address: string; reason: string }) => void;
198+
} = {},
199+
): (url: string, init?: RequestInit) => Promise<Response> {
200+
const doFetch = options.fetchImpl ?? fetch;
201+
const refuse = (address: string, reason: string) => {
202+
try {
203+
options.onRefusal?.({ address, reason });
204+
} catch {
205+
// See above: a reporter that throws must not become a request that succeeds.
206+
}
207+
return new EndpointNotAllowedError(reason);
208+
};
209+
const check = (address: string) =>
210+
checkAgentEndpoint(address, {
211+
...(options.allowPrivateHosts !== undefined
212+
? { allowPrivateHosts: options.allowPrivateHosts }
213+
: {}),
214+
});
215+
216+
return async function guardedFetch(url: string, init?: RequestInit) {
217+
const stored = check(url);
218+
if (!stored.allowed) {
219+
throw refuse(
220+
url,
221+
`This deployment will not dial ${url}: ${stored.reason.charAt(0).toLowerCase()}${stored.reason.slice(1)}`,
222+
);
223+
}
224+
225+
const origin = stored.url;
226+
let target = stored.url;
227+
let carried = init;
228+
229+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
230+
// `manual` is what makes this a check rather than a comment: the caller sees the redirect, and
231+
// the underlying fetch cannot quietly follow one on its own.
232+
const response = await doFetch(target, {
233+
...carried,
234+
redirect: "manual",
235+
});
236+
if (!REDIRECT_STATUSES.has(response.status)) return response;
237+
238+
const location = response.headers.get("location");
239+
// A redirect status with nowhere to go is just an answer. Whatever it means, it is the
240+
// agent's own reply and not a hop.
241+
if (!location) return response;
242+
243+
const next = new URL(location, target).toString();
244+
const verdict = check(next);
245+
if (!verdict.allowed) {
246+
throw refuse(
247+
next,
248+
`That address redirected to ${next}, and ${verdict.reason.charAt(0).toLowerCase()}${verdict.reason.slice(1)}`,
249+
);
250+
}
251+
if (!sameCredentialScope(origin, verdict.url)) {
252+
// A body this cannot strip refuses the hop rather than forwarding it, and that refusal is
253+
// worth the same row as any other: it means a run was carrying something unreadable to a
254+
// host it was not authorised for.
255+
try {
256+
carried = withoutCredentials(carried);
257+
} catch (error) {
258+
throw refuse(
259+
verdict.url,
260+
error instanceof Error ? error.message : String(error),
261+
);
262+
}
263+
}
264+
target = verdict.url;
265+
}
266+
267+
// Counted with the rest. An agent that loops is not a trust decision, but it is an endpoint
268+
// failing in a way only the trail can show is happening repeatedly.
269+
throw refuse(
270+
target,
271+
`That address redirected more than ${MAX_REDIRECTS} times without arriving anywhere.`,
272+
);
273+
};
274+
}

server/src/audit.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ export const auditEventTypes = [
5151
*/
5252
"channel.routed",
5353
"agent.invoked",
54+
/**
55+
* An address this deployment declined to dial for a Bot, and why.
56+
*
57+
* The stored endpoint is re-checked on the way out of every run, and so is each address it
58+
* redirects to. When one of those is refused the run fails and the person sees why, which is the
59+
* whole of what anybody learns without this row.
60+
*
61+
* That is the wrong shape for the thing worth knowing. A registration is one person at one moment;
62+
* a stored agent quietly beginning to redirect somewhere it should not is a fact about an endpoint,
63+
* happening on every run, with nobody watching. It reads as an agent being flaky until somebody can
64+
* count it. The row names the address and the reason, so a reader can tell an agent that moved from
65+
* one aimed at the metadata endpoint.
66+
*/
67+
"agent.dial_refused",
5468
/**
5569
* A Bot's stream stopped producing anything and the turn was ended for it.
5670
*

0 commit comments

Comments
 (0)