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