|
1 | | -// Anthropic Cloudflare AI Gateway 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. |
| 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 | 5 | // |
6 | 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"; |
| 7 | +// constructor options), this test stubs `globalThis.fetch` to COUNT calls. |
| 8 | +// Behavior only `buildGuardedModelFetch` can produce. |
13 | 9 | import { afterEach, describe, expect, it, vi } from "vitest"; |
14 | 10 | import type { Context, Model } from "../types.js"; |
15 | 11 |
|
@@ -63,126 +59,3 @@ describe("Anthropic Cloudflare guard-specific SSRF blocking proof", () => { |
63 | 59 | expect(globalFetchCalled).toBe(0); |
64 | 60 | }); |
65 | 61 | }); |
66 | | - |
67 | | -describe("Anthropic Cloudflare guard real wire loopback proof (http.createServer)", () => { |
68 | | - it("streams Anthropic SSE end-to-end through buildGuardedModelFetch against a real loopback server", async () => { |
69 | | - const recorded: Array<{ |
70 | | - method: string; |
71 | | - url: string; |
72 | | - bodyBytes: number; |
73 | | - authorization: string; |
74 | | - contentType: string; |
75 | | - }> = []; |
76 | | - const sseEvents: Array<Record<string, unknown>> = [ |
77 | | - { |
78 | | - type: "message_start", |
79 | | - message: { |
80 | | - id: "msg_loopback", |
81 | | - type: "message", |
82 | | - role: "assistant", |
83 | | - model: "claude-sonnet-4-6", |
84 | | - content: [], |
85 | | - stop_reason: null, |
86 | | - usage: { input_tokens: 9, output_tokens: 1 }, |
87 | | - }, |
88 | | - }, |
89 | | - { |
90 | | - type: "content_block_start", |
91 | | - index: 0, |
92 | | - content_block: { type: "text", text: "" }, |
93 | | - }, |
94 | | - { |
95 | | - type: "content_block_delta", |
96 | | - index: 0, |
97 | | - delta: { type: "text_delta", text: "Hello from local Cloudflare Anthropic loopback." }, |
98 | | - }, |
99 | | - { |
100 | | - type: "content_block_stop", |
101 | | - index: 0, |
102 | | - }, |
103 | | - { |
104 | | - type: "message_delta", |
105 | | - delta: { stop_reason: "end_turn" }, |
106 | | - usage: { output_tokens: 7 }, |
107 | | - }, |
108 | | - { |
109 | | - type: "message_stop", |
110 | | - }, |
111 | | - ]; |
112 | | - const sseBody = sseEvents |
113 | | - .map((e) => `event: ${String(e["type"])}\ndata: ${JSON.stringify(e)}\n\n`) |
114 | | - .join(""); |
115 | | - |
116 | | - const server = createServer((req: IncomingMessage, res: ServerResponse) => { |
117 | | - const chunks: Buffer[] = []; |
118 | | - req.on("data", (c: Buffer) => chunks.push(c)); |
119 | | - req.on("end", () => { |
120 | | - const body = Buffer.concat(chunks); |
121 | | - recorded.push({ |
122 | | - method: req.method ?? "?", |
123 | | - url: req.url ?? "?", |
124 | | - bodyBytes: body.byteLength, |
125 | | - authorization: (req.headers.authorization as string) ?? "<absent>", |
126 | | - contentType: (req.headers["content-type"] as string) ?? "<absent>", |
127 | | - }); |
128 | | - res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8" }); |
129 | | - res.end(sseBody); |
130 | | - }); |
131 | | - }); |
132 | | - |
133 | | - await new Promise<void>((resolve) => { |
134 | | - server.listen(0, "127.0.0.1", () => resolve()); |
135 | | - }); |
136 | | - const port = (server.address() as AddressInfo).port; |
137 | | - |
138 | | - try { |
139 | | - const { attachModelProviderRequestTransport } = |
140 | | - await import("../../agents/provider-request-config.js"); |
141 | | - const loopbackModel = attachModelProviderRequestTransport( |
142 | | - { |
143 | | - ...CLOUDFLARE_ANTHROPIC_MODEL, |
144 | | - baseUrl: `http://127.0.0.1:${port}`, |
145 | | - }, |
146 | | - { allowPrivateNetwork: true }, |
147 | | - ); |
148 | | - |
149 | | - const { streamAnthropic } = await import("./anthropic.js"); |
150 | | - const stream = streamAnthropic(loopbackModel, context, { |
151 | | - apiKey: "sk-ant-loopback-proof", |
152 | | - }); |
153 | | - |
154 | | - let textBytes = 0; |
155 | | - for await (const event of stream) { |
156 | | - if (event.type === "text_delta") { |
157 | | - textBytes += event.delta.length; |
158 | | - } |
159 | | - } |
160 | | - const result = await stream.result(); |
161 | | - |
162 | | - const hit = recorded[0]; |
163 | | - expect(recorded.length).toBeGreaterThanOrEqual(1); |
164 | | - expect(hit.method).toBe("POST"); |
165 | | - // Anthropic SDK appends "/v1/messages" to baseUrl, so with |
166 | | - // baseUrl "http://127.0.0.1:PORT" the wire URL is "/v1/messages". |
167 | | - expect(hit.url).toBe("/v1/messages"); |
168 | | - expect(hit.bodyBytes).toBeGreaterThan(0); |
169 | | - expect(hit.contentType).toBe("application/json"); |
170 | | - |
171 | | - expect(textBytes).toBeGreaterThan(0); |
172 | | - expect(result.stopReason).toBe("stop"); |
173 | | - expect(result.errorMessage).toBeUndefined(); |
174 | | - |
175 | | - console.log( |
176 | | - `[layer3 loopback proof] server_hits=${recorded.length} ` + |
177 | | - `request_url=${hit.url} request_bytes=${hit.bodyBytes} ` + |
178 | | - `content_type=${hit.contentType} ` + |
179 | | - `text_bytes=${textBytes} ` + |
180 | | - `stop_reason=${result.stopReason}`, |
181 | | - ); |
182 | | - } finally { |
183 | | - await new Promise<void>((resolve) => { |
184 | | - server.close(() => resolve()); |
185 | | - }); |
186 | | - } |
187 | | - }); |
188 | | -}); |
0 commit comments