Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/internal-host-caller.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@executor-js/sdk": patch
"@executor-js/api": patch
---

Requests to an internal host (a service binding) now carry the acting account in `x-executor-account-id`, so the bound Worker can authorize per caller. Any value the request already carried is replaced, and requests to other hosts never get it.
1 change: 1 addition & 0 deletions packages/core/api/src/server/scoped-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ export const makeScopedExecutor = <
allowLocalNetwork: config.allowLocalNetwork,
requireTls: config.requireTls,
internalHosts: config.internalHosts,
internalCaller: accountId,
};
const httpClientLayer = makeHostedHttpClientLayer(hostedHttpOptions);
const hostedFetch = makeHostedFetch(hostedHttpOptions);
Expand Down
30 changes: 30 additions & 0 deletions packages/core/sdk/src/hosted-http-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,36 @@ describe("internal host routing", () => {
expect(external.urls).toEqual([]);
});

it("tells the binding who the caller is, replacing any value the request carried", async () => {
const seen: Array<string | null> = [];
const binding = {
fetch: async (request: Request) => {
seen.push(request.headers.get("x-executor-account-id"));
return new Response("ok");
},
};
const external = externalStub();
const withCaller = makeHostedFetch({
fetch: external.fetch,
resolveHostname: publicResolver,
internalHosts: { "tools.internal": binding },
internalCaller: "ana@example.com",
});
const withoutCaller = makeHostedFetch({
fetch: external.fetch,
resolveHostname: publicResolver,
internalHosts: { "tools.internal": binding },
});
const spoofed = { headers: { "x-executor-account-id": "root@example.com" } };

await withCaller("https://tools.internal/mcp/websearch", spoofed);
await withoutCaller("https://tools.internal/mcp/websearch", spoofed);
await withCaller("https://api.example/openapi.json", spoofed);

expect(seen).toEqual(["ana@example.com", null]);
expect(external.urls).toEqual(["https://api.example/openapi.json"]);
});

it("hands the binding a manual-redirect request and returns its redirect untouched", async () => {
const seen: string[] = [];
const binding = {
Expand Down
18 changes: 17 additions & 1 deletion packages/core/sdk/src/hosted-http-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export interface HostedHttpClientOptions {
* one. Empty or absent leaves routing off.
*/
readonly internalHosts?: HostedInternalHosts;
/**
* The acting account, sent to internal hosts only as `x-executor-account-id`
* so the bound Worker can authorize per caller. The binding is the trust
* boundary: any value the request already carried is replaced, and absent
* means the header is removed.
*/
readonly internalCaller?: string;
readonly allowLocalNetwork?: boolean;
/** Require HTTPS, except private addresses explicitly allowed for local development. */
readonly requireTls?: boolean;
Expand Down Expand Up @@ -297,6 +304,9 @@ const guardFetch = (
return await underlying(current, { ...currentInit, redirect: "manual" });
}) as typeof globalThis.fetch;

/** Carries `HostedHttpClientOptions.internalCaller` to an internal host. */
export const INTERNAL_CALLER_HEADER = "x-executor-account-id";

// The router sits OUTSIDE the guard: a configured internal host goes straight to
// its binding, everything else takes the guarded path unchanged.
const routeInternalHosts = (
Expand All @@ -311,7 +321,13 @@ const routeInternalHosts = (
const request = new Request(input, { ...init, redirect: "manual" });
const hostname = normalizeHostname(new URL(request.url).hostname);
const target = Object.hasOwn(hosts, hostname) ? hosts[hostname] : undefined;
if (target) return await target.fetch(request);
if (target) {
request.headers.delete(INTERNAL_CALLER_HEADER);
if (options.internalCaller) {
request.headers.set(INTERNAL_CALLER_HEADER, options.internalCaller);
}
return await target.fetch(request);
}
if (isInternalHostname(hostname)) {
return await Effect.runPromise(
Effect.fail(
Expand Down
Loading