Skip to content

Commit ba54f3e

Browse files
fix(anthropic): wire buildGuardedModelFetch into the OAuth createClient branch
This brings the OAuth Anthropic SDK client to guarded-fetch parity with sibling providers. The change is 2 lines (+1 import, +1 `fetch:` option) plus an inline loopback proof test. This is one of five split PRs (Cloudflare / Copilot / Foundry / OAuth / API-key) that supersede #97868. Each PR is one constructor's blast radius — separate from the others. Land any combination without affecting the rest.
1 parent 54b0958 commit ba54f3e

3 files changed

Lines changed: 230 additions & 0 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
// Anthropic OAuth constructor guard-specific proof: SSRF-blocks a
2+
// private IP before the SDK's default global fetch is ever reached, then
3+
// streams Anthropic SSE end-to-end through buildGuardedModelFetch against
4+
// a real local HTTP server.
5+
//
6+
// Unlike anthropic.test.ts (which mocks the Anthropic SDK to verify
7+
// constructor options), this test does NOT mock the SDK for the SSRF
8+
// block or loopback proof — it stubs globalThis.fetch to COUNT calls and
9+
// proves the guard intercepts the request before the final fetch hop.
10+
// Behavior only buildGuardedModelFetch can produce.
11+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
12+
import type { AddressInfo } from "node:net";
13+
import { afterEach, describe, expect, it, vi } from "vitest";
14+
import type { Context, Model } from "../types.js";
15+
16+
// A private-link-local IP that the SSRF guard blocks.
17+
const SSRF_BLOCKED_OAUTH_MODEL = {
18+
id: "claude-sonnet-4-6",
19+
name: "Claude Sonnet 4.6",
20+
api: "anthropic-messages",
21+
provider: "anthropic",
22+
baseUrl: "https://api.anthropic.com",
23+
reasoning: true,
24+
input: ["text"],
25+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
26+
contextWindow: 200_000,
27+
maxTokens: 4096,
28+
} satisfies Model<"anthropic-messages">;
29+
30+
const context = {
31+
messages: [{ role: "user", content: "hi", timestamp: 1 }],
32+
} satisfies Context;
33+
34+
describe("Anthropic OAuth guard-specific SSRF blocking proof", () => {
35+
afterEach(() => {
36+
vi.unstubAllGlobals();
37+
});
38+
39+
it("blocks a private-IP request before globalThis.fetch is called (guard-specific behavior)", async () => {
40+
let globalFetchCalled = 0;
41+
vi.stubGlobal(
42+
"fetch",
43+
vi.fn(async () => {
44+
globalFetchCalled++;
45+
return new Response(null, { status: 500 });
46+
}),
47+
);
48+
49+
// Override the model baseUrl to a private link-local IP that the guard blocks.
50+
const blockedModel = {
51+
...SSRF_BLOCKED_OAUTH_MODEL,
52+
baseUrl: "http://169.254.169.254/v1",
53+
} satisfies Model<"anthropic-messages">;
54+
55+
const { streamAnthropic } = await import("./anthropic.js");
56+
const stream = streamAnthropic(blockedModel, context, {
57+
apiKey: "sk-ant-oat-test",
58+
});
59+
const result = await stream.result();
60+
61+
expect(result.stopReason).toBe("error");
62+
expect(result.errorMessage).toBeTruthy();
63+
64+
// Guard-specific: SSRF blocked the private-IP request before
65+
// globalThis.fetch was ever called.
66+
expect(globalFetchCalled).toBe(0);
67+
});
68+
});
69+
70+
describe("Anthropic OAuth guard real wire loopback proof (http.createServer)", () => {
71+
it("streams Anthropic SSE end-to-end through buildGuardedModelFetch against a real loopback server", async () => {
72+
const recorded: Array<{
73+
method: string;
74+
url: string;
75+
bodyBytes: number;
76+
authorization: string;
77+
contentType: string;
78+
}> = [];
79+
const sseEvents: Array<Record<string, unknown>> = [
80+
{
81+
type: "message_start",
82+
message: {
83+
id: "msg_loopback",
84+
type: "message",
85+
role: "assistant",
86+
model: "claude-sonnet-4-6",
87+
content: [],
88+
stop_reason: null,
89+
usage: { input_tokens: 9, output_tokens: 1 },
90+
},
91+
},
92+
{
93+
type: "content_block_start",
94+
index: 0,
95+
content_block: { type: "text", text: "" },
96+
},
97+
{
98+
type: "content_block_delta",
99+
index: 0,
100+
delta: { type: "text_delta", text: "Hello from local OAuth Anthropic loopback." },
101+
},
102+
{
103+
type: "content_block_stop",
104+
index: 0,
105+
},
106+
{
107+
type: "message_delta",
108+
delta: { stop_reason: "end_turn" },
109+
usage: { output_tokens: 7 },
110+
},
111+
{
112+
type: "message_stop",
113+
},
114+
];
115+
const sseBody = sseEvents
116+
.map((e) => `event: ${String(e["type"])}\ndata: ${JSON.stringify(e)}\n\n`)
117+
.join("");
118+
119+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
120+
const chunks: Buffer[] = [];
121+
req.on("data", (c: Buffer) => chunks.push(c));
122+
req.on("end", () => {
123+
const body = Buffer.concat(chunks);
124+
recorded.push({
125+
method: req.method ?? "?",
126+
url: req.url ?? "?",
127+
bodyBytes: body.byteLength,
128+
authorization: (req.headers.authorization as string) ?? "<absent>",
129+
contentType: (req.headers["content-type"] as string) ?? "<absent>",
130+
});
131+
res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8" });
132+
res.end(sseBody);
133+
});
134+
});
135+
136+
await new Promise<void>((resolve) => {
137+
server.listen(0, "127.0.0.1", () => resolve());
138+
});
139+
const port = (server.address() as AddressInfo).port;
140+
141+
try {
142+
const { attachModelProviderRequestTransport } =
143+
await import("../../agents/provider-request-config.js");
144+
const loopbackModel = attachModelProviderRequestTransport(
145+
{
146+
...SSRF_BLOCKED_OAUTH_MODEL,
147+
baseUrl: `http://127.0.0.1:${port}`,
148+
},
149+
{ allowPrivateNetwork: true },
150+
);
151+
152+
const { streamAnthropic } = await import("./anthropic.js");
153+
const stream = streamAnthropic(loopbackModel, context, {
154+
apiKey: "sk-ant-oat-loopback-proof",
155+
});
156+
157+
let textBytes = 0;
158+
for await (const event of stream) {
159+
if (event.type === "text_delta") {
160+
textBytes += event.delta.length;
161+
}
162+
}
163+
const result = await stream.result();
164+
165+
const hit = recorded[0];
166+
expect(recorded.length).toBeGreaterThanOrEqual(1);
167+
expect(hit.method).toBe("POST");
168+
// Anthropic SDK appends "/v1/messages" to baseUrl, so with
169+
// baseUrl "http://127.0.0.1:PORT" the wire URL is "/v1/messages".
170+
expect(hit.url).toBe("/v1/messages");
171+
expect(hit.bodyBytes).toBeGreaterThan(0);
172+
// OAuth constructor sets apiKey:null and uses authToken, which the
173+
// SDK sends as `Authorization: Bearer <token>`.
174+
expect(hit.authorization).toBe("Bearer sk-ant-oat-loopback-proof");
175+
expect(hit.contentType).toBe("application/json");
176+
177+
expect(textBytes).toBeGreaterThan(0);
178+
expect(result.stopReason).toBe("stop");
179+
expect(result.errorMessage).toBeUndefined();
180+
181+
console.log(
182+
`[layer3 loopback proof] server_hits=${recorded.length} ` +
183+
`request_url=${hit.url} request_bytes=${hit.bodyBytes} ` +
184+
`content_type=${hit.contentType} ` +
185+
`text_bytes=${textBytes} ` +
186+
`stop_reason=${result.stopReason}`,
187+
);
188+
} finally {
189+
await new Promise<void>((resolve) => {
190+
server.close(() => resolve());
191+
});
192+
}
193+
});
194+
});

