diff --git a/ts/examples/workflow/engine/package.json b/ts/examples/workflow/engine/package.json index 5a762657fc..5f58eb6c13 100644 --- a/ts/examples/workflow/engine/package.json +++ b/ts/examples/workflow/engine/package.json @@ -30,6 +30,7 @@ "dependencies": { "@github/copilot-sdk": "1.0.9", "@typeagent/aiclient": "workspace:*", + "@typeagent/common-utils": "workspace:*", "ajv": "^8.17.1", "debug": "^4.3.4", "workflow-model": "workspace:*" diff --git a/ts/examples/workflow/engine/src/builtinTaskSchemas.ts b/ts/examples/workflow/engine/src/builtinTaskSchemas.ts index 33887ff7f8..99dc1747d6 100644 --- a/ts/examples/workflow/engine/src/builtinTaskSchemas.ts +++ b/ts/examples/workflow/engine/src/builtinTaskSchemas.ts @@ -364,6 +364,11 @@ export const BUILTIN_TASK_SCHEMAS: readonly BuiltinTaskSchema[] = [ description: "Max response body size in bytes (default 10MB). Responses larger than this are truncated.", }, + timeoutMs: { + type: "integer", + description: + "Request timeout in milliseconds (default 30000).", + }, }, }, outputSchema: { diff --git a/ts/examples/workflow/engine/src/builtinTasks.ts b/ts/examples/workflow/engine/src/builtinTasks.ts index 985c61d8d5..19435885c8 100644 --- a/ts/examples/workflow/engine/src/builtinTasks.ts +++ b/ts/examples/workflow/engine/src/builtinTasks.ts @@ -13,6 +13,8 @@ import { execFile } from "node:child_process"; import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { request as httpRequest, type IncomingHttpHeaders } from "node:http"; +import { request as httpsRequest } from "node:https"; import { dirname, resolve, relative, isAbsolute } from "node:path"; import { homedir, tmpdir } from "node:os"; import { @@ -20,10 +22,16 @@ import { TaskDefinition, ConcreteTaskDefinition, GenericTaskDefinition, + TaskConstraints, TaskTypeParameter, } from "workflow-model"; import { isGenericBuiltinSchema } from "./builtinTaskSchemas.js"; import { openai } from "@typeagent/aiclient"; +import { + createPinnedLookup, + PrivateNetworkTargetError, + resolvePublicIpAddress, +} from "@typeagent/common-utils/network"; import type { CustomAgentConfig } from "@github/copilot-sdk"; import { BUILTIN_TASK_SCHEMAS } from "./builtinTaskSchemas.js"; import { invokeCopilotAgent } from "./copilotClientHost.js"; @@ -498,11 +506,202 @@ export const stringSplit: TaskDefinition< }, }; +const MAX_HTTP_REDIRECTS = 20; +const DEFAULT_HTTP_TIMEOUT_MS = 30_000; +const SENSITIVE_REDIRECT_HEADERS = new Set([ + "authorization", + "cookie", + "cookie2", + "proxy-authorization", +]); + +type HttpGetResponse = { + status: number; + headers: IncomingHttpHeaders; + body: Buffer; +}; + +function getUrlHostname(url: URL): string { + const hostname = url.hostname.toLowerCase(); + const unbracketed = + hostname.startsWith("[") && hostname.endsWith("]") + ? hostname.slice(1, -1) + : hostname; + return unbracketed.endsWith(".") ? unbracketed.slice(0, -1) : unbracketed; +} + +function hostMatches(hostname: string, constraint: string): boolean { + const normalized = constraint.toLowerCase().replace(/\.$/, ""); + return hostname === normalized || hostname.endsWith(`.${normalized}`); +} + +function validateHttpTarget(url: URL, constraints?: TaskConstraints): void { + if ( + (url.protocol !== "http:" && url.protocol !== "https:") || + url.username || + url.password + ) { + throw new Error( + `URL "${url.toString()}" must use credential-free HTTP or HTTPS`, + ); + } + + const hostname = getUrlHostname(url); + const blocked = constraints?.blockedHosts; + if (blocked?.some((host) => hostMatches(hostname, host))) { + throw new Error(`Host "${hostname}" is blocked by caller constraints`); + } + + const allowed = constraints?.allowedHosts; + if (allowed && !allowed.some((host) => hostMatches(hostname, host))) { + throw new Error(`Host "${hostname}" is not in the allowed hosts list`); + } +} + +export async function readHttpResponseBody( + body: AsyncIterable, + maxBytes: number, +): Promise { + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of body) { + const buffer = Buffer.from(chunk); + totalBytes += buffer.byteLength; + if (totalBytes > maxBytes) { + throw new Error( + `Response exceeded maximum size of ${maxBytes} bytes`, + ); + } + chunks.push(buffer); + } + return Buffer.concat(chunks); +} + +async function requestHttpGetOnce( + url: URL, + headers: Record, + maxBytes: number, + signal: AbortSignal, + constraints?: TaskConstraints, + timeoutMs: number = DEFAULT_HTTP_TIMEOUT_MS, +): Promise { + validateHttpTarget(url, constraints); + + let resolved: Awaited>; + try { + resolved = await resolvePublicIpAddress(url.hostname); + } catch (error) { + if (error instanceof PrivateNetworkTargetError) { + throw new Error( + `URL "${url.toString()}" references a private or reserved address`, + ); + } + throw error; + } + + const request = url.protocol === "https:" ? httpsRequest : httpRequest; + return new Promise((resolveRequest, rejectRequest) => { + const req = request( + url, + { + headers, + ...createPinnedLookup(resolved), + signal, + timeout: timeoutMs, + }, + (response) => { + void (async () => { + try { + const body = await readHttpResponseBody( + response, + maxBytes, + ); + resolveRequest({ + status: response.statusCode ?? 0, + headers: response.headers, + body, + }); + } catch (error) { + req.destroy(); + rejectRequest(error); + } + })(); + }, + ); + req.on("timeout", () => { + req.destroy( + new Error( + `HTTP request to "${url.toString()}" timed out after ${timeoutMs}ms`, + ), + ); + }); + req.on("error", rejectRequest); + req.end(); + }); +} + +function removeSensitiveRedirectHeaders( + headers: Record, +): Record { + return Object.fromEntries( + Object.entries(headers).filter( + ([name]) => !SENSITIVE_REDIRECT_HEADERS.has(name.toLowerCase()), + ), + ); +} + +async function fetchHttpGet( + inputUrl: string, + headers: Record | undefined, + maxBytes: number, + signal: AbortSignal, + constraints?: TaskConstraints, + timeoutMs?: number, +): Promise<{ body: string; status: number }> { + let currentUrl = new URL(inputUrl); + let currentHeaders = headers ? { ...headers } : {}; + + for (let redirect = 0; redirect <= MAX_HTTP_REDIRECTS; redirect++) { + const response = await requestHttpGetOnce( + currentUrl, + currentHeaders, + maxBytes, + signal, + constraints, + timeoutMs, + ); + const location = response.headers.location; + if ( + response.status >= 300 && + response.status < 400 && + location !== undefined + ) { + if (redirect === MAX_HTTP_REDIRECTS) { + throw new Error("HTTP request exceeded the redirect limit"); + } + const nextUrl = new URL(location, currentUrl); + if (nextUrl.origin !== currentUrl.origin) { + currentHeaders = removeSensitiveRedirectHeaders(currentHeaders); + } + currentUrl = nextUrl; + continue; + } + + return { + body: response.body.toString("utf8"), + status: response.status, + }; + } + + throw new Error("HTTP request failed"); +} + export const httpGet: TaskDefinition< { url: string; headers?: Record; maxResponseBytes?: number; + timeoutMs?: number; }, { body: string; status: number } > = { @@ -511,102 +710,16 @@ export const httpGet: TaskDefinition< async execute(input, ctx) { const maxBytes = input.maxResponseBytes ?? 10 * 1024 * 1024; // 10MB try { - // Validate URL to prevent SSRF against internal services. - const parsed = new URL(input.url); - const hostname = parsed.hostname?.toLowerCase(); - if ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "::1" || - hostname === "0.0.0.0" || - hostname === "169.254.169.254" || - hostname === "[::1]" || - hostname?.startsWith("10.") || - hostname?.startsWith("192.168.") || - /^172\.(1[6-9]|2\d|3[01])\./.test(hostname ?? "") || - hostname?.endsWith(".internal") || - parsed.protocol === "file:" - ) { - return { - kind: "fail", - error: { - message: `URL "${input.url}" references a private or reserved address`, - }, - }; - } - - // Enforce caller-supplied blockedHosts - const blocked = ctx.constraints?.blockedHosts; - if ( - blocked && - hostname && - blocked.some( - (h) => - hostname === h.toLowerCase() || - hostname.endsWith("." + h.toLowerCase()), - ) - ) { - return { - kind: "fail", - error: { - message: `Host "${hostname}" is blocked by caller constraints`, - }, - }; - } - - // Enforce caller-supplied allowedHosts (allowlist overrides) - const allowedHosts = ctx.constraints?.allowedHosts; - if (allowedHosts && hostname) { - const isAllowed = allowedHosts.some( - (h) => - hostname === h.toLowerCase() || - hostname.endsWith("." + h.toLowerCase()), - ); - if (!isAllowed) { - return { - kind: "fail", - error: { - message: `Host "${hostname}" is not in the allowed hosts list`, - }, - }; - } - } - - const resp = await fetch(input.url, { - ...(input.headers ? { headers: input.headers } : {}), - signal: ctx.signal, - }); - // Stream the body to enforce the size limit. - const reader = resp.body?.getReader(); - if (!reader) { - const body = await resp.text(); - return { kind: "ok", output: { body, status: resp.status } }; - } - const chunks: Uint8Array[] = []; - let totalBytes = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - totalBytes += value.byteLength; - if (totalBytes > maxBytes) { - reader.cancel(); - return { - kind: "fail", - error: { - message: `Response exceeded maximum size of ${maxBytes} bytes`, - }, - }; - } - chunks.push(value); - } - const decoder = new TextDecoder(); - const body = - chunks - .map((c) => decoder.decode(c, { stream: true })) - .join("") + decoder.decode(); return { kind: "ok", - output: { body, status: resp.status }, + output: await fetchHttpGet( + input.url, + input.headers, + maxBytes, + ctx.signal, + ctx.constraints, + input.timeoutMs, + ), }; } catch (err) { return { diff --git a/ts/examples/workflow/engine/test/engine.spec.ts b/ts/examples/workflow/engine/test/engine.spec.ts index 5ff3b6a3a5..6906f30029 100644 --- a/ts/examples/workflow/engine/test/engine.spec.ts +++ b/ts/examples/workflow/engine/test/engine.spec.ts @@ -51,6 +51,7 @@ import { errorFail, httpGet, } from "../src/index.js"; +import { readHttpResponseBody } from "../src/builtinTasks.js"; import { readFileSync, writeFileSync, @@ -2161,6 +2162,7 @@ describe("WorkflowEngine (IR v1)", () => { url: { type: "string" }, headers: { type: "object" }, maxResponseBytes: { type: "integer" }, + timeoutMs: { type: "integer" }, }, }, outputSchema: { @@ -2265,6 +2267,7 @@ describe("WorkflowEngine (IR v1)", () => { url: { type: "string" }, headers: { type: "object" }, maxResponseBytes: { type: "integer" }, + timeoutMs: { type: "integer" }, }, }, outputSchema: { @@ -4868,54 +4871,50 @@ describe("WorkflowEngine (IR v1)", () => { policy: allowAllPolicy, }); expect(result.success).toBe(false); - expect(result.error?.message).toContain("private or reserved"); + expect(result.error?.message).toContain( + "credential-free HTTP or HTTPS", + ); + }); + + it.each([ + ["decimal IPv4", "http://2852039166/metadata"], + ["hexadecimal IPv4", "http://0xa9fea9fe/metadata"], + ["octal IPv4", "http://0251.0376.0251.0376/metadata"], + ["loopback subnet", "http://127.0.0.2/metadata"], + ["carrier-grade NAT", "http://100.64.0.1/metadata"], + ["IPv4-mapped IPv6", "http://[::ffff:169.254.169.254]/metadata"], + ["Teredo", "http://[2001:0000::1]/metadata"], + ["6to4", "http://[2002:a9fe:a9fe::]/metadata"], + ["NAT64", "http://[64:ff9b::a9fe:a9fe]/metadata"], + ["unique-local IPv6", "http://[fc00::1]/metadata"], + ])("rejects %s targets", async (_name, url) => { + const result = await httpGet.execute( + { url }, + { + runId: "test", + nodeId: "test", + scopePath: [], + signal: new AbortController().signal, + outputSchema: { type: "object" }, + }, + ); + + expect(result.kind).toBe("fail"); + if (result.kind === "fail") { + expect(result.error.message).toContain("private or reserved"); + } }); }); describe("http.get response size enforcement", () => { - it("returns fail when response exceeds maxResponseBytes", async () => { - // Use a mock that simulates a streaming response. - // The real http.get code streams and checks byte count. - // We test via the builtinTasks import directly. - // Mock a global fetch that returns a large streaming body - const originalFetch = globalThis.fetch; - const largeBody = "X".repeat(200); - const encoder = new TextEncoder(); - const encoded = encoder.encode(largeBody); - - globalThis.fetch = (async () => ({ - status: 200, - body: new ReadableStream({ - start(controller) { - controller.enqueue(encoded); - controller.close(); - }, - }), - })) as any; - - try { - const result = await httpGet.execute( - { - url: "https://example.com/large", - maxResponseBytes: 50, - }, - { - runId: "test", - nodeId: "test", - scopePath: [], - signal: new AbortController().signal, - outputSchema: { type: "object" }, - } as any, - ); - expect(result.kind).toBe("fail"); - if (result.kind === "fail") { - expect(result.error.message).toContain( - "exceeded maximum size", - ); - } - } finally { - globalThis.fetch = originalFetch; + it("rejects a response that exceeds maxResponseBytes", async () => { + async function* responseBody() { + yield new TextEncoder().encode("X".repeat(200)); } + + await expect( + readHttpResponseBody(responseBody(), 50), + ).rejects.toThrow("exceeded maximum size"); }); }); diff --git a/ts/packages/copilot-plugin/package.json b/ts/packages/copilot-plugin/package.json index 6b49cb3140..ed2efd88b1 100644 --- a/ts/packages/copilot-plugin/package.json +++ b/ts/packages/copilot-plugin/package.json @@ -33,6 +33,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@typeagent/agent-sdk": "workspace:*", "@typeagent/agent-server-client": "workspace:*", + "@typeagent/common-utils": "workspace:*", "@typeagent/copilot-macros": "workspace:*", "@typeagent/dispatcher-types": "workspace:*", "html-to-text": "^9.0.5", diff --git a/ts/packages/copilot-plugin/src/mcp/workspaceServer.ts b/ts/packages/copilot-plugin/src/mcp/workspaceServer.ts index cbf1b979af..4929f0272d 100644 --- a/ts/packages/copilot-plugin/src/mcp/workspaceServer.ts +++ b/ts/packages/copilot-plugin/src/mcp/workspaceServer.ts @@ -4,13 +4,15 @@ import { createReadStream, promises as fs } from "node:fs"; import { request as httpRequest } from "node:http"; import { request as httpsRequest } from "node:https"; -import { isIP } from "node:net"; -import { lookup } from "node:dns/promises"; import path from "node:path"; import { createInterface } from "node:readline"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { + createPinnedLookup, + resolvePublicIpAddress, +} from "@typeagent/common-utils/network"; import { convert } from "html-to-text"; import { z } from "zod"; import { getMode } from "../shared/plugin-config.js"; @@ -508,98 +510,6 @@ export async function grepWorkspace(args: { return { matches, truncated }; } -const nonPublicIpv4Ranges: readonly (readonly [number, number])[] = [ - [0x00000000, 0x00ffffff], - [0x0a000000, 0x0affffff], - [0x64400000, 0x647fffff], - [0x7f000000, 0x7fffffff], - [0xa9fe0000, 0xa9feffff], - [0xac100000, 0xac1fffff], - [0xc0000000, 0xc00000ff], - [0xc0000200, 0xc00002ff], - [0xc0a80000, 0xc0a8ffff], - [0xc6120000, 0xc613ffff], - [0xc6336400, 0xc63364ff], - [0xcb007100, 0xcb0071ff], - [0xe0000000, 0xffffffff], -]; - -function parseIpv4(address: string): number | undefined { - const parts = address.split(".").map(Number); - if ( - parts.length !== 4 || - parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255) - ) { - return undefined; - } - return ( - parts[0] * 0x1000000 + parts[1] * 0x10000 + parts[2] * 0x100 + parts[3] - ); -} - -function isPublicIpv4(address: string): boolean { - const value = parseIpv4(address); - return ( - value !== undefined && - !nonPublicIpv4Ranges.some( - ([start, end]) => value >= start && value <= end, - ) - ); -} - -function isPublicIp(address: string): boolean { - const family = isIP(address); - if (family === 4) { - return isPublicIpv4(address); - } - if (family !== 6) { - return false; - } - const normalized = address.toLowerCase(); - if (normalized.startsWith("::ffff:")) { - return isPublicIpv4(normalized.slice("::ffff:".length)); - } - if ( - normalized.startsWith("2001:db8:") || - normalized.startsWith("2001:0db8:") - ) { - return false; - } - return normalized.startsWith("2") || normalized.startsWith("3"); -} - -async function resolvePublicAddress( - hostname: string, -): Promise<{ address: string; family: 4 | 6 }> { - const normalized = hostname.toLowerCase(); - if (normalized === "localhost" || normalized.endsWith(".local")) { - throw new Error(`Private network target is not allowed: ${hostname}`); - } - - const family = isIP(hostname); - if (family !== 0) { - if (!isPublicIp(hostname)) { - throw new Error( - `Private network target is not allowed: ${hostname}`, - ); - } - return { address: hostname, family: family as 4 | 6 }; - } - - const addresses = await lookup(hostname, { all: true, verbatim: true }); - const publicAddress = addresses.find((entry) => isPublicIp(entry.address)); - if ( - !publicAddress || - addresses.some((entry) => !isPublicIp(entry.address)) - ) { - throw new Error(`Private network target is not allowed: ${hostname}`); - } - return { - address: publicAddress.address, - family: publicAddress.family as 4 | 6, - }; -} - async function fetchOnce( url: URL, maxBytes: number, @@ -608,7 +518,7 @@ async function fetchOnce( headers: Record; body: Buffer; }> { - const resolved = await resolvePublicAddress(url.hostname); + const resolved = await resolvePublicIpAddress(url.hostname); const request = url.protocol === "https:" ? httpsRequest : httpRequest; return new Promise((resolve, reject) => { @@ -620,9 +530,7 @@ async function fetchOnce( "accept-encoding": "identity", "user-agent": "TypeAgent-Workspace-Tools/1.0", }, - lookup: (_hostname, _options, callback) => { - callback(null, resolved.address, resolved.family); - }, + ...createPinnedLookup(resolved), timeout: FETCH_TIMEOUT_MS, }, (response) => { diff --git a/ts/packages/copilot-plugin/test/workspaceServer.spec.ts b/ts/packages/copilot-plugin/test/workspaceServer.spec.ts index 98fbff461b..ff28584d63 100644 --- a/ts/packages/copilot-plugin/test/workspaceServer.spec.ts +++ b/ts/packages/copilot-plugin/test/workspaceServer.spec.ts @@ -144,4 +144,21 @@ describe("workspace MCP primitives", () => { fetchWorkspaceUrl({ url: "http://localhost/private" }), ).rejects.toThrow("Private network target"); }); + + it.each([ + ["decimal IPv4", "http://2852039166/private"], + ["hexadecimal IPv4", "http://0xa9fea9fe/private"], + ["octal IPv4", "http://0251.0376.0251.0376/private"], + ["loopback subnet", "http://127.0.0.2/private"], + ["carrier-grade NAT", "http://100.64.0.1/private"], + ["IPv4-mapped IPv6", "http://[::ffff:169.254.169.254]/private"], + ["Teredo", "http://[2001:0000::1]/private"], + ["6to4", "http://[2002:a9fe:a9fe::]/private"], + ["NAT64", "http://[64:ff9b::a9fe:a9fe]/private"], + ["unique-local IPv6", "http://[fc00::1]/private"], + ])("blocks %s fetch targets", async (_name, url) => { + await expect(fetchWorkspaceUrl({ url })).rejects.toThrow( + "Private network target", + ); + }); }); diff --git a/ts/packages/utils/commonUtils/package.json b/ts/packages/utils/commonUtils/package.json index fff3371277..14d8a08c61 100644 --- a/ts/packages/utils/commonUtils/package.json +++ b/ts/packages/utils/commonUtils/package.json @@ -15,7 +15,8 @@ ".": { "node": "./dist/indexNode.js", "default": "./dist/indexBrowser.js" - } + }, + "./network": "./dist/network.js" }, "files": [ "dist", diff --git a/ts/packages/utils/commonUtils/src/network.ts b/ts/packages/utils/commonUtils/src/network.ts new file mode 100644 index 0000000000..f0c1367b43 --- /dev/null +++ b/ts/packages/utils/commonUtils/src/network.ts @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { lookup } from "node:dns/promises"; +import { BlockList, isIP } from "node:net"; + +const nonPublicIpv4 = new BlockList(); +for (const [network, prefix] of [ + ["0.0.0.0", 8], + ["10.0.0.0", 8], + ["100.64.0.0", 10], + ["127.0.0.0", 8], + ["169.254.0.0", 16], + ["172.16.0.0", 12], + ["192.0.0.0", 24], + ["192.0.2.0", 24], + ["192.88.99.0", 24], + ["192.168.0.0", 16], + ["198.18.0.0", 15], + ["198.51.100.0", 24], + ["203.0.113.0", 24], + ["224.0.0.0", 4], + ["240.0.0.0", 4], +] as const) { + nonPublicIpv4.addSubnet(network, prefix, "ipv4"); +} + +const globalIpv6 = new BlockList(); +globalIpv6.addSubnet("2000::", 3, "ipv6"); + +const publicIpv6Exceptions = new BlockList(); +for (const [network, prefix] of [ + ["2001:1::1", 128], + ["2001:1::2", 128], + ["2001:1::3", 128], + ["2001:3::", 32], + ["2001:4:112::", 48], + ["2001:20::", 28], + ["2001:30::", 28], +] as const) { + publicIpv6Exceptions.addSubnet(network, prefix, "ipv6"); +} + +const nonPublicIpv6 = new BlockList(); +for (const [network, prefix] of [ + ["::", 128], + ["::1", 128], + ["::ffff:0:0", 96], + ["64:ff9b::", 96], + ["64:ff9b:1::", 48], + ["100::", 64], + ["100:0:0:1::", 64], + ["2001::", 23], + ["2001:db8::", 32], + ["2002::", 16], + ["3fff::", 20], + ["5f00::", 16], + ["fc00::", 7], + ["fe80::", 10], + ["ff00::", 8], +] as const) { + nonPublicIpv6.addSubnet(network, prefix, "ipv6"); +} + +export type PublicIpAddress = { + address: string; + family: 4 | 6; +}; + +export type HostnameResolver = ( + hostname: string, +) => Promise; + +export function createPinnedLookup(address: PublicIpAddress) { + return { + family: address.family, + lookup: ( + _hostname: string, + _options: unknown, + callback: ( + error: NodeJS.ErrnoException | null, + resolvedAddress: string, + family: number, + ) => void, + ) => callback(null, address.address, address.family), + }; +} + +export class PrivateNetworkTargetError extends Error { + constructor(hostname: string) { + super(`Private network target is not allowed: ${hostname}`); + this.name = "PrivateNetworkTargetError"; + } +} + +function normalizeHostname(hostname: string): string { + let normalized = hostname.toLowerCase(); + if (normalized.startsWith("[") && normalized.endsWith("]")) { + normalized = normalized.slice(1, -1); + } + while (normalized.endsWith(".")) { + normalized = normalized.slice(0, -1); + } + return normalized; +} + +export function isPublicIpAddress(address: string): boolean { + const normalized = normalizeHostname(address); + const family = isIP(normalized); + if (family === 4) { + return !nonPublicIpv4.check(normalized, "ipv4"); + } + if (family === 6) { + return ( + globalIpv6.check(normalized, "ipv6") && + (publicIpv6Exceptions.check(normalized, "ipv6") || + !nonPublicIpv6.check(normalized, "ipv6")) + ); + } + return false; +} + +const defaultHostnameResolver: HostnameResolver = (hostname) => + lookup(hostname, { all: true, verbatim: true }); + +export async function resolvePublicIpAddress( + hostname: string, + resolveHostname: HostnameResolver = defaultHostnameResolver, +): Promise { + const normalized = normalizeHostname(hostname); + if ( + normalized === "localhost" || + normalized.endsWith(".localhost") || + normalized.endsWith(".local") || + normalized.endsWith(".internal") || + normalized.endsWith(".home.arpa") + ) { + throw new PrivateNetworkTargetError(hostname); + } + + const family = isIP(normalized); + if (family !== 0) { + if (!isPublicIpAddress(normalized)) { + throw new PrivateNetworkTargetError(hostname); + } + return { address: normalized, family: family as 4 | 6 }; + } + + const lookupResults = await resolveHostname(normalized); + const addresses = lookupResults.map(({ address }) => { + const normalizedAddress = normalizeHostname(address); + return { + address: normalizedAddress, + family: isIP(normalizedAddress), + }; + }); + if ( + addresses.length === 0 || + addresses.some( + ({ address, family: addressFamily }) => + addressFamily === 0 || !isPublicIpAddress(address), + ) + ) { + throw new PrivateNetworkTargetError(hostname); + } + + const selected = addresses[0]; + return { + address: selected.address, + family: selected.family as 4 | 6, + }; +} diff --git a/ts/packages/utils/commonUtils/test/network.spec.ts b/ts/packages/utils/commonUtils/test/network.spec.ts new file mode 100644 index 0000000000..6b2459bc9c --- /dev/null +++ b/ts/packages/utils/commonUtils/test/network.spec.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + createPinnedLookup, + isPublicIpAddress, + PrivateNetworkTargetError, + resolvePublicIpAddress, + type HostnameResolver, +} from "../src/network.js"; + +describe("public IP validation", () => { + test.each([ + "1.1.1.1", + "8.8.8.8", + "192.31.196.1", + "2001:1::1", + "2001:3::1", + "2001:4:112::1", + "2001:20::1", + "2001:30::1", + "2001:4860:4860::8888", + "2606:4700:4700::1111", + "3000::1", + ])("accepts globally routable address %s", (address) => { + expect(isPublicIpAddress(address)).toBe(true); + }); + + test.each([ + "0.0.0.0", + "10.0.0.1", + "100.64.0.1", + "127.0.0.2", + "169.254.169.254", + "172.31.255.255", + "192.0.0.1", + "192.0.2.1", + "192.88.99.1", + "192.168.1.1", + "198.18.0.1", + "198.51.100.1", + "203.0.113.1", + "224.0.0.1", + "255.255.255.255", + "[::]", + "[::1]", + "::ffff:a9fe:a9fe", + "64:ff9b::a9fe:a9fe", + "64:ff9b:1::a9fe:a9fe", + "100::1", + "100:0:0:1::1", + "2001::1", + "2001:2::1", + "2001:db8::1", + "2002:a9fe:a9fe::", + "3fff::1", + "5f00::1", + "fc00::1", + "fe80::1", + "ff00::1", + ])("rejects non-public address %s", (address) => { + expect(isPublicIpAddress(address)).toBe(false); + }); +}); + +describe("pinned DNS lookup", () => { + test("fixes the address family and returns the validated address", () => { + const pinned = createPinnedLookup({ + address: "192.31.196.1", + family: 4, + }); + + expect(pinned.family).toBe(4); + pinned.lookup("ignored.example", {}, (error, address, family) => { + expect(error).toBeNull(); + expect(address).toBe("192.31.196.1"); + expect(family).toBe(4); + }); + }); +}); + +describe("public hostname resolution", () => { + const resolver = + ( + ...addresses: { address: string; family: number }[] + ): HostnameResolver => + async () => + addresses; + + test("returns a public literal without a DNS lookup", async () => { + let called = false; + const resolveHostname: HostnameResolver = async () => { + called = true; + return []; + }; + + await expect( + resolvePublicIpAddress("[2001:4860:4860::8888]", resolveHostname), + ).resolves.toEqual({ + address: "2001:4860:4860::8888", + family: 6, + }); + expect(called).toBe(false); + }); + + test("rejects a hostname that resolves to a private address", async () => { + await expect( + resolvePublicIpAddress( + "attacker.example", + resolver({ address: "169.254.169.254", family: 4 }), + ), + ).rejects.toBeInstanceOf(PrivateNetworkTargetError); + }); + + test("rejects a hostname with mixed public and private answers", async () => { + await expect( + resolvePublicIpAddress( + "attacker.example", + resolver( + { address: "1.1.1.1", family: 4 }, + { address: "10.0.0.1", family: 4 }, + ), + ), + ).rejects.toThrow("Private network target"); + }); + + test("returns one address only after validating every DNS answer", async () => { + await expect( + resolvePublicIpAddress( + "public.example", + resolver( + { address: "2001:4860:4860::8888", family: 6 }, + { address: "1.1.1.1", family: 4 }, + ), + ), + ).resolves.toEqual({ + address: "2001:4860:4860::8888", + family: 6, + }); + }); + + test.each([ + "localhost", + "service.localhost.", + "printer.local", + "service.internal", + "router.home.arpa", + ])("rejects reserved local hostname %s without DNS", async (hostname) => { + let called = false; + const resolveHostname: HostnameResolver = async () => { + called = true; + return []; + }; + + await expect( + resolvePublicIpAddress(hostname, resolveHostname), + ).rejects.toThrow("Private network target"); + expect(called).toBe(false); + }); +}); diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 1a57a23220..0b8daa2033 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -1127,6 +1127,9 @@ importers: '@typeagent/aiclient': specifier: workspace:* version: link:../../../packages/aiclient + '@typeagent/common-utils': + specifier: workspace:* + version: link:../../../packages/utils/commonUtils ajv: specifier: ^8.17.1 version: 8.18.0 @@ -4669,6 +4672,9 @@ importers: '@typeagent/agent-server-client': specifier: workspace:* version: link:../agentServer/client + '@typeagent/common-utils': + specifier: workspace:* + version: link:../utils/commonUtils '@typeagent/copilot-macros': specifier: workspace:* version: link:../copilot-macros