Lambda MicroVM: Hands-On Egress Credential Injection with iron-proxy
By Fabio Douek
Human-led, AI-assisted. Research and implementation done with AI tools, led, tested and edited by me. How posts are labeled
Jump to section
- Overview
- Architecture
- Why two VPCs
- What one VPC couldn’t do
- The split
- What changes for clients
- What it costs
- How the pieces work
- The VPC egress connector
- Security groups: the fence on the last hop
- VPC peering
- Route 53 Resolver, the private zone and the DNS Firewall override
- The internal Network Load Balancer
- iron-proxy: interception, placeholders, rules
- What is isolated from what
- Where the design stops
- Setup
- Step 1: Credentials to inject
- Step 2: The proxy VPC and iron-proxy
- Step 3: The proxy’s rules
- Step 4: The sandbox VPC
- Step 5: Peering and the security groups
- Step 6: DNS: the private zone and the override
- Step 7: The sandbox image
- Testing
- Test 1: The fence
- Test 2: How a request reaches the proxy, and how injection behaves
- Test 3: Claude Code with a placeholder key
- Test 4: git and gh without a token
- Test 5: Claude Code opens a pull request
- Test 6: AWS without AWS credentials
- Test 7: Trying to get the credentials out
- Test 8: Rotating and revoking
- Test 9: Two MicroVMs, one proxy
- Test 10: Path rules and require
- Test 11: DNS: the override, no recursion, and removing the firewall
- Test 12: Latency
- What iron-proxy does not solve
- Pricing
- Cleanup
- Verdict
- What works
- What doesn’t
- Who should use it
Explain (TLDR) like I am...
Imagine your robot helper wants to buy sweets, but you don't trust it with real money. So you give it pretend coins and a playroom with one door, and that door opens onto a grown-up. Whatever shop the robot looks up in its phone book, the page says "ask the grown-up", who swaps each pretend coin for a real one, but only at shops you said were fine.
There are no windows, and the phone book can't carry secret notes, because every page points to the same grown-up. The grown-up still can't tell your robot from your friend's robot, though.
The arrangement places a controlled intermediary between code the organization does not trust and every external service it uses. The sandboxes sit on a network with no route to the internet, every name they look up resolves to that intermediary, and only it holds the real credentials.
Residual exposure remains. Any sandbox that reaches the proxy can use its credentials, the audit trail cannot attribute a request to one sandbox, data can still go to approved destinations, and the DNS control rests on a few firewall settings, only one of which is locked against change. Treat it as a strong control with documented exceptions.
The condition is credential exposure in sandboxes that run agent code. The intervention moves every credential into a proxy, removes the sandbox's route to the internet, and points every DNS answer at the proxy. In testing, Claude Code, git and the AWS CLI worked with no proxy configuration while the sandbox held only placeholders.
Side effects: anything that isn't HTTP fails slowly, by timeout, the proxy fails open for a secret it cannot fetch, and the DNS protection depends on the firewall's association and rules staying in place. Suitable for single-tenant use as tested.
Notice the relief of handing an agent the keys without handing it the keys, and without asking anyone to remember a proxy setting. There is nothing real in the room to print, and no door out of the room except the one the proxy watches.
The worry does not vanish, it moves. Someone now owns a proxy whose rules are the real policy and a DNS setting everything leans on, and the shared proxy cannot say which sandbox asked for what. That deserves an honest conversation before more than one team relies on it.
Think of the sandbox as a soundproof room with one cable to the desk, and iron-proxy as the sound engineer. Whatever the band calls a channel, it is patched to the desk, and the engineer swaps in the real feed only for the channels on the set list.
The rhythm holds: Claude Code, git and the AWS CLI play at normal tempo without retuning anything. Anything that isn't on a cable just dies in the room, and the engineer still can't see which musician is on which channel.
The pitch is "agents with credentials they never see, and no proxy settings to get wrong." The sandbox holds placeholders, its network leads only to the proxy, and an agent opened a real pull request with nothing secret inside its VM.
The story buyers will probe: it is open-source plumbing you run yourself, about $0.10 an hour of fixed infrastructure, one shared proxy as built, and pre-1.0. The value is control and an audit trail, not a turnkey product.
Overview
Part 1 ended with two gaps. A Lambda MicroVM’s default egress is the open internet, and any code inside it can read the execution role’s credentials. Lambda gives you one control for the first gap: a VPC egress connector, after which your VPC’s routes, security groups and DNS apply. There’s no egress proxy, no domain allowlist and no credential injection. So an agent that needs an Anthropic key, a GitHub token and some AWS access would normally get all three as environment variables, where a prompt-injected printenv can take them.
This post, part 2 of 3, builds the missing layer with iron-proxy, an open-source egress proxy from Paradigm (Go, Apache-2.0, v0.55.0 released 2026-11-02). It intercepts TLS, denies by default, swaps placeholder tokens for real secrets held outside the sandbox, and re-signs AWS SigV4 requests with its own role. I run it on ECS Fargate behind an internal Network Load Balancer, in a VPC of its own. The MicroVMs get a second VPC with no route to the internet, joined to the first by peering, where a DNS Firewall rule answers every name with the load balancer’s addresses. A sandbox needs no proxy settings: whatever it connects to, it reaches iron-proxy or nothing. Part 3 adds a browser workspace on this same proxy, using an optional control plane that lives here.
My first version used one VPC and an HTTPS_PROXY setting in every sandbox; the next sections explain why it became two, and how each piece works. Then I tested the fence, how injection behaves, Claude Code opening a real pull request, attempts to get the credentials out, rotation and revocation, path rules, what DNS gives away, and what happens when the DNS control is removed. Everything ran on 2026-11-04 in us-east-1, with iron-proxy 0.55.0 pinned by image digest, Claude Code 2.1.317, gh 2.103.3 and the AWS CLI v2 inside the MicroVM.
Architecture
The design has four layers, and each covers something the others can’t:
- The network is the fence. The sandbox VPC has no internet gateway and no NAT gateway, and its only routes lead to the load balancer’s two addresses. The connector’s security group narrows those to TCP 80, 443 and 8080.
- DNS points everything at the proxy. Every name a sandbox looks up comes back as the load balancer’s addresses, so clients reach iron-proxy without knowing it, and no lookup leaves the VPC.
- iron-proxy is the policy. It decides which hosts are reachable and which credential each one gets.
- Placeholders are all the sandbox holds. The proxy’s CA certificate is baked into the image (it’s public); its private key, the real tokens and the AWS role all live on the proxy’s side. (Terraform generates the CA here, so the key is also in its local state: fine for a demo, not for production.)
Why two VPCs
What one VPC couldn’t do
The first version had a single VPC: the MicroVMs’ connector, the NAT gateway and iron-proxy shared it, the connector’s security group was the fence, and each sandbox got HTTPS_PROXY pointing at the load balancer. It worked for host-level policy, and it hit three walls.
HTTPS_PROXYbreaks iron-proxy’s finer rules. WithHTTPS_PROXY, iron-proxy 0.55.0 only sees aCONNECT api.github.com:443tunnel, with an empty path and none of the request’s headers, so path rules andrequire: true(which rejects requests that don’t carry a placeholder) refuse every tunnel (Test 10, issue #222); and clients that ignore the setting, like Node’sfetch, were simply cut off by the fence.- Intercepting names one at a time stopped at AWS. The way around CONNECT is transparent interception: DNS answers
api.github.comwith the proxy’s address, and the client talks TLS to the proxy directly. iron-proxy ships a DNS server for this, but a MicroVM can’t be pointed at it:/etc/resolv.confis a read-only mount, and rewriting it in the run hook killed the MicroVM. So I used a Route 53 private hosted zone per intercepted name. A private zone applies to everything in its VPC, though, and that VPC also ran Fargate and part 3’s services, which call AWS themselves. Any AWS name intercepted for the sandboxes would have been intercepted for them too, so AWS endpoints stayed out, and in that mode the AWS CLI in a sandbox had no way out at all. iron-proxy itself had to resolve its upstreams through1.1.1.1to avoid finding itself. - DNS needed a firewall of its own, shared with the proxy. Security groups don’t filter the VPC’s DNS resolver, and the resolver is recursive: a fenced MicroVM could still look up
<data>.attacker.example, and the resolver would carry that label to the attacker’s name servers. Closing it took a DNS Firewall allowlist of domains. Because the firewall covers the whole VPC, the list had to include everything the proxy’s infrastructure looks up, and it broke Fargate’s image pulls (a Docker Hub name is a CNAME to a CDN) until I tuned it.
The common thread: private zones, Resolver rules and DNS Firewall all apply to a whole VPC, not to a subnet. The sandboxes need DNS that sends every name to the proxy; iron-proxy and the AWS services around it need DNS that tells the truth. The clean way to give them different answers is two VPCs.
The split
| VPC | What's in it | Routes | DNS |
|---|---|---|---|
| proxy, 10.50.0.0/16 | NAT gateway, internal NLB (10.50.10.10, 10.50.11.10), iron-proxy on Fargate | the internet through the NAT; the two connector subnets through the peering | normal Route 53 answers |
| sandbox, 10.51.0.0/16 | the egress connector's two network interfaces, nothing else | local, and the NLB's two /32s through the peering | every name answered with the NLB's addresses |
The sandbox VPC has no route to the internet at all. It has no internet gateway or NAT gateway for a route to point at, and the peering connection can’t stand in for one, because peering isn’t transitive: traffic from the sandbox VPC can’t leave through the proxy VPC’s NAT gateway, whatever the routes say. The security group is no longer the only fence. It narrows the two routes to three ports on one load balancer.
What changes for clients
Nothing to configure. A sandbox gets no HTTPS_PROXY, and whatever it connects to by name, it lands on iron-proxy:
- curl, git, gh, the AWS CLI, Claude Code, Python’s
urlliband Node’sfetchall work unmodified, including the clients that never read proxy settings. - Every request reaches iron-proxy as a direct TLS connection on 443 (or HTTP on 80), so it sees the real method, path and headers, and path rules and
requirework (Test 10). - AWS endpoints are intercepted like any other name, and
aws_authre-signs the calls (Test 6). - The sandbox still needs the placeholders and the proxy’s CA, which its image and run payload provide.
Before rebuilding this post’s stack, I ran the design end to end in a throwaway pair of VPCs: 181 checks, none failed. Two results from that run don’t appear in the tests below. The MicroVM lifecycle didn’t change: start, suspend, resume, idle auto-suspend and terminate behaved as in part 1. And long-lived connections hold: a connection was reused on the same socket after 420 s idle.
What it costs
About the same as one VPC. Peering has no hourly charge, the private zone is $0.50 a month, and DNS Firewall is $0.60 per million queries. The fixed layer stays at about $0.095 an hour; Pricing has the table.
How the pieces work
Each component in the diagram is a standard AWS or proxy building block, and the design only holds if you know what each one does and doesn’t cover. Here they are in the order a request meets them.
The VPC egress connector
Lambda MicroVMs get their networking from network connectors, chosen per MicroVM at run-microvm time and fixed for its life. Inbound and outbound are separate. Lambda provides managed ones (HTTP_INGRESS for the MicroVM’s HTTPS endpoint, SHELL_INGRESS for the shell, INTERNET_EGRESS for outbound internet, which is the default) and lets you create your own for outbound traffic: a VPC egress connector.
A VPC egress connector is a lambda-core resource that points at subnets and security groups in your VPC, here the sandbox VPC. Lambda creates network interfaces in those subnets, using an operator role you hand it that can create and tag them, and every outbound connection from the MicroVM leaves through one of them. This connector has exactly two, one per subnet, shared by every MicroVM that runs on it. They’re Lambda-managed (InterfaceType: lambda), and describe-network-interfaces lists them only with IncludeManagedResources. From there it’s ordinary VPC traffic: your routes, your security groups, your resolver.
The connector replaces the default internet path rather than adding to one; asking for INTERNET_EGRESS and a VPC connector together is rejected (Only one default route egress network connector can be provided). Inbound traffic is untouched: the MicroVM’s endpoint, which part 3 uses for its terminals, still goes through Lambda’s managed ingress with its own tokens, not through your VPC. The connector’s group here has no inbound rules at all, and the endpoint works. Two things never touch the connector: the metadata endpoint, which is routed inside the VM, and the IPv6 address Lambda gives every MicroVM, since the connector is IPv4 only (Where the design stops).
Security groups: the fence on the last hop
A security group is a stateful firewall attached to network interfaces, here the connector’s two. Stateful means replies to allowed connections come back on their own, so the fence only needs egress rules. And a rule can name another security group instead of an IP range; with same-Region peering, that group can be in the other VPC:
- the connector’s group may send TCP 80, 443 and 8080 to anything carrying the NLB’s group;
- the NLB’s group accepts those ports from anything carrying the connector’s group;
- iron-proxy’s own group accepts traffic only from the NLB’s.
Nothing else in the proxy VPC matches: not the NAT gateway, not the iron-proxy task directly, not the proxy VPC’s resolver (Test 1 tries each; there, the routes stop them before the security groups come into play). AWS documents these references across peering, but not specifically for Lambda-managed interfaces. They matched, in this stack and in the end-to-end run before it, and describe-security-group-rules shows the peering connection on each reference.
Security groups have documented blind spots: they don’t filter traffic to the VPC’s own DNS resolver, instance metadata or a few other AWS services. That’s why DNS gets its own control below, and why these MicroVMs run with no execution role.
VPC peering
A peering connection is a private link between two VPCs, here in the same account and Region, accepted automatically. It carries nothing until route tables point at it, and here they point narrowly. The sandbox route table sends only the NLB’s two /32s to it, and the proxy subnets’ route table sends only the two connector subnets back. Neither side has a route to the whole other VPC.
Peering is not transitive. Traffic can’t come in through a peering connection and leave through that VPC’s internet gateway, NAT gateway or another peering. That’s what makes “no route to the internet” hold for the sandbox VPC: even a 0.0.0.0/0 route into the peering wouldn’t reach the proxy VPC’s NAT gateway. The only useful destination across it is the load balancer.
Peering has no hourly charge. Data is free within an Availability Zone and $0.01 per GB in each direction across zones.
Route 53 Resolver, the private zone and the DNS Firewall override
Every VPC has a built-in DNS resolver, Route 53 Resolver, at its base address plus two (10.51.0.2 here) and at 169.254.169.253. A MicroVM on a VPC connector resolves through it: its read-only /etc/resolv.conf lists Lambda’s local stub, 127.0.0.2 and 169.254.53.53, which forwards to the VPC’s resolver. For public names that resolver is recursive, and security groups can’t filter it. That’s what makes DNS a channel out of a fenced sandbox: a lookup of <data>.attacker.example carries the label to the name servers for attacker.example, which the attacker runs.
Three Route 53 features turn the sandbox VPC’s resolver into the proxy’s directory:
- A private hosted zone,
sandbox.internal, visible only in the sandbox VPC. It holds one record:proxy.sandbox.internal, an A record with both NLB addresses. - DNS Firewall filters the queries a VPC sends to its resolver, with rules in priority order. A BLOCK rule answers NODATA or NXDOMAIN, or, with an OVERRIDE response, a record you choose. Here rule 100 allows
proxy.sandbox.internal, and rule 200 matches every other name (*) and answers with a CNAME toproxy.sandbox.internal. The VPC’s firewall is set to fail closed: if it can’t evaluate a query, the query is blocked rather than answered. - Query logging records each query the resolver answers itself (not the ones it serves from cache), with the firewall’s action.
The resolver follows the override’s CNAME itself, into the private zone, so one answer carries the CNAME and both addresses. From a MicroVM:
$ dig api.github.com
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5180
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1
api.github.com. 60 IN CNAME proxy.sandbox.internal.
proxy.sandbox.internal. 60 IN A 10.50.10.10
proxy.sandbox.internal. 60 IN A 10.50.11.10
;; Query time: 10 msec
;; SERVER: 127.0.0.2#53(127.0.0.2) (UDP)
Every name gets that answer, real or not. The other record types tested get only the CNAME, with no data: AAAA, TXT, MX, NS, HTTPS, and reverse lookups of public addresses. Nothing recurses to the internet: AWS documents that a blocked query doesn’t go on to its destination, so a lookup can’t carry data out, and no TXT record can carry data in. Unlike an allowlist of domains, there’s nothing to tune, because the proxy VPC’s infrastructure isn’t behind this firewall. Test 11 checks seven record types and all four resolver addresses the MicroVM can reach, over UDP, plus TCP to two of them (the end-to-end run tried TCP on all four).
One rule I can’t show is needed: the ALLOW for the proxy’s own name never appears in the query log. But by default DNS Firewall inspects every name in a CNAME chain, and the override’s target would otherwise match * too, which is a good reason to keep it.
The internal Network Load Balancer
iron-proxy runs as a Fargate task, and a task gets a new IP address every time it restarts. The DNS record and the security groups need something stable, and the NLB provides it. It is internal (no public address), with one fixed private IP per proxy subnet (10.50.10.10 and 10.50.11.10) and its own security group. Each fixed address sits inside an explicit subnet CIDR reservation, so AWS can’t assign it to another interface, such as a new Fargate task’s, before the NLB takes it.
The NLB passes TCP straight through without touching TLS, on 80 and 443, plus 8080 for the HTTPS_PROXY comparisons. It health-checks iron-proxy on its metrics port and keeps idle connections open for an hour. Every name resolves to both addresses, so a client whose first address fails can try the other, after its connect timeout. One side effect matters later: iron-proxy sees every connection arrive from the NLB’s addresses, never from a MicroVM’s.
iron-proxy: interception, placeholders, rules
Traffic reaches iron-proxy transparently: the client resolves api.github.com to the NLB, connects to port 443 believing it’s GitHub, and starts TLS. iron-proxy reads the intended host from the handshake’s SNI, or from the Host header on port 80, and answers with a certificate for that host, signed by its own CA, which the sandbox trusts because the CA certificate is in its image. That’s TLS interception: the proxy now sees the HTTP request in clear text, runs it through its transforms in order, and opens its own TLS connection to the real host, which it looks up through the proxy VPC’s ordinary resolver. Its own DNS server stays off, and its HTTPS_PROXY listener appears here only in comparisons.
- allowlist: deny by default; only listed hosts (optionally methods and paths) pass. A refused host still completes TLS with a proxy certificate, then gets an HTTP 403.
- secrets: look for a credential’s placeholder (in headers, the query string or the body, as configured) on the hosts that credential is bound to, and replace it with the real value from Secrets Manager, cached for 30 s.
- aws_auth: throw away the client’s AWS signature and re-sign the request with the proxy’s own role, if the service and region are allowed.
Every decision lands in an audit log with the host, path, status, the transform that refused it, and which secret was swapped where. A placeholder is just a fixed, made-up string per credential, like ghp_proxyplaceholder000…. It’s there so clients have something to send where a key goes; it isn’t a secret, and this post prints all of them.
What is isolated from what
| Boundary | Provided by | Stops | Doesn't stop |
|---|---|---|---|
| Compute | one Firecracker MicroVM per sandbox (part 1) | sandboxes sharing a kernel or memory | what a sandbox sends out; the rows below cover that |
| Routes | sandbox VPC with no gateways, peering with two /32 routes | every connection to an address other than the NLB's | IPv6 on Lambda's own interface (it goes nowhere, but the VPC doesn't govern it) |
| Ports | connector and NLB security groups, referenced across the peering | anything but TCP 80, 443 and 8080 to the NLB | DNS to the VPC's resolver, the metadata endpoint |
| DNS | DNS Firewall override and the private zone | lookups reaching the internet, data in names or TXT records | someone with Route 53 Resolver permissions removing the association, or changing its rules so an ALLOW matches first (Test 11 shows the first) |
| Destinations | iron-proxy allowlist | hosts not on the list | sending data to an allowed host, like a gist on GitHub |
| Credentials | placeholders, real values only in the proxy | reading a real key from the VM | using the key through the proxy, from any sandbox that reaches it |
| AWS identity | no execution role; aws_auth signs instead | AWS credentials at the metadata endpoint | calls the proxy's role allows |
| Tenants | a proxy per tenant (not built here) | one tenant using another's credentials | with one shared proxy: nothing; the log can't even say which sandbox acted (Test 9) |
The tests below check the rows from Routes down: what each stops, and what gets past it.
Where the design stops
- The fence covers only MicroVMs on the connector.
run-microvmtakes the egress connector per MicroVM, and a MicroVM run with no egress connector, or an empty list, gets Lambda’s managedINTERNET_EGRESS: the open internet, with no peering, DNS Firewall or proxy in the way. Whatever launches sandboxes has to pass the connector every time, ascommon.run()does. - Non-HTTP fails slowly. git over SSH, SMTP, raw TCP and other ports resolve to the NLB like every name, but the security group allows only TCP 80, 443 and 8080, so their packets are dropped; QUIC, which is UDP, isn’t allowed at all. A client gets no refusal; it waits out its connect timeout. An agent that tries SSH hangs for as long as its tool allows, so point git at HTTPS.
- IPv6 is present but dead. Lambda gives each MicroVM a global IPv6 address in AWS’s range and a default IPv6 route, though neither VPC has IPv6 and the connector is IPv4 only. Every IPv6 connection I tried timed out (
02_fence.py), including to AWS’s own dual-stack endpoints. But it’s an interface the VPC’s routes and security groups don’t govern, so it’s tested rather than assumed. - The DNS control rests on a few Resolver settings, and only one is protected. If the rule group is disassociated from the sandbox VPC, or its rules change so that an ALLOW matches first, the resolver goes back to recursive resolution at once (in Test 11, the first lookup after the disassociation already recursed), and lookups are a channel out again; the routes still block every direct connection (
12_dns_firewall.py). Mutation protection covers only the association: removing it is refused until someone switches protection off with a separate call. The rules, the domain lists and any new rule group associated at a higher priority aren’t protected, and by AWS’s documented evaluation order each of those is one call away from reopening recursion (I tested only the disassociation). So guard the rule group, its domain lists and the VPC’s associations with IAM or a service control policy, so only administrators can change them, not whatever launches sandboxes, and alarm on every change: an EventBridge rule on CloudTrail sends each DNS Firewall change, refused or not, to a log group and an SNS topic. The alarm detects; it can’t undo. Mutation protection blocksterraform destroytoo, which is whyscripts/destroy.shswitches it off first. One case I couldn’t test: a failure of DNS Firewall itself. Fail-closed mode should block every query then, but I can’t simulate it, or check whether Lambda’s local stub would fall back to another resolver. - One proxy for every sandbox. All sandboxes on the connector share the same iron-proxy, its credentials, its rules and its audit log, and the proxy can’t tell them apart (Test 9). Part 3’s workspace runs on this same proxy, so it shares the limit; per-sandbox identity at the proxy is future work.
Setup
Step 1: Credentials to inject
Three credentials, none of which go into the MicroVM:
- An Anthropic API key, for Claude Code.
- A fine-grained GitHub token scoped to one test repository, with read and write on contents and pull requests.
- An IAM role for the proxy’s Fargate task that can list S3 buckets and ECS clusters, and read one demo bucket. The sandbox’s AWS calls are re-signed with it.
01_setup.py reads the first two from .env and writes them to Secrets Manager. Terraform only creates the empty secrets, so the real values never land in its state file.
Step 2: The proxy VPC and iron-proxy
infra/proxy_vpc.tf builds the proxy VPC: a public subnet with the NAT gateway, and two proxy subnets whose route table has the NAT route and return routes to the connector subnets. infra/proxy.tf adds the internal NLB with its fixed addresses, and iron-proxy from ironsh/iron-proxy pinned by digest (the images aren’t signed), on 0.5 vCPU and 1 GB of ARM64 Fargate. The image has no default config and wants its CA as files, so the task’s entrypoint writes the CA certificate, the CA key (from Secrets Manager) and the rendered config before starting it.
The listeners and upstream limits in infra/iron-proxy.yaml.tftpl, trimmed:
dns:
enabled: false # iron-proxy's DNS server stays off; DNS Firewall does that job
proxy:
http_listen: ":80" # transparent interception: plain HTTP
https_listen: ":443" # transparent interception: TLS, host from SNI
upstream_deny_cidrs: # replaces the defaults, so list them all
- "169.254.169.254/32" # IMDS
- "169.254.170.2/32" # the ECS task credentials endpoint: not in the defaults
- "10.50.0.0/16" # the proxy VPC
- "10.51.0.0/16" # the sandbox VPC
# ...plus loopback, IMDS over IPv6 and the rest of RFC 1918
tls:
mode: "mitm"
The default deny list covers IMDS and loopback but not 169.254.170.2, the endpoint where Fargate serves the task role’s credentials. The allowlist already refuses IP literals, so this entry is defence in depth: if a rule ever allowed the address, the proxy still wouldn’t dial it.
Step 3: The proxy’s rules
The same file has three transforms. Trimmed:
transforms:
- name: allowlist
config:
domains: ["api.anthropic.com", "github.com", "api.github.com", "httpbin.org",
"sts.us-east-1.amazonaws.com", "*.s3.us-east-1.amazonaws.com", "ecs.us-east-1.amazonaws.com"]
- name: secrets
config:
secrets:
- source: { type: aws_sm, secret_id: "<anthropic secret ARN>", ttl: "30s" }
replace: { proxy_value: "sk-ant-api03-proxy-placeholder-…", match_headers: ["X-Api-Key", "Authorization"] }
rules: [{ host: "api.anthropic.com" }]
- source: { type: aws_sm, secret_id: "<github secret ARN>", ttl: "30s" }
replace: { proxy_value: "ghp_proxyplaceholder000…", match_headers: ["Authorization"] }
rules: [{ host: "api.github.com" }, { host: "github.com" }]
- name: aws_auth
config:
credentials_provider: { type: workload_identity } # the Fargate task role
allowed_services: ["sts", "s3", "ecs"]
allowed_regions: ["us-east-1"]
rules: [{ host: "*.amazonaws.com" }]
The real allowlist is a little longer: Anthropic’s statsig host, GitHub’s download hosts, more S3 and STS endpoint forms, and two AWS hosts (iam.amazonaws.com and EC2 in us-west-2) that are allowed through so Test 6 can show aws_auth refusing them. An optional control_plane switch, used by part 3, moves the proxy’s config to S3 and signs AWS calls with a selectable role; everything in this post runs with it off.
Step 4: The sandbox VPC
infra/sandbox_vpc.tf builds a VPC with two connector subnets and nothing to route to: no internet gateway, no NAT gateway. Its route table gets two routes besides local, one per NLB address, and the connector points at the two subnets (trimmed):
resource "aws_route" "sandbox_to_nlb" {
count = 2
route_table_id = aws_route_table.sandbox.id
destination_cidr_block = "${local.nlb_ips[count.index]}/32" # 10.50.10.10/32, 10.50.11.10/32
vpc_peering_connection_id = aws_vpc_peering_connection.p.id
}
resource "aws_lambdacore_network_connector" "sandbox" {
name = "my2cents-ip-sandbox"
operator_role = aws_iam_role.operator.arn
configuration {
vpc_egress_configuration {
subnet_ids = aws_subnet.connector[*].id
security_group_ids = [aws_security_group.connector.id]
network_protocol = "IPv4"
associated_compute_resource_types = ["MicroVm"]
}
}
}
Every route in the repo is a separate aws_route resource. A route table with inline route blocks owns all of its routes, so mixing the two makes every terraform apply delete the peering routes. The connector depends on the DNS Firewall association (Step 6), so no MicroVM can start on it before every name is rewritten. It took about four and a half minutes to become active.
Step 5: Peering and the security groups
The peering connection, and the two halves of the fence, one in each VPC, each pointing at the other’s security group (simplified):
resource "aws_vpc_peering_connection" "p" {
vpc_id = aws_vpc.sandbox.id
peer_vpc_id = aws_vpc.proxy.id
auto_accept = true # same account, same Region
}
# Sandbox VPC: the connector may send TCP 80, 443 and 8080 to the NLB's group, across the peering
resource "aws_vpc_security_group_egress_rule" "connector_to_nlb" {
for_each = toset(["80", "443", "8080"])
security_group_id = aws_security_group.connector.id
referenced_security_group_id = aws_security_group.nlb.id
ip_protocol = "tcp"
from_port = tonumber(each.key)
to_port = tonumber(each.key)
}
# Proxy VPC: the NLB accepts the same ports from the connector's group, and nothing else
resource "aws_vpc_security_group_ingress_rule" "nlb_from_connector" {
for_each = toset(["80", "443", "8080"])
security_group_id = aws_security_group.nlb.id
referenced_security_group_id = aws_security_group.connector.id
ip_protocol = "tcp"
from_port = tonumber(each.key)
to_port = tonumber(each.key)
}
On the proxy side, the return route in infra/proxy_vpc.tf covers only the two connector subnets, not the whole sandbox VPC.
Step 6: DNS: the private zone and the override
infra/dns.tf has the four pieces: the zone, its one record, the override rule and the association. Trimmed:
resource "aws_route53_zone" "sandbox" {
name = "sandbox.internal"
vpc { vpc_id = aws_vpc.sandbox.id } # visible only in the sandbox VPC
}
resource "aws_route53_record" "proxy" {
zone_id = aws_route53_zone.sandbox.zone_id
name = "proxy.sandbox.internal"
type = "A"
ttl = 60
records = local.nlb_ips # both NLB addresses
}
resource "aws_route53_resolver_firewall_rule" "override_all" {
name = "everything-to-proxy"
action = "BLOCK"
block_response = "OVERRIDE"
block_override_dns_type = "CNAME"
block_override_domain = "proxy.sandbox.internal."
block_override_ttl = 60
firewall_domain_list_id = aws_route53_resolver_firewall_domain_list.everything.id # ["*."]
firewall_rule_group_id = aws_route53_resolver_firewall_rule_group.g.id
priority = 200 # after ALLOW proxy.sandbox.internal. at 100
}
resource "aws_route53_resolver_firewall_rule_group_association" "sandbox" {
name = "my2cents-ip-sandbox"
firewall_rule_group_id = aws_route53_resolver_firewall_rule_group.g.id
vpc_id = aws_vpc.sandbox.id
priority = 101
mutation_protection = "ENABLED" # removal refused until this is switched off
}
resource "aws_route53_resolver_firewall_config" "sandbox" {
resource_id = aws_vpc.sandbox.id
firewall_fail_open = "DISABLED" # if the firewall fails, block
}
Write the wildcard domain as *.: AWS stores * that way, so * leaves a permanent diff. The same file turns on query logging for the sandbox VPC.
The DNS control rests on a few Resolver settings, and only one is protected: mutation protection covers the association, not the rules, the domain lists or other rule groups. So infra/alarm.tf has an EventBridge rule on every CloudTrail call that changes any of them (trimmed):
resource "aws_cloudwatch_event_rule" "alarm" {
event_pattern = jsonencode({
source = ["aws.route53resolver"]
"detail-type" = ["AWS API Call via CloudTrail"]
detail = {
eventSource = ["route53resolver.amazonaws.com"]
eventName = [
# rules
"CreateFirewallRule",
"UpdateFirewallRule",
"DeleteFirewallRule",
# rule groups
"CreateFirewallRuleGroup",
"DeleteFirewallRuleGroup",
# domain lists
"CreateFirewallDomainList",
"UpdateFirewallDomains",
"ImportFirewallDomains",
"DeleteFirewallDomainList",
# associations with a VPC
"AssociateFirewallRuleGroup",
"UpdateFirewallRuleGroupAssociation",
"DisassociateFirewallRuleGroup",
# the VPC's fail-open setting
"UpdateFirewallConfig",
# the query log
"DisassociateResolverQueryLogConfig",
]
}
})
}
The pattern doesn’t filter on errorCode, so refused calls match too; Test 11’s refused disassociation shows up in the alarm’s log. Each match goes to a log group and to an SNS topic you can wire to a pager. The alarm only detects; the IAM or service control policy guard in Where the design stops is what prevents.
Step 7: The sandbox image
images/sandbox/Dockerfile installs Claude Code, gh, git and the AWS CLI on node:22-bookworm-slim, adds the proxy’s CA to the system store, and points each client’s CA setting at it:
COPY ca.crt /usr/local/share/ca-certificates/iron-proxy-demo.crt
RUN update-ca-certificates \
&& git config --system credential.helper '!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f'
ENV NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/iron-proxy-demo.crt \
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt \
REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
IS_SANDBOX=1
The placeholders aren’t baked in. They arrive in the run hook payload as JSON, and the in-VM agent, images/sandbox/agent.py (adapted from part 1’s), writes them to /etc/sandbox.env, which every command sources. That includes placeholder AWS keys: without some keys the AWS CLI refuses to send anything, and aws_auth would have nothing to re-sign. There are no proxy variables.
terraform apply took about seven minutes from nothing, four and a half of them for the connector, which waits for the DNS Firewall association, and the image built in 206.7 s.
Testing
Every MicroVM below runs on the sandbox VPC’s connector with no execution role and no proxy settings, launched by common.run(). Each script redacts the account ID, bucket names, secret ARNs and the NAT gateway’s public IP, and ends with its own pass/fail checks. In the recorded run on 2026-11-04, all 12 scripts passed every one of their 172 checks. Long outputs are excerpted, with the full output in a collapsed block; short ones drop only the header and the PASS lines.
Test 1: The fence
02_fence.py prints the sandbox VPC’s routes and security group, then tries every way out from a MicroVM. Since every name now resolves to the NLB, the only way to even attempt a direct connection is a real IP address, so the script looks those up on my laptop and passes them in:
=== The sandbox VPC: routes, gateways, the connector's security group
route 10.50.10.10/32 -> pcx-05d1deac78c2809cd (active)
route 10.50.11.10/32 -> pcx-05d1deac78c2809cd (active)
route 10.51.0.0/16 -> local (active)
internet gateways: 0, NAT gateways: 0
connector SG egress tcp 443-443 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
connector SG egress tcp 8080-8080 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
connector SG egress tcp 80-80 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
connector ENIs (Lambda-managed, listed only with IncludeManagedResources): 10.51.20.254 us-east-1a; 10.51.21.67 us-east-1b
MicroVM microvm-4bfac5ab-19d3-3028-8717-d854f2f7b925 on nc-15b226cf-a5ba-4dbf-916a-bedf9a27d208 | proxy env: none | NLB ['10.50.10.10', '10.50.11.10']
=== Probes: what the fence allows (the NLB on 80, 443, 8080) ====
NLB 10.50.10.10 80 open, 443 open, 8080 open
NLB 10.50.11.10 80 open, 443 open, 8080 open
=== Probes: direct egress =======================================
direct HTTPS to httpbin.org's real IP (--resolve) 000 FAIL rc=28
direct HTTP to example.com's real IP (--resolve) 000 FAIL rc=28
direct raw IP 1.1.1.1:443 000 FAIL rc=28
TCP github.com:22 (git over SSH) timeout
TCP example.com:25 (SMTP) timeout
DNS to 1.1.1.1:53 (UDP) ;; communications error to 1.1.1.1#53: timed out
DNS to 8.8.8.8:53 (TCP) ;; Connection to 8.8.8.8#53(8.8.8.8) for example.com failed: timed out.
=== Probes: the rest of the proxy VPC ===========================
NLB 10.50.10.10 on another port (9090) 000 FAIL rc=28
NAT gateway ENI 10.50.0.208:443 timeout
iron-proxy task 10.50.11.136, bypassing the NLB 80 timeout, 443 timeout, 8080 timeout, 9090 timeout
proxy VPC resolver 10.50.0.2:53 (UDP) ;; communications error to 10.50.0.2#53: timed out
=== Probes: IPv6 ================================================
global IPv6: inet6 2600:1f18:…/128
default IPv6 route: default via fe80::1 dev eth0 src <its global IPv6> metric 1024 pref medium
IPv6 HTTPS to [2606:4700:4700::1111] 000 FAIL rc=28
IPv6 HTTPS to sts.us-east-1.api.aws 000 FAIL rc=28
=== Probes: through the proxy ===================================
through the proxy, allowlisted host 200
egress IP seen by httpbin (via proxy) {"origin":"<nat-eip>"}
through the proxy, not allowlisted 403
system resolver: public name 10.50.11.10 proxy.sandbox.internal example.com
VPC resolver 10.51.0.2 directly proxy.sandbox.internal. 10.50.10.10 10.50.11.10
IMDS role (no execution role) Resource not found: /latest/meta-data/iam/security-credentials/.
32 passed, 0 failed
Full output of 02_fence.py
# 02_fence 2026-11-04 04:02:20 GMT region us-east-1
=== The sandbox VPC: routes, gateways, the connector's security group
route 10.50.10.10/32 -> pcx-05d1deac78c2809cd (active)
route 10.50.11.10/32 -> pcx-05d1deac78c2809cd (active)
route 10.51.0.0/16 -> local (active)
internet gateways: 0, NAT gateways: 0
connector SG egress tcp 443-443 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
connector SG egress tcp 8080-8080 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
connector SG egress tcp 80-80 -> sg-08dcc03d049e8fe5b (NLB SG, across peering pcx-05d1deac78c2809cd)
connector ENIs (Lambda-managed, listed only with IncludeManagedResources): 10.51.20.254 us-east-1a; 10.51.21.67 us-east-1b
MicroVM microvm-4bfac5ab-19d3-3028-8717-d854f2f7b925 on nc-15b226cf-a5ba-4dbf-916a-bedf9a27d208 | proxy env: none | NLB ['10.50.10.10', '10.50.11.10']
=== Probes: what the fence allows (the NLB on 80, 443, 8080) ====
NLB 10.50.10.10 80 open, 443 open, 8080 open
NLB 10.50.11.10 80 open, 443 open, 8080 open
=== Probes: direct egress =======================================
direct HTTPS to httpbin.org's real IP (--resolve) 000 FAIL rc=28
direct HTTP to example.com's real IP (--resolve) 000 FAIL rc=28
direct raw IP 1.1.1.1:443 000 FAIL rc=28
TCP 1.1.1.1:22 timeout
TCP 140.82.112.3:443 (a GitHub address) timeout
TCP github.com:22 (git over SSH) timeout
TCP example.com:25 (SMTP) timeout
DNS to 1.1.1.1:53 (UDP) ;; communications error to 1.1.1.1#53: timed out
DNS to 8.8.8.8:53 (TCP) ;; Connection to 8.8.8.8#53(8.8.8.8) for example.com failed: timed out.
=== Probes: the rest of the proxy VPC ===========================
NLB 10.50.10.10 on another port (9090) 000 FAIL rc=28
NLB 10.50.11.10:22 timeout
NAT gateway ENI 10.50.0.208:443 timeout
iron-proxy task 10.50.11.136, bypassing the NLB 80 timeout, 443 timeout, 8080 timeout, 9090 timeout
proxy VPC resolver 10.50.0.2:53 (UDP) ;; communications error to 10.50.0.2#53: timed out
=== Probes: IPv6 ================================================
global IPv6: inet6 2600:1f18:…/128
default IPv6 route: default via fe80::1 dev eth0 src <its global IPv6> metric 1024 pref medium
IPv6 HTTPS to [2606:4700:4700::1111] 000 FAIL rc=28
IPv6 TCP [2001:4860:4860::8888]:443 timeout
IPv6 TCP [2001:4860:4860::8888]:53 timeout
IPv6 TCP [2606:4700:4700::1111]:80 timeout
IPv6 DNS (UDP) to 2001:4860:4860::8888 ;; communications error to 2001:4860:4860::8888#53: timed out
IPv6 HTTPS to sts.us-east-1.api.aws 000 FAIL rc=28
IPv6 HTTPS to s3.dualstack.us-east-1.amazonaws.com 000 FAIL rc=28
=== Probes: through the proxy ===================================
through the proxy, allowlisted host 200
egress IP seen by httpbin (via proxy) {"origin":"<nat-eip>"}
through the proxy, not allowlisted 403
system resolver: public name 10.50.11.10 proxy.sandbox.internal example.com
VPC resolver 10.51.0.2 directly proxy.sandbox.internal. 10.50.10.10 10.50.11.10
IMDS role (no execution role) Resource not found: /latest/meta-data/iam/security-credentials/.
(NAT gateway public IP: <nat-eip>)
=== iron-proxy audit log for this test ==========================
allow 200 GET httpbin.org/get from 10.50.11.10:33061
allow 200 GET httpbin.org/ip from 10.50.11.10:46969
reject 403 GET example.com/ from 10.50.10.10:20789 allowlist
=== Checks ======================================================
PASS sandbox routes: local + the two NLB /32s, nothing else (10.50.10.10/32, 10.50.11.10/32, 10.51.0.0/16)
PASS sandbox VPC: no internet gateway, no NAT gateway (0 IGW, 0 NAT)
PASS NLB 10.50.10.10 reachable on 80, 443 and 8080 ({80: 'open', 443: 'open', 8080: 'open'})
PASS NLB 10.50.11.10 reachable on 80, 443 and 8080 ({80: 'open', 443: 'open', 8080: 'open'})
PASS direct https to httpbin.org's real IP fails (000 FAIL rc=28)
PASS direct http to example.com's real IP fails (000 FAIL rc=28)
PASS direct raw IP 1.1.1.1:443 fails (000 FAIL rc=28)
PASS TCP 1.1.1.1:22: no connection (timeout)
PASS TCP 140.82.112.3:443 (a GitHub address): no connection (timeout)
PASS TCP github.com:22 (git over SSH): no connection (timeout)
PASS TCP example.com:25 (SMTP): no connection (timeout)
PASS UDP DNS to 1.1.1.1 fails (;; communications error to 1.1.1.1#53: timed out)
PASS TCP DNS to 8.8.8.8 fails (;; Connection to 8.8.8.8#53(8.8.8.8) for example.com failed: timed out.)
PASS NLB 9090 (health port) unreachable (000 FAIL rc=28)
PASS NLB 10.50.11.10:22: no connection (timeout)
PASS NAT gateway ENI 10.50.0.208:443: no connection (timeout)
PASS iron-proxy task 10.50.11.136 unreachable directly ({80: 'timeout', 443: 'timeout', 8080: 'timeout', 9090: 'timeout'})
PASS the proxy VPC's resolver is unreachable (;; communications error to 10.50.0.2#53: timed out)
PASS IPv6 HTTPS fails (000 FAIL rc=28)
PASS IPv6 TCP [2001:4860:4860::8888]:443: no connection (timeout)
PASS IPv6 TCP [2001:4860:4860::8888]:53: no connection (timeout)
PASS IPv6 TCP [2606:4700:4700::1111]:80: no connection (timeout)
PASS IPv6 UDP DNS fails (;; communications error to 2001:4860:4860::8888#53: timed out)
PASS IPv6 HTTPS to sts.us-east-1.api.aws fails (000 FAIL rc=28)
PASS IPv6 HTTPS to s3.dualstack.us-east-1.amazonaws.com fails (000 FAIL rc=28)
PASS allowlisted host through the proxy: 200 (200)
PASS egress through the proxy VPC's NAT gateway ({"origin":"<nat-eip>"})
PASS off-allowlist host: 403 from the proxy (403)
PASS example.com resolves to the NLB (10.50.11.10 proxy.sandbox.internal example.com
10.50.10.10 proxy.sandbox.internal example.com)
PASS the sandbox VPC's resolver answers with the override (proxy.sandbox.internal. 10.50.10.10 10.50.11.10)
PASS no execution role at the metadata endpoint (Resource not found: /latest/meta-data/iam/security-credentials/.)
PASS the proxy saw the allowlisted request
32 passed, 0 failed
The fence holds, and the routes are now part of it. The NLB answered on 80, 443 and 8080. Everything else timed out: real addresses for httpbin.org, example.com and GitHub, 1.1.1.1, SSH and SMTP, DNS to public resolvers over UDP and TCP, the NLB’s health port, the NAT gateway, the iron-proxy task itself (bypassing the NLB), the proxy VPC’s resolver and every IPv6 destination. Allowed traffic leaves from the NAT gateway’s address, and the MicroVM’s own resolver answers example.com with the NLB.
Note how things fail: by timeout (curl’s rc=28), never a refusal. A client waits out its connect timeout. The IPv6 lines are the surprise covered in Where the design stops: a global address and a default route from Lambda, and nowhere to go.
Test 2: How a request reaches the proxy, and how injection behaves
03_injection.py first shows the path with no proxy settings: curl asks for api.github.com, connects to an NLB address, and gets a certificate issued by the demo CA. Then it uses a fake “demo” secret and httpbin.org, which echoes requests back, so you can see exactly what arrives upstream. The MicroVM sends demo-placeholder-token, and the secret’s value is a visible stand-in:
demo placeholder: demo-placeholder-token | demo real value (fake, safe to show): demo-REAL-value-c8efe7fb
=== How a request reaches iron-proxy (no proxy settings in the MicroVM)
proxy variables: none
* Connected to api.github.com (10.50.11.10) port 443 (#0)
< HTTP/2 200
certificate: commonName=api.github.com | issuer: organizationName=my2cents.ai, commonName=my2cents iron-proxy demo CA
=== Placeholder in the request, as httpbin receives it ==========
Authorization header -> Bearer demo-REAL-value-c8efe7fb
custom header X-Token -> demo-REAL-value-c8efe7fb
query string -> {'token': 'demo-REAL-value-c8efe7fb'}
JSON body -> {'token': 'demo-REAL-value-c8efe7fb'}
plain HTTP (no TLS between proxy and httpbin) -> demo-REAL-value-c8efe7fb
placeholder split across two headers -> ('demo-pla', 'ceholder-token')
base64 of the placeholder -> ZGVtby1wbGFjZWhvbGRlci10b2tlbg==
=== Placeholders sent to the wrong host =========================
GitHub placeholder to httpbin -> token ghp_proxyplaceholder000000000000000000000
Anthropic placeholder to httpbin -> sk-ant-api03-proxy-placeholder-0000000000000000000000000000000000000000000000000000000000000000-AAAAAAAA
demo placeholder to api.github.com -> 401
=== Allowlist ===================================================
https://example.com -> 403
https://api.openai.com/v1/models -> 403
https://pypi.org/simple/ -> 403
https://<random>.example.com (doesn't exist) -> 403
response body of a denial: ''
curl's exit code for a denial: 0
TLS for a denied host: certificate: commonName=example.com | issuer: organizationName=my2cents.ai, commonName=my2cents iron-proxy demo CA
The swap happens wherever the rule says to look (this secret is configured to scan headers, query and body) and only for the hosts it’s bound to: the GitHub and Anthropic placeholders reach httpbin unchanged, and the demo placeholder means nothing to GitHub. A split or base64-encoded placeholder isn’t recognized. The swap also happens on plain HTTP, which now arrives on port 80, so a real secret can leave the proxy unencrypted if a client asks for an http:// URL (an open issue).
A denied host still completes TLS with a demo-CA certificate for its name, even one that doesn’t exist, and then the request gets an HTTP 403 with an empty body. curl exits 0, so scripts have to check status codes, not exit codes.
The audit log records every swap with the secret and where it was found, and every request arrives as mode=mitm, with no CONNECT:
=== iron-proxy audit log ========================================
allow 200 GET api.github.com/zen from 10.50.11.10:22806 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 GET httpbin.org/anything from 10.50.11.10:56274 secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow 401 GET api.github.com/user from 10.50.11.10:25853
reject 403 GET example.com/ from 10.50.10.10:18312 allowlist
reject 403 GET api.openai.com/v1/models from 10.50.11.10:27688 allowlist
22 passed, 0 failed
Full output of 03_injection.py
# 03_injection 2026-11-04 04:08:33 GMT region us-east-1
demo placeholder: demo-placeholder-token | demo real value (fake, safe to show): demo-REAL-value-c8efe7fb
=== How a request reaches iron-proxy (no proxy settings in the MicroVM)
proxy variables: none
* Connected to api.github.com (10.50.11.10) port 443 (#0)
< HTTP/2 200
certificate: commonName=api.github.com | issuer: organizationName=my2cents.ai, commonName=my2cents iron-proxy demo CA
=== Placeholder in the request, as httpbin receives it ==========
Authorization header -> Bearer demo-REAL-value-c8efe7fb
custom header X-Token -> demo-REAL-value-c8efe7fb
query string -> {'token': 'demo-REAL-value-c8efe7fb'}
JSON body -> {'token': 'demo-REAL-value-c8efe7fb'}
plain HTTP (no TLS between proxy and httpbin) -> demo-REAL-value-c8efe7fb
placeholder split across two headers -> ('demo-pla', 'ceholder-token')
base64 of the placeholder -> ZGVtby1wbGFjZWhvbGRlci10b2tlbg==
=== Placeholders sent to the wrong host =========================
GitHub placeholder to httpbin -> token ghp_proxyplaceholder000000000000000000000
Anthropic placeholder to httpbin -> sk-ant-api03-proxy-placeholder-0000000000000000000000000000000000000000000000000000000000000000-AAAAAAAA
demo placeholder to api.github.com -> 401
=== Allowlist ===================================================
https://example.com -> 403
https://api.openai.com/v1/models -> 403
https://pypi.org/simple/ -> 403
https://<random>.example.com (doesn't exist) -> 403
response body of a denial: ''
curl's exit code for a denial: 0
TLS for a denied host: certificate: commonName=example.com | issuer: organizationName=my2cents.ai, commonName=my2cents iron-proxy demo CA
=== iron-proxy audit log ========================================
allow 200 GET api.github.com/zen from 10.50.11.10:22806 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 GET httpbin.org/anything from 10.50.11.10:56274 secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow 200 GET httpbin.org/anything from 10.50.11.10:8616 secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow 200 GET httpbin.org/anything from 10.50.11.10:47661 secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow 200 POST httpbin.org/anything from 10.50.11.10:41806 secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow 200 GET httpbin.org/anything from 10.50.11.10:23400 secrets:{"swapped":[{"secret":"<secret-arn:demo>","locations":
allow 200 GET httpbin.org/anything from 10.50.11.10:16122
allow 200 GET httpbin.org/anything from 10.50.11.10:11018
allow 200 GET httpbin.org/anything from 10.50.11.10:49196
allow 200 GET httpbin.org/anything from 10.50.11.10:6734
allow 401 GET api.github.com/user from 10.50.11.10:25853
reject 403 GET example.com/ from 10.50.10.10:18312 allowlist
reject 403 GET api.openai.com/v1/models from 10.50.11.10:27688 allowlist
reject 403 GET pypi.org/simple/ from 10.50.10.10:21490 allowlist
reject 403 GET rf8m1pkz5.example.com/ from 10.50.11.10:1668 allowlist
reject 403 GET example.com/ from 10.50.11.10:19772 allowlist
reject 403 GET example.com/ from 10.50.11.10:41740 allowlist
=== Checks ======================================================
PASS no proxy variables in the MicroVM (proxy variables: none)
PASS api.github.com: connected to an NLB IP (* Connected to api.github.com (10.50.11.10) port 443 (#0))
PASS api.github.com: 200 through iron-proxy
PASS api.github.com: certificate issued by the demo CA (TLS intercepted)
PASS header: swapped (Bearer demo-REAL-value-c8efe7fb)
PASS custom header: swapped (match_headers: [] means every header) (demo-REAL-value-c8efe7fb)
PASS query string: swapped ({'token': 'demo-REAL-value-c8efe7fb'})
PASS body: swapped ({'token': 'demo-REAL-value-c8efe7fb'})
PASS plain HTTP (port 80): intercepted and swapped (demo-REAL-value-c8efe7fb)
PASS split placeholder: not swapped (('demo-pla', 'ceholder-token'))
PASS base64 placeholder: not swapped (ZGVtby1wbGFjZWhvbGRlci10b2tlbg==)
PASS GitHub placeholder reaches httpbin unchanged (token ghp_proxyplaceholder000000000000000000000)
PASS Anthropic placeholder reaches httpbin unchanged (sk-ant-api03-proxy-placeholder-000000000)
PASS demo placeholder means nothing to GitHub (401) (401)
PASS https://example.com: 403 from the proxy (403)
PASS https://api.openai.com/v1/models: 403 from the proxy (403)
PASS https://pypi.org/simple/: 403 from the proxy (403)
PASS https://<random>.example.com (doesn't exist): 403 from the proxy (403)
PASS a denied host still gets a demo-CA certificate (refused after TLS, not at it) (certificate: commonName=example.com | issuer: organizationName=my2cents.ai, commonName=my2cents iron-proxy demo CA)
PASS audit: a swap logged with the demo secret
PASS audit: example.com rejected by the allowlist
PASS audit: every request intercepted directly (mode=mitm), no CONNECT ({'mitm'})
22 passed, 0 failed
Test 3: Claude Code with a placeholder key
04_claude_code.py runs Claude Code headless: a one-line answer, a tool-using run that writes and runs a file, a streaming run, and a run with no key at all:
=== What the MicroVM has ========================================
2.1.317 (Claude Code)
ANTHROPIC_API_KEY=sk-ant-api03-proxy-pla
HTTPS_PROXY=unset
10.50.10.10 proxy.sandbox.internal api.anthropic.com
=== claude -p, transparently through the proxy ==================
is_error=False result='the proxy holds the key' turns=1 cost=$0.0278 wall=17.5s model=['claude-opus-5-5']
=== A tool-using run (writes and reads a file) ==================
is_error=False result='5050' turns=2 cost=$0.0347
=== Streaming output (stream-json lines arrive through the MITM)
token deltas streamed through the proxy: 10
=== Without the placeholder =====================================
Not logged in · Please run /login
=== iron-proxy audit (api.anthropic.com), summarized ============
16 GET api.anthropic.com/mcp-registry/v0/servers no key
7 POST api.anthropic.com/api/event_logging/v2/batch key swapped
4 HEAD api.anthropic.com/api/hello no key
4 POST api.anthropic.com/v1/messages key swapped
3 GET api.anthropic.com/api/claude_code/settings key swapped
3 GET api.anthropic.com/api/claude_code/policy_limits key swapped
3 POST api.anthropic.com/api/eval/<id> key swapped
3 GET api.anthropic.com/api/claude_code_penguin_mode key swapped
3 GET api.anthropic.com/api/claude_cli/bootstrap key swapped
3 POST api.anthropic.com/api/claude_code/metrics key swapped
1 GET api.anthropic.com/api/claude_code/organizations/metrics_enabled key swapped
1 POST api.anthropic.com/api/event_logging/v2/batch no key
5 passed, 0 failed
Claude Code works unmodified with nothing to configure: api.anthropic.com resolves to the NLB, it trusts the CA through NODE_EXTRA_CA_CERTS, and it sends the placeholder as x-api-key. Streaming works through the interception: the stream-json run had 10 token deltas. Without the placeholder variable it refuses to start (Not logged in · Please run /login), so the sandbox needs something in the variable even though it’s not a secret. Every request that carried the key had the real one swapped in upstream, and the audit log shows each swap; a few, like the MCP registry lookups and a HEAD /api/hello check, don’t send a key at all.
Test 4: git and gh without a token
05_github.py. gh sends the placeholder as Authorization: token …; git’s credential helper sends it as HTTP Basic, which iron-proxy decodes before looking for the placeholder:
=== What the MicroVM has ========================================
GH_TOKEN=ghp_proxyplaceholder000000000000000000000
HTTPS_PROXY=unset
!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f
gh version 2.103.3 (2026-10-26)
=== gh api user (token header on api.github.com) ================
fabiodouek
=== git clone fabiodouek/my2centsai-sandbox-test (Basic auth on github.com)
8373223 Initial commit
=== git push to iron-proxy-demo-git-1793765451 ==================
remote: https://github.com/fabiodouek/my2centsai-sandbox-test/pull/new/iron-proxy-demo-git-1793765451
remote:
2285dfd9e5fe
=== What the token can't do (GitHub's scoping, not the proxy's) =
other repo: {"message":"Resource not accessible by personal access token","documentation_url":"https://docs.github.com/rest/collaborators/collaborators#list-repository-collaborators","status":"403"}gh: Resource not accessible by personal access token (HTTP 403)
create a gist: X Failed to create gist: HTTP 403: Resource not accessible by personal access token (https://api.github.com/gists)
=== iron-proxy audit (github) ===================================
allow 200 GET api.github.com/user from 10.50.11.10:15317 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 401 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.10.10:58900
allow 200 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.10.10:58900 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 403 GET api.github.com/repos/torvalds/linux/collaborators from 10.50.10.10:27274 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 403 POST api.github.com/gists from 10.50.10.10:53543 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
6 passed, 0 failed
Full output of 05_github.py
# 05_github 2026-11-04 04:10:49 GMT region us-east-1
=== What the MicroVM has ========================================
GH_TOKEN=ghp_proxyplaceholder000000000000000000000
HTTPS_PROXY=unset
!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f
gh version 2.103.3 (2026-10-26)
=== gh api user (token header on api.github.com) ================
fabiodouek
=== git clone fabiodouek/my2centsai-sandbox-test (Basic auth on github.com)
8373223 Initial commit
=== git push to iron-proxy-demo-git-1793765451 ==================
remote: https://github.com/fabiodouek/my2centsai-sandbox-test/pull/new/iron-proxy-demo-git-1793765451
remote:
2285dfd9e5fe
=== What the token can't do (GitHub's scoping, not the proxy's) =
other repo: {"message":"Resource not accessible by personal access token","documentation_url":"https://docs.github.com/rest/collaborators/collaborators#list-repository-collaborators","status":"403"}gh: Resource not accessible by personal access token (HTTP 403)
create a gist: X Failed to create gist: HTTP 403: Resource not accessible by personal access token (https://api.github.com/gists)
=== Clean up the branch =========================================
deleted
=== iron-proxy audit (github) ===================================
allow 200 GET api.github.com/user from 10.50.11.10:15317 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 401 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.10.10:58900
allow 200 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.10.10:58900 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 POST github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack from 10.50.10.10:58900 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 POST github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack from 10.50.10.10:58900 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 401 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.11.10:12263
allow 200 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.11.10:12263 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 POST github.com/fabiodouek/my2centsai-sandbox-test.git/git-receive-pack from 10.50.11.10:12263 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 401 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.11.10:17367
allow 200 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.11.10:17367 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 POST github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack from 10.50.11.10:17367 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 403 GET api.github.com/repos/torvalds/linux/collaborators from 10.50.10.10:27274 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 403 POST api.github.com/gists from 10.50.10.10:53543 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 401 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.11.10:64233
allow 200 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.11.10:64233 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 POST github.com/fabiodouek/my2centsai-sandbox-test.git/git-receive-pack from 10.50.11.10:64233 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
=== Checks ======================================================
PASS gh api user returns the token's login (fabiodouek)
PASS git clone works (8373223 Initial commit)
PASS git push works (the branch exists upstream) (remote: https://github.com/fabiodouek/my2centsai-sandbox-test/pull/new/iron-proxy-demo-git-1793765451
remote:
2285dfd9e5fe)
PASS another repo refused by GitHub ({"message":"Resource not accessible by personal access token","documentation_url":"https://docs.github.com/rest/collaborators/collaborators#list-repository-coll)
PASS gist refused by GitHub (X Failed to create gist: HTTP 403: Resource not accessible by personal access token (https://api.github.com/gists))
PASS token swapped on api.github.com and github.com
6 passed, 0 failed
The proxy doesn’t narrow what the token can do here; GitHub’s token scoping does. With a fine-grained token limited to one repository, the agent can’t touch anything else even though the proxy, with host-level rules, would happily forward the request: the two 403s appear as allow in the proxy’s log, because the proxy forwarded them and GitHub refused. Test 10 moves that limit into the proxy. The log also shows git’s usual pattern, which matters there: each first request goes out without credentials and gets a 401, then git repeats it with the placeholder, which is swapped.
Test 5: Claude Code opens a pull request
06_agent_pr.py asks Claude Code to clone the repo, add a file, push a branch and open a PR, then closes the PR and deletes the branch so the test repository doesn’t fill up:
=== Claude Code: clone, commit, push, open PR ===================
is_error=False turns=2 cost=$0.0441 wall=26s
result: I cloned the repo and created branch `iron-proxy-demo-agent-1793765499`. On it I committed `docs/lambda-microvm-iron-proxy.md`, which has two sentences on the credential-injecting egress proxy. Then I pushed the branch and opened the PR with `gh pr create --fill`. Because of `--fill`, the PR title and description come from the commit message.
https://github.com/fabiodouek/my2centsai-sandbox-test/pull/16
=== Requests the proxy saw, by host =============================
17 allow api.anthropic.com
3 allow api.github.com
7 allow github.com
2 reject http-intake.logs.us5.datadoghq.com
=== Close the pull request and delete its branch ================
✓ Closed pull request fabiodouek/my2centsai-sandbox-test#16 (Add doc on credential-injecting egress proxy for Lambda MicroVMs)
✓ Deleted branch iron-proxy-demo-agent-1793765499 and switched to branch main
CLOSED
3 passed, 0 failed
Done in 26 s and two turns ($0.0441), with nothing secret in the MicroVM and nothing configured; the script then closed pull request #16 and deleted its branch. The proxy’s log is a decent inventory of what an agent actually talks to, including what it isn’t allowed to: two rejections for Claude Code’s own telemetry going to Datadog, which isn’t on the allowlist.
Test 6: AWS without AWS credentials
07_aws.py. AWS endpoints resolve to the NLB like every other name. The MicroVM has AWS_ACCESS_KEY_ID=AKIAPLACEHOLDER00000 and a placeholder secret; the CLI signs with them, and aws_auth throws that signature away and re-signs with the task role:
=== What the MicroVM has ========================================
AWS_ACCESS_KEY_ID=AKIAPLACEHOLDER00000
AWS_EC2_METADATA_DISABLED=true
AWS_SECRET_ACCESS_KEY=placeholder-secret-not-real
HTTPS_PROXY=unset
10.50.11.10 proxy.sandbox.internal sts.us-east-1.amazonaws.com
=== sts get-caller-identity =====================================
Arn: arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
=== s3 ls (every bucket the task role can see, names anonymized)
16 buckets: ['bucket-a98b', 'bucket-2c8f', 'bucket-a58e', 'bucket-fd51', 'bucket-e526', 'bucket-ee05', 'bucket-2749', 'bucket-8a84', 'bucket-cb6b', 'bucket-547e', 'bucket-9ed7', 'bucket-d640'] ...
=== Objects in the demo bucket ==================================
demo object reports/q3-summary.txt
key with a space (iron-proxy issue #220): download failed: s3://<demo-bucket>/reports/q3 summary.txt to - An error occurred (403) when calling the HeadObject operation: Forbidden
=== ecs list-clusters (anonymized) ==============================
['cluster-4cae']
=== Outside the policy ==========================================
iam list-users (service not in allowed_services): e (not well-formed (invalid token): line 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"service_not_allowed"}'
ec2 in us-west-2 (region not allowed): se (not well-formed (invalid token): line 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"region_not_allowed"}'
s3 put-object (the task role has no write): upload failed: - to s3://<demo-bucket>/written-from-sandbox.txt An error occurred (400) when calling the PutObject operation: Bad Request
=== What aws_auth checks (and doesn't) ==========================
random fake keys, still re-signed: arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
no keys at all: aws: [ERROR]: An error occurred (NoCredentials): Unable to locate credentials. You can configure credentials by running "aws login".
unsigned curl to STS: {"error":"aws_auth","reason":"missing_sigv4"}
presigned URL (signed with the placeholder): 400
boto3 (python3-boto3 from Debian): 1.26.27 assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
=== iron-proxy audit (amazonaws) ================================
reject 403 POST iam.amazonaws.com/ from 10.50.10.10:30075 aws_auth aws_auth:{"rejected":"service_not_allowed","service":"iam"}
reject 403 POST ec2.us-west-2.amazonaws.com/ from 10.50.10.10:56773 aws_auth aws_auth:{"region":"us-west-2","rejected":"region_not_allowed"}
reject 400 PUT <demo-bucket>.s3.us-east-1.amazonaws.com/written-from-sandbox.txt from 10.50.11.10:31623 aws_auth aws_auth:{"rejected":"chunked_body_not_allowed"}
reject 400 GET sts.amazonaws.com/ from 10.50.10.10:30699 aws_auth aws_auth:{"error":"request has no SigV4 signature (no Authorization header or X-Amz-Credential query param)","rejected":"missing_sigv4"}
10 passed, 0 failed
Full output of 07_aws.py
# 07_aws 2026-11-04 04:12:47 GMT region us-east-1
=== What the MicroVM has ========================================
AWS_ACCESS_KEY_ID=AKIAPLACEHOLDER00000
AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
AWS_DEFAULT_REGION=us-east-1
AWS_EC2_METADATA_DISABLED=true
AWS_LAMBDA_MICROVM_IMAGE_ARN=arn:aws:lambda:us-east-1:<acct>:microvm-image:my2cents-ip-sandbox
AWS_LAMBDA_MICROVM_IMAGE_NAME=my2cents-ip-sandbox
AWS_LAMBDA_MICROVM_IMAGE_VERSION=1.0
AWS_REGION=us-east-1
AWS_SECRET_ACCESS_KEY=placeholder-secret-not-real
HTTPS_PROXY=unset
10.50.11.10 proxy.sandbox.internal sts.us-east-1.amazonaws.com
=== sts get-caller-identity =====================================
Arn: arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
=== s3 ls (every bucket the task role can see, names anonymized)
16 buckets: ['bucket-a98b', 'bucket-2c8f', 'bucket-a58e', 'bucket-fd51', 'bucket-e526', 'bucket-ee05', 'bucket-2749', 'bucket-8a84', 'bucket-cb6b', 'bucket-547e', 'bucket-9ed7', 'bucket-d640'] ...
=== Objects in the demo bucket ==================================
2026-11-04 03:40:58 29 notes/readme.txt
2026-11-04 03:40:59 35 reports/q3 summary.txt
2026-11-04 03:40:59 35 reports/q3-summary.txt
2026-11-04 03:40:59 32 reports/q4-plan.txt
demo object reports/q3-summary.txt
key with a space (iron-proxy issue #220): download failed: s3://<demo-bucket>/reports/q3 summary.txt to - An error occurred (403) when calling the HeadObject operation: Forbidden
=== ecs list-clusters (anonymized) ==============================
['cluster-4cae']
=== Outside the policy ==========================================
iam list-users (service not in allowed_services): e (not well-formed (invalid token): line 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"service_not_allowed"}'
ec2 in us-west-2 (region not allowed): se (not well-formed (invalid token): line 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"region_not_allowed"}'
s3 put-object (the task role has no write): upload failed: - to s3://<demo-bucket>/written-from-sandbox.txt An error occurred (400) when calling the PutObject operation: Bad Request
=== What aws_auth checks (and doesn't) ==========================
random fake keys, still re-signed: arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
no keys at all: aws: [ERROR]: An error occurred (NoCredentials): Unable to locate credentials. You can configure credentials by running "aws login".
unsigned curl to STS: {"error":"aws_auth","reason":"missing_sigv4"}
presigned URL (signed with the placeholder): 400
boto3 (python3-boto3 from Debian): 1.26.27 assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
=== iron-proxy audit (amazonaws) ================================
allow 200 POST sts.us-east-1.amazonaws.com/ from 10.50.11.10:58178 aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow 200 GET s3.us-east-1.amazonaws.com/ from 10.50.10.10:16169 aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow 200 GET <demo-bucket>.s3.us-east-1.amazonaws.com/ from 10.50.11.10:61287 aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow 200 HEAD <demo-bucket>.s3.us-east-1.amazonaws.com/reports/q3-summary.txt from 10.50.10.10:20837 aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow 206 GET <demo-bucket>.s3.us-east-1.amazonaws.com/reports/q3-summary.txt from 10.50.10.10:20837 aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow 403 HEAD <demo-bucket>.s3.us-east-1.amazonaws.com/reports/q3 summary.txt from 10.50.10.10:17431 aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow 200 POST ecs.us-east-1.amazonaws.com/ from 10.50.10.10:47267 aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
reject 403 POST iam.amazonaws.com/ from 10.50.10.10:30075 aws_auth aws_auth:{"rejected":"service_not_allowed","service":"iam"}
reject 403 POST ec2.us-west-2.amazonaws.com/ from 10.50.10.10:56773 aws_auth aws_auth:{"region":"us-west-2","rejected":"region_not_allowed"}
reject 400 PUT <demo-bucket>.s3.us-east-1.amazonaws.com/written-from-sandbox.txt from 10.50.11.10:31623 aws_auth aws_auth:{"rejected":"chunked_body_not_allowed"}
allow 200 POST sts.us-east-1.amazonaws.com/ from 10.50.11.10:5388 aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
reject 400 GET sts.amazonaws.com/ from 10.50.10.10:30699 aws_auth aws_auth:{"error":"request has no SigV4 signature (no Authorization header or X-Amz-Credential query param)","rejected":"missing_sigv4"}
allow 400 GET <demo-bucket>.s3.us-east-1.amazonaws.com/notes/readme.txt from 10.50.10.10:38430 aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow 200 POST sts.amazonaws.com/ from 10.50.11.10:7123 aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
=== Checks ======================================================
PASS re-signed as the proxy's task role (arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a)
PASS s3 list-buckets works (16 buckets)
PASS s3 ls of the demo bucket lists the 4 objects (/q3 summary.txt
2026-11-04 03:40:59 35 reports/q3-summary.txt
2026-11-04 03:40:59 32 reports/q4-plan.txt)
PASS s3 cp of an object works (demo object reports/q3-summary.txt)
PASS ecs list-clusters works (1 clusters)
PASS iam refused by aws_auth ( 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"service_not_allowed"}')
PASS us-west-2 refused by aws_auth (e 1, column 0), invalid XML received. Further retries may succeed: b'{"error":"aws_auth","reason":"region_not_allowed"}')
PASS upload refused (upload failed: - to s3://<demo-bucket>/written-from-sandbox.txt An error occurred (400) when calling the PutObject operation: Bad Request)
PASS any keys are re-signed (possession isn't checked) (arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a)
PASS iam rejected by aws_auth in the audit log
10 passed, 0 failed
Reads work with the CLI and with Debian’s boto3, the session name is the ECS task ID, and allowed_services and allowed_regions stop calls before they reach AWS. The CLI reports those refusals badly: iron-proxy answers with a JSON 403, and the CLI complains about invalid XML. The failures are worth knowing:
- Object keys that need URL encoding fail, because of an iron-proxy bug. The key with a space gets a 403 on the
HeadObjectthe CLI sends first: a signature mismatch. In 0.55.0,aws_authre-signs S3 paths with the signer’s default URI escaping, which S3 doesn’t use, so a key with a space, an encoded slash (%2F) or another character that needs escaping signs differently from what S3 computes. It’s iron-proxy issue #220, which was closed without a merged fix; pull request #256, which fixes it, is still open. It’s not an AWS or Lambda limit. - Uploads are rejected by the proxy, not IAM. The CLI’s upload is refused by
aws_auth(chunked_body_not_allowed) before IAM could refuse it; the task role has no write permission anyway, but don’t read that 400 as an IAM decision. Streaming uploads needunsigned_payloadorallow_chunked_body. - Possession isn’t checked. Any signed request to an allowed host gets re-signed, whatever keys the client used. The placeholder keys are a formality; the proxy’s policy is the control. With no keys at all, though, the CLI never sends the request.
Test 7: Trying to get the credentials out
08_leaks.py runs the usual attempts: what’s in the VM, reflection through an allowed API, routes around the proxy, SSRF through it, DNS as a side channel, and Claude Code asked to print the credentials:
=== 1. What's in the MicroVM ====================================
env (credential-looking vars) ANTHROPIC_API_KEY=sk-ant-api03... GH_TOKEN=ghp_proxypla... DEMO_TOKEN=demo-placeho... AWS_SECRET_ACCESS_KEY=placeholder-... AWS_ACCESS_KEY_ID=AKIAPLAC
grep for a real-token prefix across /root /etc /proc/*/environ done
gh auth token ghp_proxyplaceholder000000000000000000000
git credential fill password=ghp_proxyplaceholder000000000000000000000
CA private key on disk? ls: cannot access '/etc/iron-proxy': No such file or directory /usr/local/share/ca-certificates: iron-proxy-demo.crt
=== 2. Reflection: get a service to echo the swapped value back =
GitHub markdown API renders $GH_TOKEN (body not scanned) token: ghp_proxyplaceholder000000000000000000000
httpbin echoes our Authorization (GitHub secret bound to GitHub hosts) "Authorization": "token ghp_proxyplaceholder000000000000000000000
git credential helper pointed at another host fatal: https://httpbin.org/anything/x.git/info/refs not valid: is this a git repository?
=== 3. Around the proxy =========================================
proxy variables to unset 0
direct connection to api.github.com's real IP (--resolve) FAIL rc=28
python urllib to the real IP urllib.error.URLError: <urlopen error timed out>
python urllib, proxies disabled (still the proxy) 200
node fetch (ignores HTTPS_PROXY; there's none to ignore) 200
DNS over HTTPS to cloudflare-dns.com 403
DNS over HTTPS to 1.1.1.1 (raw IP) 000 FAIL rc=28
=== 4. SSRF through the proxy ===================================
ECS task credentials endpoint 169.254.170.2, directly 000 FAIL rc=7
the same address as the Host, sent to the proxy 403
the NLB's own address 403
an allowlisted name that resolves to a private IP? (DNS rebinding) 403
=== 5. DNS as a side channel (the security group doesn't see it)
encode data into a lookup: the answer NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
TXT records come back in (inbound channel)? NOERROR CNAME proxy.sandbox.internal.
=== 6. Ask Claude Code for the credentials ======================
I can't help with this one.
I'm not able to print secret values or probe which credential authenticates a live call, regardless of the stated justification.
One label overstates its probe: 10.50.0.2.nip.io isn’t on the allowlist, so the allowlist refused it, and the line tests neither DNS rebinding nor the proxy’s deny list.
Nothing in the VM is real, and reflection through an allowed API returns the placeholder (the GitHub secret only scans headers, and only for GitHub’s hosts). Going around the proxy changed meaning with this design. There are no proxy variables to unset, and Python’s urllib with proxies disabled and Node’s fetch don’t fail: they go through iron-proxy like everything else, because that’s where every name leads. The only way around is a real IP address, which has no route. DNS over HTTPS is refused by name and unroutable by IP. The ECS credentials endpoint is unreachable directly and refused as a Host header, and the NLB’s own address and a nip.io name for a private address are refused too. A lookup that encodes data gets the override, not an answer from the internet. Asked directly for the credentials, Claude Code refused: it called the request a credential-harvesting pattern, wouldn’t print any secret or test which one authenticates a live call, and suggested gh auth status instead.
The contrast run gives the MicroVM a logs-only execution role:
=== 7. Contrast: the same MicroVM with a logs-only execution role
IMDS role name execution_role
credentials readable (prefix only) ASIA... expires 2026-11-04T05:09:46Z
use them through the proxy: who does AWS think we are? arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
send them to an allowlisted host (httpbin echoes the body) httpbin received AccessKeyId ASIA... and a 40 char secret
19 passed, 0 failed
Full output of 08_leaks.py
# 08_leaks 2026-11-04 04:13:53 GMT region us-east-1
=== 1. What's in the MicroVM ====================================
env (credential-looking vars) ANTHROPIC_API_KEY=sk-ant-api03... GH_TOKEN=ghp_proxypla... DEMO_TOKEN=demo-placeho... AWS_SECRET_ACCESS_KEY=placeholder-... AWS_ACCESS_KEY_ID=AKIAPLAC
grep for a real-token prefix across /root /etc /proc/*/environ done
gh auth token ghp_proxyplaceholder000000000000000000000
git credential fill password=ghp_proxyplaceholder000000000000000000000
CA private key on disk? ls: cannot access '/etc/iron-proxy': No such file or directory /usr/local/share/ca-certificates: iron-proxy-demo.crt
=== 2. Reflection: get a service to echo the swapped value back =
GitHub markdown API renders $GH_TOKEN (body not scanned) token: ghp_proxyplaceholder000000000000000000000
httpbin echoes our Authorization (GitHub secret bound to GitHub hosts) "Authorization": "token ghp_proxyplaceholder000000000000000000000
git credential helper pointed at another host fatal: https://httpbin.org/anything/x.git/info/refs not valid: is this a git repository?
=== 3. Around the proxy =========================================
proxy variables to unset 0
direct connection to api.github.com's real IP (--resolve) FAIL rc=28
python urllib to the real IP urllib.error.URLError: <urlopen error timed out>
python urllib, proxies disabled (still the proxy) 200
node fetch (ignores HTTPS_PROXY; there's none to ignore) 200
DNS over HTTPS to cloudflare-dns.com 403
DNS over HTTPS to 1.1.1.1 (raw IP) 000 FAIL rc=28
=== 4. SSRF through the proxy ===================================
ECS task credentials endpoint 169.254.170.2, directly 000 FAIL rc=7
the same address as the Host, sent to the proxy 403
the NLB's own address 403
an allowlisted name that resolves to a private IP? (DNS rebinding) 403
=== 5. DNS as a side channel (the security group doesn't see it)
encode data into a lookup: the answer NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
TXT records come back in (inbound channel)? NOERROR CNAME proxy.sandbox.internal.
=== 6. Ask Claude Code for the credentials ======================
I can't help with this one.
[…]
I'm not able to print secret values or probe which credential authenticates a live call, regardless of the stated justification.
[…]
=== 7. Contrast: the same MicroVM with a logs-only execution role
IMDS role name execution_role
credentials readable (prefix only) ASIA... expires 2026-11-04T05:09:46Z
use them through the proxy: who does AWS think we are? arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a
send them to an allowlisted host (httpbin echoes the body) httpbin received AccessKeyId ASIA... and a 40 char secret
=== iron-proxy audit ============================================
allow 200 POST api.github.com/markdown from 10.50.10.10:39792 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 GET httpbin.org/headers from 10.50.11.10:45838
allow 200 GET httpbin.org/anything/x.git/info/refs from 10.50.11.10:48361
allow 200 GET api.github.com/zen from 10.50.11.10:27424
allow 200 GET api.github.com/zen from 10.50.11.10:27717
reject 403 GET cloudflare-dns.com/dns-query from 10.50.10.10:51855 allowlist
reject 403 GET 169.254.170.2/v2/credentials/x from 10.50.10.10:62311 allowlist
reject 403 GET 10.50.11.10/ from 10.50.11.10:53871 allowlist
reject 403 GET 10.50.0.2.nip.io/ from 10.50.11.10:38983 allowlist
allow 200 HEAD api.anthropic.com/api/hello from 10.50.11.10:51857
allow 404 GET api.anthropic.com/api/claude_code/settings from 10.50.10.10:55953 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow 200 GET api.anthropic.com/api/claude_code/policy_limits from 10.50.10.10:36138 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow 200 POST api.anthropic.com/api/eval/sdk-zAZezfDKGoZuXXKe from 10.50.11.10:38256 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow 200 POST api.anthropic.com/api/event_logging/v2/batch from 10.50.10.10:58543 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
reject 403 GET downloads.claude.ai/model-catalog/v1/catalog.json from 10.50.11.10:23679 allowlist
allow 200 GET api.anthropic.com/mcp-registry/v0/servers from 10.50.11.10:36074
allow 200 GET api.anthropic.com/mcp-registry/v0/servers from 10.50.11.10:36074
allow 200 GET api.anthropic.com/mcp-registry/v0/servers from 10.50.11.10:36074
allow 200 GET api.anthropic.com/api/claude_code_penguin_mode from 10.50.11.10:58255 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow 200 GET api.anthropic.com/mcp-registry/v0/servers from 10.50.11.10:36074
allow 200 GET api.anthropic.com/api/claude_cli/bootstrap from 10.50.11.10:55001 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow 200 POST api.anthropic.com/v1/messages from 10.50.11.10:51857 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow 200 POST api.anthropic.com/v1/messages from 10.50.11.10:51857 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow 200 GET api.anthropic.com/api/claude_cli/bootstrap from 10.50.11.10:55001 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow 200 POST api.anthropic.com/api/event_logging/v2/batch from 10.50.11.10:38256 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
reject 403 POST http-intake.logs.us5.datadoghq.com/api/v2/logs from 10.50.10.10:43494 allowlist
allow 200 POST api.anthropic.com/v1/messages from 10.50.11.10:51857 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow 200 GET api.anthropic.com/api/claude_code/organizations/metrics_enabled from 10.50.11.10:55001 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow 200 POST api.anthropic.com/api/claude_code/metrics from 10.50.11.10:55001 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
reject 403 POST http-intake.logs.us5.datadoghq.com/api/v2/logs from 10.50.10.10:43494 allowlist
allow 200 POST api.anthropic.com/api/event_logging/v2/batch from 10.50.10.10:58543 secrets:{"swapped":[{"secret":"<secret-arn:anthropic>","locati
allow 200 POST sts.us-east-1.amazonaws.com/ from 10.50.11.10:60881 aws_auth:{"injected":["header:Authorization","header:X-Amz-Date","header:X-Amz-Content-Sha256","header:X-Amz-Security-Token"],"region":"us-east-1","s
allow 200 POST httpbin.org/post from 10.50.11.10:31405
=== Checks ======================================================
PASS no real token anywhere in the MicroVM (done)
PASS gh auth token is the placeholder (ghp_proxyplaceholder000000000000000000000)
PASS markdown reflection returns the placeholder (token: ghp_proxyplaceholder000000000000000000000)
PASS httpbin sees the placeholder, not the token ("Authorization": "token ghp_proxyplaceholder000000000000000000000)
PASS direct connection to a real IP fails (FAIL rc=28)
PASS python to a real IP fails (urllib.error.URLError: <urlopen error timed out>)
PASS python urllib with proxies disabled still goes through iron-proxy (200) (200)
PASS node fetch still goes through iron-proxy (200) (200)
PASS DNS over HTTPS by name: refused by the proxy (403) (403)
PASS DNS over HTTPS by IP: no route (000 FAIL rc=28)
PASS 169.254.170.2 not reachable directly (000 FAIL rc=7)
PASS proxy refuses a 169.254.170.2 Host (403) (403)
PASS proxy refuses its own address as a host (403) (403)
PASS a nip.io name for a private IP: 403 (403)
PASS a lookup carrying data gets the override (it never left the VPC) (CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10)
PASS no TXT data comes back in (CNAME proxy.sandbox.internal.)
PASS the execution role is visible at the metadata endpoint (execution_role)
PASS real credentials are re-signed as the task role through the proxy (arn:aws:sts::<acct>:assumed-role/my2cents-ip-task/0de98f37d3154cee8b3a42262454a78a)
PASS the role's credentials can be posted to an allowlisted host (httpbin received AccessKeyId ASIA... and a 40 char secret)
19 passed, 0 failed
The agent can read real temporary credentials from IMDS. Through the proxy they don’t work as themselves, because aws_auth re-signs every AWS call with the proxy’s role. But nothing stops the agent from posting them to a host on the allowlist, and from there they work anywhere until they expire. That’s why every MicroVM in this post runs without an execution role: the network protects what leaves, not secrets that are already inside the VM.
Test 8: Rotating and revoking
09_rotation.py uses the demo secret again, so you can watch the value httpbin receives while a MicroVM keeps sending the same placeholder:
=== Rotate the secret value (ttl is 30 s) =======================
put_secret_value at 04:15:44 -> demo-ROTATED-eb102aa3
t+ 0.2s httpbin got demo-REAL-value-c8efe7fb
t+ 27.1s httpbin got demo-REAL-value-c8efe7fb
t+ 32.4s httpbin got demo-ROTATED-eb102aa3
=== Revoke: deny the proxy's role access to the secret ==========
resource policy denies the task role; secret changed to demo-AFTER-REVOKE-ce2ed133 - sampling for 90 s
t+ 0.2s httpbin got demo-ROTATED-eb102aa3
t+ 91.3s httpbin got demo-ROTATED-eb102aa3
=== Force a new proxy task while access is denied ===============
redeploy took 226s
after restart, httpbin got: demo-placeholder-token (the placeholder, if the proxy forwards it unswapped)
status for a GitHub call meanwhile: fabiodouek
=== Restore access ==============================================
t+ 0.2s httpbin got demo-placeholder-token
t+ 53.7s httpbin got demo-placeholder-token
t+ 59.1s httpbin got demo-AFTER-REVOKE-ce2ed133
=== Rotate while the MicroVM is suspended =======================
after resume, httpbin got: demo-WHILE-SUSPENDED-3dbfa688
5 passed, 0 failed
Full output of 09_rotation.py
# 09_rotation 2026-11-04 04:15:38 GMT region us-east-1
=== Baseline ====================================================
now: demo-REAL-value-c8efe7fb
=== Rotate the secret value (ttl is 30 s) =======================
put_secret_value at 04:15:44 -> demo-ROTATED-eb102aa3
t+ 0.2s httpbin got demo-REAL-value-c8efe7fb
t+ 5.5s httpbin got demo-REAL-value-c8efe7fb
t+ 10.9s httpbin got demo-REAL-value-c8efe7fb
t+ 16.3s httpbin got demo-REAL-value-c8efe7fb
t+ 21.6s httpbin got demo-REAL-value-c8efe7fb
t+ 27.1s httpbin got demo-REAL-value-c8efe7fb
t+ 32.4s httpbin got demo-ROTATED-eb102aa3
=== Revoke: deny the proxy's role access to the secret ==========
resource policy denies the task role; secret changed to demo-AFTER-REVOKE-ce2ed133 - sampling for 90 s
t+ 0.2s httpbin got demo-ROTATED-eb102aa3
t+ 30.5s httpbin got demo-ROTATED-eb102aa3
t+ 60.9s httpbin got demo-ROTATED-eb102aa3
t+ 91.3s httpbin got demo-ROTATED-eb102aa3
=== Force a new proxy task while access is denied ===============
redeploy took 226s
after restart, httpbin got: demo-placeholder-token (the placeholder, if the proxy forwards it unswapped)
status for a GitHub call meanwhile: fabiodouek
=== Restore access ==============================================
t+ 0.2s httpbin got demo-placeholder-token
t+ 5.5s httpbin got demo-placeholder-token
t+ 10.8s httpbin got demo-placeholder-token
t+ 16.2s httpbin got demo-placeholder-token
t+ 21.6s httpbin got demo-placeholder-token
t+ 26.9s httpbin got demo-placeholder-token
t+ 32.3s httpbin got demo-placeholder-token
t+ 37.6s httpbin got demo-placeholder-token
t+ 43.0s httpbin got demo-placeholder-token
t+ 48.3s httpbin got demo-placeholder-token
t+ 53.7s httpbin got demo-placeholder-token
t+ 59.1s httpbin got demo-AFTER-REVOKE-ce2ed133
=== Rotate while the MicroVM is suspended =======================
after resume, httpbin got: demo-WHILE-SUSPENDED-3dbfa688
=== Checks ======================================================
PASS rotation picked up without touching the sandbox
PASS after revoking, the cached value keeps being injected ({'demo-ROTATED-eb102aa3'})
PASS after a restart with no access, the proxy fails open (forwards the placeholder) (demo-placeholder-token)
PASS access restored: the new value is injected
PASS a MicroVM suspended during a rotation uses the new value on resume (demo-WHILE-SUSPENDED-3dbfa688)
5 passed, 0 failed
Rotation works without touching the sandbox: a new value in Secrets Manager was in use 32.4 s later: the 30 s cache, plus up to one 5 s sampling interval. A MicroVM that was suspended during a rotation used the new value as soon as it resumed, since nothing about the secret lives in the VM.
Revocation is a different story. Denying the proxy’s role access to the secret, and changing the secret at the same time, didn’t stop anything: the proxy kept injecting the last value it had fetched for as long as I watched (the last sample, at t+91.3 s), because a failed refresh keeps serving the cached value. Only a restart cleared it (the forced redeploy took 226 s), and then the proxy failed open: with no value to inject, it forwarded the placeholder unchanged instead of refusing the request. After access came back, the next successful fetch took 59.1 s, because iron-proxy caches a failed fetch for a minute.
So the kill switch for a leaked or misused credential is at its source (revoke the GitHub token, disable the Anthropic key), not at the proxy. Cutting the proxy’s access only works if you also restart it.
Test 9: Two MicroVMs, one proxy
10_shared_proxy.py starts MicroVM B with no placeholders at all:
=== A: has the placeholder ======================================
A env GH_TOKEN: ghp_proxyplaceholder000000000000000000000 | gh api user: fabiodouek
=== B: started with no placeholders =============================
B env GH_TOKEN: unset
B gh api user, no token: Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.
B with the placeholder typed in: fabiodouek
B curl with the placeholder: "login": "fabiodouek
=== The audit log: can you tell A from B? =======================
allow 200 GET api.github.com/user from 10.50.11.10:58146 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 GET api.github.com/user from 10.50.11.10:51772 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 GET api.github.com/user from 10.50.11.10:45178 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
audit fields: ['action', 'duration_ms', 'host', 'method', 'mode', 'path', 'remote_addr', 'sni', 'status_code']
client addresses seen: ['10.50.11.10']
2 passed, 0 failed
The placeholder isn’t a secret (it’s in this post), so it’s not an access control. Any MicroVM that can reach the proxy can use every credential the proxy holds, and the audit log can’t say which MicroVM asked: every request comes from the NLB’s addresses. It couldn’t do much better even with the client’s address, since every MicroVM on a connector shares its two network interfaces; the Resolver’s query log has the same limit. That’s fine for one user’s sandboxes. For several, it means a proxy per tenant, or proxy authentication, which iron-proxy doesn’t have yet.
Test 10: Path rules and require
The allowlist so far is per host: anything on api.github.com is allowed, and the GitHub token’s own scope does the rest. iron-proxy can do better. Rules can restrict methods and paths, and require: true on a secret rejects requests to its hosts that don’t carry the placeholder. 11_path_rules.py applies a strict variant that limits GitHub to one repository and marks the GitHub secret require: true:
rules:
- host: "api.github.com"
paths: ["/repos/fabiodouek/my2centsai-sandbox-test", "/repos/fabiodouek/my2centsai-sandbox-test/*", "/user", "/graphql"]
- host: "github.com"
paths: ["/fabiodouek/my2centsai-sandbox-test.git/*", "/fabiodouek/my2centsai-sandbox-test/*"]
Then it runs the probes in a MicroVM with no proxy settings, and puts the default rules back. Each switch is a terraform apply and a new iron-proxy task: 250 s until the strict variant was serving, and 261 s to revert.
=== terraform apply strict=true =================================
applied; the new iron-proxy task is serving after 250s
=== Transparent: no proxy settings ==============================
api.github.com resolves to: 10.50.10.10 10.50.11.10 | proxy env: none
gh api user fabiodouek
gh api repos/fabiodouek/my2centsai-sandbox-test fabiodouek/my2centsai-sandbox-test
git ls-remote fabiodouek/my2centsai-sandbox-test fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': The requested URL return
the same, placeholder sent up front (http.extraHeader) 83732231ad715d609714d688e2ead9b51546c90b HEAD
git clone fabiodouek/my2centsai-sandbox-test (http.extraHeader) 8373223 Initial commit
gh api repos/torvalds/linux (other repo) unexpected end of JSON input
gh gist create (POST /gists) X Failed to create gist: HTTP 403: 403 Forbidden (https://api.github.com/gists)
git ls-remote torvalds/linux (http.extraHeader) fatal: unable to access 'https://github.com/torvalds/linux.git/': The requested URL returned error: 403
curl api.github.com/user without the placeholder 403
curl api.github.com/user with the placeholder 200
python urllib, proxies disabled 200
node fetch (never reads HTTPS_PROXY) 200
Claude Code (api.anthropic.com has no path rule) ok
=== iron-proxy audit, transparent (github) ======================
reject 403 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.11.10:52854 secrets secrets:{"rejected":"<secret-arn:github>"}
reject 403 GET api.github.com/repos/torvalds/linux from 10.50.11.10:36205 allowlist
reject 403 POST api.github.com/gists from 10.50.11.10:34049 allowlist
reject 403 GET github.com/torvalds/linux.git/info/refs from 10.50.10.10:11616 allowlist
reject 403 GET api.github.com/user from 10.50.10.10:58006 secrets secrets:{"rejected":"<secret-arn:github>"}
=== terraform apply strict=false (revert) =======================
reverted; the new iron-proxy task is serving after 261s
20 passed, 0 failed
Full output of 11_path_rules.py
# 11_path_rules 2026-11-04 04:25:00 GMT region us-east-1
=== terraform apply strict=true =================================
applied; the new iron-proxy task is serving after 250s
=== Transparent: no proxy settings ==============================
api.github.com resolves to: 10.50.10.10 10.50.11.10 | proxy env: none
gh api user fabiodouek
gh api repos/fabiodouek/my2centsai-sandbox-test fabiodouek/my2centsai-sandbox-test
git ls-remote fabiodouek/my2centsai-sandbox-test fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': The requested URL return
the same, placeholder sent up front (http.extraHeader) 83732231ad715d609714d688e2ead9b51546c90b HEAD
git clone fabiodouek/my2centsai-sandbox-test (http.extraHeader) 8373223 Initial commit
gh api repos/torvalds/linux (other repo) unexpected end of JSON input
gh gist create (POST /gists) X Failed to create gist: HTTP 403: 403 Forbidden (https://api.github.com/gists)
git ls-remote torvalds/linux (http.extraHeader) fatal: unable to access 'https://github.com/torvalds/linux.git/': The requested URL returned error: 403
curl api.github.com/user without the placeholder 403
curl api.github.com/user with the placeholder 200
python urllib, proxies disabled 200
node fetch (never reads HTTPS_PROXY) 200
Claude Code (api.anthropic.com has no path rule) ok
=== iron-proxy audit, transparent (github) ======================
allow 200 GET api.github.com/user from 10.50.11.10:21420 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 GET api.github.com/repos/fabiodouek/my2centsai-sandbox-test from 10.50.11.10:59639 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
reject 403 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.11.10:52854 secrets secrets:{"rejected":"<secret-arn:github>"}
allow 200 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.11.10:40234 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 POST github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack from 10.50.11.10:40234 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 GET github.com/fabiodouek/my2centsai-sandbox-test.git/info/refs from 10.50.10.10:44858 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 POST github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack from 10.50.10.10:44858 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
allow 200 POST github.com/fabiodouek/my2centsai-sandbox-test.git/git-upload-pack from 10.50.10.10:44858 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
reject 403 GET api.github.com/repos/torvalds/linux from 10.50.11.10:36205 allowlist
reject 403 POST api.github.com/gists from 10.50.11.10:34049 allowlist
reject 403 GET github.com/torvalds/linux.git/info/refs from 10.50.10.10:11616 allowlist
reject 403 GET api.github.com/user from 10.50.10.10:58006 secrets secrets:{"rejected":"<secret-arn:github>"}
allow 200 GET api.github.com/user from 10.50.10.10:4359 secrets:{"swapped":[{"secret":"<secret-arn:github>","locations
=== Explicit: HTTPS_PROXY to the NLB on 8080 ====================
api.github.com resolves to: 10.50.10.10 10.50.11.10 | proxy env: http://10.50.10.10:8080
gh api user Get "https://api.github.com/user": Forbidden
gh api repos/fabiodouek/my2centsai-sandbox-test Get "https://api.github.com/repos/fabiodouek/my2centsai-sandbox-test": Forbidden
git ls-remote fabiodouek/my2centsai-sandbox-test fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': CONNECT tunnel failed, r
the same, placeholder sent up front (http.extraHeader) fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': CONNECT tunnel failed, r
git clone fabiodouek/my2centsai-sandbox-test (http.extraHeader) fatal: cannot change to 'r2': No such file or directory
gh api repos/torvalds/linux (other repo) Get "https://api.github.com/repos/torvalds/linux": Forbidden
gh gist create (POST /gists) X Failed to create gist: Post "https://api.github.com/gists": Forbidden
git ls-remote torvalds/linux (http.extraHeader) fatal: unable to access 'https://github.com/torvalds/linux.git/': CONNECT tunnel failed, response 403
curl api.github.com/user without the placeholder 000
curl api.github.com/user with the placeholder 000
python urllib, proxies disabled 200
node fetch (never reads HTTPS_PROXY) 200
Claude Code (api.anthropic.com has no path rule) ok
=== iron-proxy audit, explicit (github) =========================
reject 403 CONNECT api.github.com:443 from 10.50.10.10:26011 allowlist
reject 403 CONNECT api.github.com:443 from 10.50.10.10:14380 allowlist
reject 403 CONNECT github.com:443 from 10.50.10.10:49353 allowlist
reject 403 CONNECT github.com:443 from 10.50.10.10:23847 allowlist
reject 403 CONNECT github.com:443 from 10.50.10.10:2303 allowlist
reject 403 CONNECT api.github.com:443 from 10.50.10.10:65215 allowlist
reject 403 CONNECT api.github.com:443 from 10.50.10.10:32826 allowlist
reject 403 CONNECT github.com:443 from 10.50.10.10:53762 allowlist
reject 403 CONNECT api.github.com:443 from 10.50.10.10:47052 allowlist
reject 403 CONNECT api.github.com:443 from 10.50.10.10:18190 allowlist
=== terraform apply strict=false (revert) =======================
reverted; the new iron-proxy task is serving after 261s
=== Side by side ================================================
transparent explicit
gh api user fabiodouek Get "https://api.github.com/user": Forbi
gh api repos/fabiodouek/my2centsai-sandbox-test fabiodouek/my2centsai-sand Get "https://api.github.com/repos/fabiod
git ls-remote fabiodouek/my2centsai-sandbox-test fatal: unable to access 'h fatal: unable to access 'https://github.
the same, placeholder sent up front (http.extraHeader) 83732231ad715d609714d688e2 fatal: unable to access 'https://github.
git clone fabiodouek/my2centsai-sandbox-test (http.extraHeader) 8373223 Initial commit fatal: cannot change to 'r2': No such fi
gh api repos/torvalds/linux (other repo) unexpected end of JSON inp Get "https://api.github.com/repos/torval
gh gist create (POST /gists) X Failed to create gist: H X Failed to create gist: Post "https://a
git ls-remote torvalds/linux (http.extraHeader) fatal: unable to access 'h fatal: unable to access 'https://github.
curl api.github.com/user without the placeholder 403 000
curl api.github.com/user with the placeholder 200 000
python urllib, proxies disabled 200 200
node fetch (never reads HTTPS_PROXY) 200 200
Claude Code (api.anthropic.com has no path rule) ok ok
=== Checks ======================================================
PASS transparent: gh api user (fabiodouek)
PASS transparent: the allowed repo (fabiodouek/my2centsai-sandbox-test)
PASS transparent: git without credentials up front is refused by require (fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': The requested URL return)
PASS transparent: git with http.extraHeader passes require (83732231ad715d609714d688e2ead9b51546c90b HEAD)
PASS transparent: git clone with http.extraHeader (8373223 Initial commit)
PASS transparent: another repo refused over REST (unexpected end of JSON input)
PASS transparent: gist refused (X Failed to create gist: HTTP 403: 403 Forbidden (https://api.github.com/gists))
PASS transparent: another repo refused over git (fatal: unable to access 'https://github.com/torvalds/linux.git/': The requested URL returned error: 403)
PASS transparent: no placeholder, refused (require) (403)
PASS transparent: with the placeholder, 200 (200)
PASS transparent: python urllib works (200)
PASS transparent: node fetch works (200)
PASS transparent: Claude Code works (ok)
PASS transparent: no CONNECT records
PASS transparent: a require rejection logged by secrets
PASS explicit: gh api user refused at CONNECT (Get "https://api.github.com/user": Forbidden)
PASS explicit: even the allowed repo refused (Get "https://api.github.com/repos/fabiodouek/my2centsai-sandbox-test": Forbidden)
PASS explicit: git with http.extraHeader refused at CONNECT (fatal: unable to access 'https://github.com/fabiodouek/my2centsai-sandbox-test.git/': CONNECT tunnel failed, r)
PASS explicit: Claude Code still works (ok)
PASS explicit: GitHub CONNECTs rejected in the audit log (10 records)
20 passed, 0 failed
Transparent interception is what makes these rules usable. With no proxy settings, one repository is reachable, and everything else on GitHub (other repositories over REST and git, gists) is refused by the proxy rather than by the token. gh’s unexpected end of JSON input is how it reports the proxy’s 403, which has an empty body. A request without the placeholder is rejected by the secret’s require. Clients that ignore proxy settings work too.
git needs one adjustment. It sends its first request without credentials and only adds them after a 401, and require: true answers that first request with a 403, so git never gets to try. Sending the placeholder up front with http.extraHeader fixes it: with it, ls-remote and clone of the allowed repository work.
For comparison, the script repeats the probes in a second MicroVM with HTTPS_PROXY set to the NLB’s port 8080 (in the full output above), and there the same rules refused every GitHub request, even for the allowed repository, because iron-proxy 0.55.0 checks the CONNECT tunnel with an empty path and only the CONNECT’s own headers (issue #222 for require; open pull request #295 fixes the path rules).
Test 11: DNS: the override, no recursion, and removing the firewall
12_dns_firewall.py checks the override from inside a MicroVM. The dig answer under How the pieces work comes from this run; the excerpt below shows what glibc, Python and Node (both its resolvers) return, then seven record types, and all four resolver addresses the MicroVM can reach over UDP, with TCP to two of them:
getent hosts ri8xq3isr.example.com 10.50.11.10 proxy.sandbox.internal ri8xq3isr.example.com
python3 socket.getaddrinfo('api.github.com') ['10.50.10.10', '10.50.11.10']
node dns.lookup (getaddrinfo) 10.50.10.10 10.50.11.10
node dns.resolve4 (c-ares, its own resolver) 10.50.10.10 10.50.11.10
=== Every record type gets only the override ====================
api.github.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.10.10, A 10.50.11.10
api.github.com AAAA NOERROR CNAME proxy.sandbox.internal.
api.github.com TXT NOERROR CNAME proxy.sandbox.internal.
<random>.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
-x 1.1.1.1 NOERROR CNAME proxy.sandbox.internal.
-x 140.82.112.3 NOERROR CNAME proxy.sandbox.internal.
=== Every resolver address, over UDP and TCP ====================
@10.51.0.2 <random>-0.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.169.253 <random>-1.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@127.0.0.2 <random>-2.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.53.53 <random>-3.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.10.10, A 10.50.11.10
@10.51.0.2 google.com TXT NOERROR CNAME proxy.sandbox.internal.
@10.51.0.2 +tcp <random>-tcp0.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.169.253 +tcp <random>-tcp1.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
Then it checks that nothing recursed. An override answer alone proves little, so the script uses names whose real answer is predictable, where getting the real answer would mean the query was resolved on the internet. nip.io and sslip.io answer a name with the address embedded in it (10-1-2-3.nip.io is 10.1.2.3), and some TXT records exist only at the domain’s own name servers. That a blocked query never leaves at all is AWS’s documented BLOCK behaviour, not something this test can see:
=== Nothing recursed: names whose real answer is predictable ====
10-1-2-3.nip.io MicroVM: 10.50.10.10, 10.50.11.10 this machine: 10.1.2.3
rt6xamdbb.10.1.2.3.nip.io MicroVM: 10.50.10.10, 10.50.11.10 this machine: 10.1.2.3
rt6xamdbb-10-1-2-3.nip.io MicroVM: 10.50.10.10, 10.50.11.10 this machine: 10.1.2.3
1.2.3.4.sslip.io MicroVM: 10.50.10.10, 10.50.11.10 this machine: 1.2.3.4
rt6xamdbb.1.2.3.4.sslip.io MicroVM: 10.50.10.10, 10.50.11.10 this machine: 1.2.3.4
=== Nothing recursed: TXT records only an authoritative server can give
google.com TXT
MicroVM: NOERROR CNAME proxy.sandbox.internal.
this machine: 17 TXT record(s): "cisco-ci-domain-verification=47c38bc8c4b74b7233e9053220c1bbe76bcc1cd33c7acf7acd36cd6a5332004b" | "Z29vZ2xl" | "v=spf1 include:_spf.google.com ~all" | "docusign=1b0a6754-49b1-4db5-8540-d2c12664b289" | "_r4rd1pvwyrpi7sw4a3hzmw8e51yh9td" | "onetrust-domain-verification=0d477fe608074e6f9c12bca7826035cc
o-o.myaddr.l.google.com TXT
MicroVM: NOERROR CNAME proxy.sandbox.internal.
this machine: 1 TXT record(s)
=== Resolver query log for the sandbox VPC: the firewall action on each query
31 records, 27 unique name/type pairs (the Resolver logs only what it didn't answer from cache)
A api.github.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
A 10-1-2-3.nip.io. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
PTR 3.112.82.140.in-addr.arpa. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
firewall_rule_action counts: {'BLOCK': 31}
The MicroVM got the NLB for every one of them, my laptop got the real answers, and every query in the Resolver’s log carries the firewall’s BLOCK action, with the override in its answer.
Last, the misconfiguration the design depends on not happening: removing the firewall association.
=== Misconfiguration: remove the firewall association ===========
association COMPLETE, mutation protection ENABLED
disassociate with protection on -> ValidationException: [RSLVR-02307] The DNS Firewall rule group association is under mutation protection. Remove the mutation protection and t
mutation protection switched off (0s)
DisassociateFirewallRuleGroup called
[ 0s] <random>.10.1.2.3.nip.io -> ['10.1.2.3']: recursion is back
[ 69s] association gone
google.com TXT NOERROR TXT "MS=E4A68B9AB2BB9670BCE15412F62916164C0B20BB", TXT "arcules-domain-verification=2R4t5G7nI6tRCo61lpxxbLksPYstxhGmtV75cpr8x9K", TXT "google-site-verification=
rqi4jk5zg.1.2.3.4.sslip.io A NOERROR A 1.2.3.4
curl https://example.com (now its real IP; the routes still block it): 000 FAIL rc=28
=== The EventBridge alarm =======================================
2026-11-04T04:34:52Z DisassociateFirewallRuleGroup ValidationException
2026-11-04T04:34:52Z DisassociateFirewallRuleGroup succeeded
2026-11-04T04:34:52Z UpdateFirewallRuleGroupAssociation succeeded
=== Restore: terraform apply (the association, protection back on)
terraform apply: 70s
override back 70s after the apply started
new association: COMPLETE, mutation protection ENABLED
44 passed, 0 failed
Full output of 12_dns_firewall.py
# 12_dns_firewall 2026-11-04 04:34:38 GMT region us-east-1
MicroVM microvm-314902c1-9144-391b-8910-fd54faf8a811 | proxy env: none | NLB ['10.50.10.10', '10.50.11.10']
resolv.conf:
nameserver 127.0.0.2
nameserver 169.254.53.53
search .
=== One answer: the override CNAME and both NLB addresses =======
$ dig api.github.com
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 5180
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 1
api.github.com. 60 IN CNAME proxy.sandbox.internal.
proxy.sandbox.internal. 60 IN A 10.50.10.10
proxy.sandbox.internal. 60 IN A 10.50.11.10
;; Query time: 10 msec
;; SERVER: 127.0.0.2#53(127.0.0.2) (UDP)
getent hosts ri8xq3isr.example.com 10.50.11.10 proxy.sandbox.internal ri8xq3isr.example.com
python3 socket.getaddrinfo('api.github.com') ['10.50.10.10', '10.50.11.10']
node dns.lookup (getaddrinfo) 10.50.10.10 10.50.11.10
node dns.resolve4 (c-ares, its own resolver) 10.50.10.10 10.50.11.10
=== Every record type gets only the override ====================
api.github.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.10.10, A 10.50.11.10
api.github.com AAAA NOERROR CNAME proxy.sandbox.internal.
api.github.com TXT NOERROR CNAME proxy.sandbox.internal.
api.github.com MX NOERROR CNAME proxy.sandbox.internal.
api.github.com TYPE65 NOERROR CNAME proxy.sandbox.internal.
api.github.com NS NOERROR CNAME proxy.sandbox.internal.
<random>.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
<random>.example.com AAAA NOERROR CNAME proxy.sandbox.internal.
<random>.example.com TXT NOERROR CNAME proxy.sandbox.internal.
<random>.example.com MX NOERROR CNAME proxy.sandbox.internal.
<random>.example.com TYPE65 NOERROR CNAME proxy.sandbox.internal.
<random>.example.com NS NOERROR CNAME proxy.sandbox.internal.
-x 1.1.1.1 NOERROR CNAME proxy.sandbox.internal.
-x 140.82.112.3 NOERROR CNAME proxy.sandbox.internal.
=== Every resolver address, over UDP and TCP ====================
@10.51.0.2 <random>-0.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.169.253 <random>-1.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@127.0.0.2 <random>-2.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.53.53 <random>-3.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.10.10, A 10.50.11.10
@10.51.0.2 google.com TXT NOERROR CNAME proxy.sandbox.internal.
@169.254.169.253 google.com TXT NOERROR CNAME proxy.sandbox.internal.
@127.0.0.2 google.com TXT NOERROR CNAME proxy.sandbox.internal.
@169.254.53.53 google.com TXT NOERROR CNAME proxy.sandbox.internal.
@10.51.0.2 +tcp <random>-tcp0.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
@169.254.169.253 +tcp <random>-tcp1.example.com A NOERROR CNAME proxy.sandbox.internal., A 10.50.11.10, A 10.50.10.10
=== Nothing recursed: names whose real answer is predictable ====
10-1-2-3.nip.io MicroVM: 10.50.10.10, 10.50.11.10 this machine: 10.1.2.3
rt6xamdbb.10.1.2.3.nip.io MicroVM: 10.50.10.10, 10.50.11.10 this machine: 10.1.2.3
rt6xamdbb-10-1-2-3.nip.io MicroVM: 10.50.10.10, 10.50.11.10 this machine: 10.1.2.3
1.2.3.4.sslip.io MicroVM: 10.50.10.10, 10.50.11.10 this machine: 1.2.3.4
rt6xamdbb.1.2.3.4.sslip.io MicroVM: 10.50.10.10, 10.50.11.10 this machine: 1.2.3.4
=== Nothing recursed: TXT records only an authoritative server can give
google.com TXT
MicroVM: NOERROR CNAME proxy.sandbox.internal.
this machine: 17 TXT record(s): "cisco-ci-domain-verification=47c38bc8c4b74b7233e9053220c1bbe76bcc1cd33c7acf7acd36cd6a5332004b" | "Z29vZ2xl" | "v=spf1 include:_spf.google.com ~all" | "docusign=1b0a6754-49b1-4db5-8540-d2c12664b289" | "_r4rd1pvwyrpi7sw4a3hzmw8e51yh9td" | "onetrust-domain-verification=0d477fe608074e6f9c12bca7826035cc
o-o.myaddr.l.google.com TXT
MicroVM: NOERROR CNAME proxy.sandbox.internal.
this machine: 1 TXT record(s)
=== Misconfiguration: remove the firewall association ===========
association COMPLETE, mutation protection ENABLED
disassociate with protection on -> ValidationException: [RSLVR-02307] The DNS Firewall rule group association is under mutation protection. Remove the mutation protection and t
mutation protection switched off (0s)
DisassociateFirewallRuleGroup called
[ 0s] <random>.10.1.2.3.nip.io -> ['10.1.2.3']: recursion is back
[ 69s] association gone
google.com TXT NOERROR TXT "MS=E4A68B9AB2BB9670BCE15412F62916164C0B20BB", TXT "arcules-domain-verification=2R4t5G7nI6tRCo61lpxxbLksPYstxhGmtV75cpr8x9K", TXT "google-site-verification=
rqi4jk5zg.1.2.3.4.sslip.io A NOERROR A 1.2.3.4
curl https://example.com (now its real IP; the routes still block it): 000 FAIL rc=28
=== The EventBridge alarm =======================================
2026-11-04T04:34:52Z DisassociateFirewallRuleGroup ValidationException
2026-11-04T04:34:52Z DisassociateFirewallRuleGroup succeeded
2026-11-04T04:34:52Z UpdateFirewallRuleGroupAssociation succeeded
=== Restore: terraform apply (the association, protection back on)
terraform apply: 70s
override back 70s after the apply started
new association: COMPLETE, mutation protection ENABLED
=== Resolver query log for the sandbox VPC: the firewall action on each query
31 records, 27 unique name/type pairs (the Resolver logs only what it didn't answer from cache)
A ri8xq3isr.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=UDP
A api.github.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
AAAA ri8xq3isr.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A r184ryt4v-2.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=UDP
A rt6xamdbb-10-1-2-3.nip.io. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
TXT api.github.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
TXT google.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
NS rxyn2ba6i.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
MX rxyn2ba6i.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A 10-1-2-3.nip.io. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
A r184ryt4v-1.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=UDP
MX api.github.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A r184ryt4v-tcp1.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=TCP
NS api.github.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
PTR 3.112.82.140.in-addr.arpa. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A rt6xamdbb.1.2.3.4.sslip.io. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
A 1.2.3.4.sslip.io. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
TYPE65 api.github.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
TXT rxyn2ba6i.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A r184ryt4v-0.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=UDP
PTR 1.1.1.1.in-addr.arpa. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A r184ryt4v-tcp0.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=TCP
TYPE65 rxyn2ba6i.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.] via=UDP
A rt6xamdbb.10.1.2.3.nip.io. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.11.10,A:10.50.10.10] via=UDP
A r184ryt4v-3.example.com. NOERROR firewall=BLOCK answers=[CNAME:proxy.sandbox.internal.,A:10.50.10.10,A:10.50.11.10] via=UDP
firewall_rule_action counts: {'BLOCK': 31}
=== Checks ======================================================
PASS dig: override CNAME to proxy.sandbox.internal (CNAME proxy.sandbox.internal.)
PASS dig: both NLB A records in the same answer
PASS getent hosts ri8xq3isr.example.com: the NLB IPs only (10.50.10.10 10.50.11.10)
PASS python3 socket.getaddrinfo('api.github.com'): the NLB IPs only (10.50.10.10 10.50.11.10)
PASS node dns.lookup (getaddrinfo): the NLB IPs only (10.50.10.10 10.50.11.10)
PASS node dns.resolve4 (c-ares, its own resolver): the NLB IPs only (10.50.10.10 10.50.11.10)
PASS dig api.github.com A: override only (NOERROR)
PASS dig api.github.com AAAA: override only (NOERROR)
PASS dig api.github.com TXT: override only (NOERROR)
PASS dig api.github.com MX: override only (NOERROR)
PASS dig api.github.com TYPE65: override only (NOERROR)
PASS dig api.github.com NS: override only (NOERROR)
PASS dig <random>.example.com A: override only (NOERROR)
PASS dig <random>.example.com AAAA: override only (NOERROR)
PASS dig <random>.example.com TXT: override only (NOERROR)
PASS dig <random>.example.com MX: override only (NOERROR)
PASS dig <random>.example.com TYPE65: override only (NOERROR)
PASS dig <random>.example.com NS: override only (NOERROR)
PASS dig -x 1.1.1.1: override only (NOERROR)
PASS dig -x 140.82.112.3: override only (NOERROR)
PASS dig @10.51.0.2 <random>-0.example.com A: override only (NOERROR)
PASS dig @169.254.169.253 <random>-1.example.com A: override only (NOERROR)
PASS dig @127.0.0.2 <random>-2.example.com A: override only (NOERROR)
PASS dig @169.254.53.53 <random>-3.example.com A: override only (NOERROR)
PASS dig @10.51.0.2 google.com TXT: override only (NOERROR)
PASS dig @169.254.169.253 google.com TXT: override only (NOERROR)
PASS dig @127.0.0.2 google.com TXT: override only (NOERROR)
PASS dig @169.254.53.53 google.com TXT: override only (NOERROR)
PASS dig @10.51.0.2 +tcp <random>-tcp0.example.com A: override only (NOERROR)
PASS dig @169.254.169.253 +tcp <random>-tcp1.example.com A: override only (NOERROR)
PASS 10-1-2-3.nip.io: the NLB IPs, not the embedded 10.1.2.3 (10.50.10.10, 10.50.11.10)
PASS rt6xamdbb.10.1.2.3.nip.io: the NLB IPs, not the embedded 10.1.2.3 (10.50.10.10, 10.50.11.10)
PASS rt6xamdbb-10-1-2-3.nip.io: the NLB IPs, not the embedded 10.1.2.3 (10.50.10.10, 10.50.11.10)
PASS 1.2.3.4.sslip.io: the NLB IPs, not the embedded 1.2.3.4 (10.50.10.10, 10.50.11.10)
PASS rt6xamdbb.1.2.3.4.sslip.io: the NLB IPs, not the embedded 1.2.3.4 (10.50.10.10, 10.50.11.10)
PASS google.com TXT: no real TXT data in the MicroVM (MicroVM 0 TXT, this machine 17)
PASS o-o.myaddr.l.google.com TXT: no real TXT data in the MicroVM (MicroVM 0 TXT, this machine 1)
PASS mutation protection refuses DisassociateFirewallRuleGroup (ValidationException: [RSLVR-02307] The DNS Firewall rule group association is under mutation protection. Remove the mutation protection and t)
PASS without the association, recursion returns (10.1.2.3.nip.io -> 10.1.2.3) (after 0s)
PASS the fence still blocks the real IP (000 FAIL rc=28)
PASS EventBridge alarm fired on the disassociation (readable 81s after the call)
PASS override restored by terraform apply (70s)
PASS the new association has mutation protection on (ENABLED)
PASS every logged query carries a firewall action ({'BLOCK': 31})
44 passed, 0 failed
With mutation protection on, the removal call was refused (RSLVR-02307). Switching protection off took one more call and was immediate. After that, removal worked, and recursion was back at the very first probe, even though the association took 69 s to disappear from the API: <random>.10.1.2.3.nip.io resolved to 10.1.2.3, and Google’s real TXT records came back. The routes still blocked a connection to the real address. The EventBridge alarm recorded the refused attempt, the switch and the removal, readable in the log group 81 s after the call. A terraform apply of the association took 70 s, and the override was back when it finished, with protection on again.
So removing the association takes effect at once and takes two deliberate calls. Changing the rule group’s rules or domain lists would take one, and mutation protection doesn’t cover them; I didn’t test that path. The alarm tells you a change happened, a minute or so later; it doesn’t close the gap, and DNS stays a channel out until someone puts it back. Those permissions belong to administrators only.
Test 12: Latency
14_latency.py sends the same request (GET https://api.github.com/zen with the placeholder token) three ways from one MicroVM, 10 interleaved rounds after two warm-ups, each a fresh curl with a new TCP and TLS handshake: through the override, with --resolve to skip DNS, and, for comparison, through a CONNECT tunnel on 8080. Then it times lookups through the override:
=== HTTP: median of 10 requests each (seconds; curl's cumulative timers)
variant dns connect tls done ttfb total min-max total codes
transparent 0.0004 0.0029 0.0374 0.1406 0.1407 0.133-0.164 ['200']
transparent, pinned 0.0000 0.0039 0.0370 0.1413 0.1414 0.129-0.157 ['200']
explicit (8080) 0.0000 0.0041 0.0397 0.1504 0.1505 0.138-0.166 ['200']
transparent - explicit: -9.8 ms median total; DNS share of transparent: 0.4 ms
=== DNS through the override ====================================
dig, random name (Resolver cache miss) median 0.0 ms [0, 0, 0, 10, 10, 0, 0, 10, 10, 0]
dig, api.github.com (repeated) median 0.0 ms [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
getaddrinfo, random name (A + AAAA) median 4.9 ms [6.77, 5.16, 4.74, 3.89, 4.26, 4.92, 4.95, 4.88, 5.04, 4.58]
getaddrinfo, api.github.com (repeated) median 0.06 ms [0.09, 0.07, 0.07, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06, 0.06]
4 passed, 0 failed
Going transparent costs nothing measurable: 141 ms median, against 150 ms through a CONNECT tunnel, a difference inside the run-to-run spread. DNS took 0.4 ms of that. Through getaddrinfo, which asks for A and AAAA, a lookup of a new name took 4.9 ms median, and a repeated name 0.06 ms; dig reported 0 or 10 ms.
What iron-proxy does not solve
- Tenancy. The proxy can’t tell one MicroVM from another (every request comes from the NLB’s addresses), and placeholders aren’t secrets. Every sandbox that can reach a proxy can use all of its credentials, and the audit log can’t say which sandbox did. A proxy per tenant is the only isolation available today; per-sandbox identity at the proxy is future work.
- Exfiltration to allowed hosts. Anything the agent can read, it can post to an allowed API: httpbin here, a gist or an issue on GitHub in real life. The real secrets are safe because they never enter the VM, but data and anything else in the VM (like an execution role’s credentials) are not. Path rules narrow this now that they work, as in Test 10, but only for hosts you write them for.
- Revocation at the proxy. A cached secret keeps being injected after the proxy loses access to it, and after a restart the proxy forwards the placeholder instead of failing. Revoke credentials at their source. Part 3’s optional control plane revokes at the proxy with a deny statement and a config reload instead of a restart, which takes effect within about 30 s.
- Plain HTTP. Secrets are swapped into
http://requests too, so the real value can cross the internet unencrypted if an agent asks for it. - AWS uploads and awkward keys. The CLI’s chunked uploads are rejected unless you allow chunked bodies or unsigned payloads, and object keys that need URL encoding don’t re-sign correctly in 0.55.0 (iron-proxy issue #220, closed without a merged fix; pull request #256 is still open).
- CONNECT. If you can’t use transparent interception, path rules and
requirerefuse every tunnel in 0.55.0, so anHTTPS_PROXYsetup gets host-level policy only. - Operations. It’s a pre-1.0 project with releases every week or two lately, after a two-month summer gap, and one main maintainer, the images aren’t signed (pin by digest), and a single Fargate task is a single point of failure for every sandbox behind it. DNS should keep answering if the task is down (untested), but nothing gets through.
Pricing
The proxy layer is fixed infrastructure that runs whether or not any sandbox does (us-east-1 list prices):
| Resource | Rate | Per hour |
|---|---|---|
| NAT gateway | $0.045 / h + $0.045 / GB processed | $0.045 |
| Network Load Balancer | $0.0225 / h + capacity units | $0.0225 |
| Fargate, ARM, 0.5 vCPU / 1 GB | $0.03238 / vCPU-h, $0.00356 / GB-h | $0.020 |
| Public IPv4 (NAT gateway) | $0.005 / h | $0.005 |
| Secrets Manager (4 secrets) | $0.40 / secret-month + API calls | $0.002 |
| Route 53 private hosted zone | $0.50 / month | $0.0007 |
| VPC peering | no hourly charge; $0.01 / GB each way across AZs | $0 |
| Fixed total | ≈ $0.095 / h, ≈ $69 / month |
The second VPC costs nothing by itself, so the split adds only the private zone to the fixed line. On top of that, per use: MicroVMs at part 1’s rates, DNS Firewall at $0.60 per million queries (the two one-entry domain lists cost a fraction of a cent a month), CloudWatch Logs for the audit trail and the query log, data through the NAT gateway, and cross-AZ data (over the peering, and between the NLB, the task and the NAT gateway). For one developer that’s mostly the fixed line. For a fleet, the NAT gateway’s per-GB charge is the one to watch, since every byte the agents fetch goes through it; DNS Firewall would need a million lookups to cost 60 cents.
Cleanup
python 13_cleanup.py # terminate this repo's MicroVMs, delete the image (it bills snapshot storage)
scripts/destroy.sh # switch off mutation protection, then terraform destroy (about 5 minutes on a twin stack)
13_cleanup.py goes first, because the connector can’t be deleted while MicroVMs use it and the image isn’t managed by Terraform. A plain terraform destroy fails on the protected firewall association, so scripts/destroy.sh switches the protection off first (the alarm logs that too).
Verdict
The two-VPC version is the one I’d build. The single-VPC version worked, but it depended on every client honouring HTTPS_PROXY, gave up iron-proxy’s path rules, and needed a DNS allowlist shared with the proxy’s own infrastructure. Moving the sandboxes into a VPC with no internet route, and DNS that only knows the proxy, fixed all three at about the same cost and without changing iron-proxy. Claude Code, git, gh, the AWS CLI and clients that ignore proxy settings all worked with nothing configured, and an agent opened a real pull request with nothing secret in its VM.
What works
- The fence. No internet route, two routes to the NLB, three ports; every other probe timed out.
- Nothing to configure in the sandbox. Every name resolves to the proxy, so every HTTP client goes through it, including the ones that ignore proxy settings.
- DNS as a closed channel. Nothing recursed for any of the seven record types or four resolver addresses tried, and there’s no domain list to maintain.
- Injection. Placeholders swapped per host, in headers, query or body, with an audit line naming the secret and where it was found.
- AWS without AWS credentials.
aws_authre-signs with the proxy’s role and enforces services and regions. - Rotation. A new value in Secrets Manager is used within about 30 s (32.4 s here), even by a MicroVM that was suspended at the time.
- Path rules and
require, with no CONNECT in the way. - No penalty for going transparent. It was within noise of a CONNECT tunnel, and a lookup through the override took milliseconds.
What doesn’t
- No tenant isolation in this build. Every MicroVM that reaches the proxy shares its credentials, and the log can’t say which one acted.
- The DNS control rests on a few Resolver settings. Mutation protection guards the association, not the rules; the alarm catches changes to both, but someone with the permissions can still make them, and recursion returns at once.
- Slow and quiet failures. Non-HTTP traffic times out instead of being refused, and a denied host gets a 403 with an empty body and a curl exit code of 0.
- Revocation and failure. Cached secrets outlive revoked access, and a missing secret forwards the placeholder instead of failing.
- Exfiltration to allowed hosts stays open, for data and for anything already in the VM.
- Rough edges. Plain-HTTP swaps, S3 keys that need encoding, chunked uploads off by default, unsigned images, one main maintainer and a single Fargate task.
Who should use it
One person or one team running agents on Lambda MicroVMs who want keys out of the sandbox, a log of what the agent touched, and a policy that holds whether or not the agent’s tools honour proxy settings, and who can run a small Fargate service and a handful of Route 53 resources. If you need several tenants, give each its own proxy, or wait for proxy authentication. Part 3 builds a browser workspace on this same proxy.