Skip to content

Commit f84bcdb

Browse files
fix(anthropic): wire buildGuardedModelFetch into the Cloudflare createClient branch (#98003)
* fix(anthropic): guard Cloudflare Anthropic fetches Wire the Cloudflare AI Gateway Anthropic SDK client through buildGuardedModelFetch so this path gets the same SSRF and transport policy as sibling providers. Keep the behavior proof focused on the regression: a Cloudflare Anthropic request pointed at a link-local address must fail before the SDK reaches global fetch. * fix(anthropic): add loopback proof test to cloudflare fetch wiring * test(anthropic-cloudflare): trim proof to SSRF-block only, remove synthetic loopback server
1 parent 6b869a6 commit f84bcdb

3 files changed

Lines changed: 65 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Anthropic Cloudflare AI Gateway constructor guard-specific proof: the SSRF
2+
// guard blocks a private-IP request before the SDK's default global fetch is
3+
// ever reached. This proves `buildGuardedModelFetch` is actively wired into
4+
// the Cloudflare branch, not just present in constructor options.
5+
//
6+
// Unlike anthropic.test.ts (which mocks the Anthropic SDK to verify
7+
// constructor options), this test stubs `globalThis.fetch` to COUNT calls.
8+
// Behavior only `buildGuardedModelFetch` can produce.
9+
import { afterEach, describe, expect, it, vi } from "vitest";
10+
import type { Context, Model } from "../types.js";
11+
12+
const CLOUDFLARE_ANTHROPIC_MODEL = {
13+
id: "claude-sonnet-4-6",
14+
name: "Claude Sonnet 4.6",
15+
api: "anthropic-messages",
16+
provider: "cloudflare-ai-gateway",
17+
baseUrl: "https://gateway.ai.cloudflare.com/v1/account/gateway/anthropic/v1/messages",
18+
reasoning: true,
19+
input: ["text"],
20+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
21+
contextWindow: 200_000,
22+
maxTokens: 4096,
23+
} satisfies Model<"anthropic-messages">;
24+
25+
const context = {
26+
messages: [{ role: "user", content: "hi", timestamp: 1 }],
27+
} satisfies Context;
28+
29+
describe("Anthropic Cloudflare guard-specific SSRF blocking proof", () => {
30+
afterEach(() => {
31+
vi.unstubAllGlobals();
32+
});
33+
34+
it("blocks a private-IP request before globalThis.fetch is called (guard-specific behavior)", async () => {
35+
let globalFetchCalled = 0;
36+
vi.stubGlobal(
37+
"fetch",
38+
vi.fn(async () => {
39+
globalFetchCalled++;
40+
return new Response(null, { status: 500 });
41+
}),
42+
);
43+
44+
// Override the model baseUrl to a private link-local IP that the guard blocks.
45+
const blockedModel = {
46+
...CLOUDFLARE_ANTHROPIC_MODEL,
47+
baseUrl: "http://169.254.169.254/v1",
48+
} satisfies Model<"anthropic-messages">;
49+
50+
const { streamAnthropic } = await import("./anthropic.js");
51+
const stream = streamAnthropic(blockedModel, context, { apiKey: "sk-ant-test" });
52+
const result = await stream.result();
53+
54+
expect(result.stopReason).toBe("error");
55+
expect(result.errorMessage).toBeTruthy();
56+
57+
// Guard-specific: SSRF blocked the private-IP request before
58+
// globalThis.fetch was ever called.
59+
expect(globalFetchCalled).toBe(0);
60+
});
61+
});

src/llm/providers/anthropic.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,14 @@ describe("Anthropic provider", () => {
7575
apiKey?: string | null;
7676
authToken?: string | null;
7777
defaultHeaders?: Record<string, string | null>;
78+
fetch?: unknown;
7879
};
7980

8081
expect(config.apiKey).toBe("sk-ant-provider");
8182
expect(config.authToken).toBeNull();
8283
expect(config.defaultHeaders?.["x-api-key"]).toBeUndefined();
8384
expect(config.defaultHeaders?.["cf-aig-authorization"]).toBe("Bearer gateway-token");
85+
expect(typeof config.fetch).toBe("function");
8486
});
8587

8688
it("uses bearer auth for Microsoft Foundry Anthropic requests", async () => {

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,
@@ -947,6 +948,7 @@ function createClient(
947948
model.headers,
948949
optionsHeaders,
949950
),
951+
fetch: buildGuardedModelFetch(model),
950952
});
951953

952954
return { client, isOAuthToken: false };

0 commit comments

Comments
 (0)