src/llm/providers/anthropic.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,40 @@ describe("Anthropic provider", () => {
8383
expect(config.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer gateway-token");
8484
});
8585

86+
it("wires buildGuardedModelFetch into the Anthropic OAuth SDK fetch option", async () => {
87+
const model = makeAnthropicModel({
88+
provider: "anthropic",
89+
baseUrl: "https://api.anthropic.com",
90+
});
91+
const context = {
92+
messages: [{ role: "user", content: "hello", timestamp: 1 }],
93+
} satisfies Context;
94+
95+
// OAuth tokens are detected by the "sk-ant-oat" prefix.
96+
streamAnthropic(model, context, {
97+
apiKey: "sk-ant-oat-test-token",
98+
});
99+
100+
await vi.waitFor(() => expect(anthropicMockState.configs).toHaveLength(1));
101+
const config = anthropicMockState.configs[0] as {
102+
apiKey?: string | null;
103+
authToken?: string | null;
104+
baseURL?: string;
105+
defaultHeaders?: Record<string, string | null>;
106+
fetch?: unknown;
107+
};
108+
109+
expect(config.apiKey).toBeNull();
110+
expect(config.authToken).toBe("sk-ant-oat-test-token");
111+
expect(config.baseURL).toBe("https://api.anthropic.com");
112+
expect(config.defaultHeaders?.["anthropic-beta"]).toContain("claude-code-20250219");
113+
expect(config.defaultHeaders?.["anthropic-beta"]).toContain("oauth-2025-04-20");
114+
// Bounded-read contract: a custom `fetch` is wired through the SDK. The
115+
// cap itself is exercised in `provider-transport-fetch.test.ts`; this
116+
// test only proves the cap is in scope on this code path.
117+
expect(typeof config.fetch).toBe("function");
118+
});
119+
86120
it("uses bearer auth for Microsoft Foundry Anthropic requests", async () => {
87121
const model = makeAnthropicModel({
88122
provider: "microsoft-foundry",

src/llm/providers/anthropic.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
type AnthropicProjectedToolChoice,
1616
type AnthropicToolProjection,
1717
} from "../../agents/anthropic-tool-projection.js";
18+
import { buildGuardedModelFetch } from "../../agents/provider-transport-fetch.js";
1819
import {
1920
splitSystemPromptCacheBoundary,
2021
stripSystemPromptCacheBoundary,
@@ -985,6 +986,7 @@ function createClient(
985986
model.headers,
986987
optionsHeaders,
987988
),
989+
fetch: buildGuardedModelFetch(model),
988990
});
989991

990992
return { client, isOAuthToken: true };

0 commit comments

Comments
 (0)