Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/azure-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 22 additions & 2 deletions docs/AZURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,26 @@
- It uses your Azure CLI extensions: install one with `az extension add --name <extension>`.
- 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.

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.
20 changes: 20 additions & 0 deletions src/lib/azure/child-env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
[],
]);
}
});
15 changes: 14 additions & 1 deletion src/lib/azure/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAzureConfig, isLocalHost } from "./config";
import { getAzureConfig, isEmulatorName, isLocalHost } from "./config";

const HOME = "/home/u";
const deps = {
Expand Down Expand Up @@ -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) => {
Expand Down
25 changes: 15 additions & 10 deletions src/lib/azure/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
189 changes: 189 additions & 0 deletions src/lib/azure/egress-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
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, one that closes at once, and one that
// counts the connections it gets.
let echo: net.Server;
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<number>((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.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]);
return net.connect({ 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<string>((resolve) => socket.once("data", (d) => resolve(String(d))));
socket.write("ping");
expect(await echoed).toBe("ping");
socket.destroy();
}
(await connectThrough(`[::1]:${echoPort}`)).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("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");
socket.resetAndDestroy();
await new Promise((resolve) => setTimeout(resolve, 100));
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;
});
Loading
Loading