# iron-proxy: a default-deny egress proxy for workloads you do not trust

> iron-proxy is a Go man-in-the-middle egress proxy with a built-in DNS server that lets an untrusted CI job or AI agent reach only the domains you list, while real credentials stay on the proxy side and are swapped in at the last moment. It also ships a PostgreSQL listener that enforces a role through a SQL AST walk, which is the part of the project with the sharpest edges.

**paradigmxyz/iron-proxy** — An egress firewall for untrusted workloads.

- Repository: https://github.com/paradigmxyz/iron-proxy
- Website: https://docs.iron.sh
- Stars: 785 · Forks: 57
- Language: Go
- License: Apache-2.0
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/paradigmxyz-iron-proxy

## The threat is a build step that phones home

The README states the problem in plain terms, and it is worth restating because the design follows from it directly. CI jobs, AI coding agents and sandboxed containers can make arbitrary outbound requests. A compromised dependency, a prompt injection or a malicious build step can exfiltrate secrets, phone home, or open a reverse shell. Most teams have zero visibility into what is leaving their workloads, let alone a way to stop it.

Three attack paths sit side by side there, and they need different answers. A compromised dependency sends data out through an allowed endpoint. A prompt injection in an agent turns a model response into an instruction that fetches a URL of the attacker's choosing. A malicious build step simply runs curl. The common factor is egress, and the project chooses to control it at the network boundary rather than inside the workload.

The stated audience is precise: CI pipelines, GitHub Actions, AI agents including Claude Code, Cursor and Codex, and any environment where you run code you do not fully trust. That is a security tool with a narrow job, not a general API gateway, and the README describes the deployment model as a single binary with a single YAML config.

## Default-deny at the boundary, with DNS as the enforcement point

iron-proxy is described as a MITM egress proxy with a built-in DNS server that sits between the untrusted workload and the internet. It enforces default-deny at the network boundary, so the workload can only reach domains you explicitly allow.

The mechanics of that enforcement are the interesting part. Rather than relying on the workload to respect a proxy environment variable, the proxy answers DNS itself. A client inside the container is pointed at the proxy's IP for name resolution, so every hostname lookup resolves to the proxy, and the traffic lands there whether or not the client agreed to be proxied. The README calls this the simplest approach to routing containers through the proxy.

The allowlist itself takes domains and CIDRs. Anything that does not match is answered with a 403, and the audit record for that request says why it was blocked. Because the decision happens at the proxy, the same rule applies to a tool that knows nothing about proxies, which is the normal case for a build script.

The cost of the approach is that the proxy has to impersonate the destination. It terminates TLS by generating leaf certificates on the fly, signed by a certificate authority you supply, and client containers must trust that authority. That trust step is the price of the visibility, and it is also the failure mode to think about before rolling this out.

## Proxy tokens instead of real secrets at the workload boundary

The second core idea is that real secrets never enter the sandbox. Workloads use proxy tokens, and iron-proxy swaps in real credentials at egress, which means a compromised workload can exfiltrate a token that is worthless outside the proxy.

The value of this is that it changes what a leak is worth. A stolen API key is usable from anywhere, immediately, forever until it is rotated. A stolen proxy token is usable only against the proxy, and only for whatever the proxy will substitute at that destination. The attacker has to be inside the same boundary to use it, at which point the proxy is still in the path and still logging.

The dependency list shows where the real credentials come from. The module requirements include the 1Password Connect SDK and the 1Password Go SDK, the AWS SDK v2 with the Secrets Manager and SSM services, and the HashiCorp Vault API client. So the secrets do not have to live in an environment file, they can be fetched from a secret manager and injected at request time.

The audit trail records the substitution. Every request is logged as structured JSON including the full transform pipeline result, which secrets were swapped, which rules matched, and what got blocked and why.

## The upstream deny list is what stops the DNS rebinding trick

Allowing a hostname is not enough, and the README explains the gap by name. Even when a host is allowed, the proxy refuses to dial it if its resolved address falls inside a denied CIDR, which closes the SSRF and DNS-rebinding gap where an allowlisted hostname points at a metadata service or at loopback.

This is the most important security mechanism in the project, and it is easy to miss. Without it, an allowlist entry for a domain you control becomes a way to reach anything on the network: the attacker makes that domain resolve to 169.254.169.254, and the proxy dutifully connects to the cloud metadata endpoint on your behalf, handing back credentials to a workload that was only ever allowed to talk to one domain.

The defaults are strict. Cloud metadata endpoints 169.254.169.254, fd00:ec2::254 and fd20:ce::254 are denied, along with loopback. Two override mechanisms are documented, the proxy.upstream_deny_cidrs configuration key and the IRON_PROXY_UPSTREAM_DENY_CIDRS environment variable.

Two details are worth flagging. First, the check has to happen at connect time, after resolution, not at configuration time, because the resolution is exactly what the attacker controls. Second, the default set covers the metadata addresses of the major cloud providers, so on a cloud hosted CI runner the protection is present out of the box rather than something you remember to add.

## The PostgreSQL listener walks the SQL AST to keep clients in their role

The optional PostgreSQL MITM proxy is the most elaborate feature, and it is the one with the sharpest operational requirement. The listener authenticates clients against proxy-managed credentials, injects SET ROLE on the upstream session, and rejects client attempts to mutate the role. The rejection list is specific: a client SET ROLE, any set_config call, DO blocks, unsafe function definitions, and definitions that bind functions as callbacks such as CREATE AGGREGATE.

How the rejection is implemented is the interesting engineering. It is done via a SQL AST walk rather than by pattern matching on query text, so the decision is made on the parsed statement. Routine bodies written in SQL and PL/pgSQL are inspected recursively, and dynamic PL/pgSQL and unsupported routine languages are rejected outright, which closes the hole where behaviour hides inside a function body or a string that is only executed later.

There is a second layer described as defense in depth. The proxy revokes EXECUTE on pg_catalog.set_config and unneeded TEMPORARY and CREATE privileges from the injected role, for example from PUBLIC, and grants set_config back only to the login user the proxy uses for session setup. The goal, combined with PostgreSQL row-level security, is per-tenant data isolation when the application connects as a shared service-account user.

The catch is stated just as plainly. It requires PgBouncer, if you use it, to run in pool_mode = session, because transaction or statement pooling modes silently rebind backends between queries and would defeat the policy. A proxy that enforces a role but runs on a pooler that swaps connections underneath it is worse than no proxy, because the audit trail would claim a guarantee that is not there.

## Setting it up: a CA, a Docker network and DNS pointing at the proxy

The README walks through production usage in four steps, and the sequence matters because the proxy needs a fixed address and a certificate authority before any container can be routed to it.

First, generate the CA that signs the leaf certificates. The command below is reproduced exactly as the README gives it.

```bash
mkdir -p certs
openssl genrsa -out certs/ca.key 4096
openssl req -x509 -new -nodes \
    -key certs/ca.key \
    -sha256 -days 3650 \
    -subj "/CN=iron-proxy CA" \
    -addext "basicConstraints=critical,CA:TRUE" \
    -addext "keyUsage=critical,keyCertSign" \
    -out certs/ca.crt
```

Second, create a Docker network with a fixed subnet so the proxy has a predictable IP.

```bash
docker network create --subnet=172.20.0.0/24 iron-proxy
```

Third, run the proxy with the config file, the CA and the secrets file mounted in.

```bash
docker run -d --name iron-proxy \
  --network iron-proxy --ip 172.20.0.2 \
  -v $(pwd)/proxy.yaml:/etc/iron-proxy/proxy.yaml:ro \
  -v $(pwd)/certs/ca.crt:/etc/iron-proxy/ca.crt:ro \
  -v $(pwd)/certs/ca.key:/etc/iron-proxy/ca.key:ro \
  --env-file .env \
  ironsh/iron-proxy:latest -config /etc/iron-proxy/proxy.yaml
```

Fourth, route clients through it by pointing their DNS at the proxy IP, and give them the CA to trust.

```bash
docker run --rm \
  --network iron-proxy \
  --dns 172.20.0.2 \
  -v $(pwd)/certs/ca.crt:/certs/ca.crt:ro \
  curlimages/curl --cacert /certs/ca.crt https://httpbin.org/get
```

For a first look rather than a production deployment, the README offers a compose example that starts the proxy and a demo client firing five requests, and the log output shows which were allowed, which were blocked and which had secrets rewritten.

## One JSON line per request, and a release every week or two

The audit format is the other half of the value proposition, and it is plain structured JSON rather than a log format you have to parse out of prose.

```json
{
  "host": "httpbin.org",
  "method": "GET",
  "path": "/headers",
  "action": "allow",
  "status_code": 200,
  "request_transforms": [
    { "name": "allowlist", "action": "continue" },
    {
      "name": "secrets",
      "action": "continue",
      "annotations": { "swapped": [{ "secret": "OPENAI_API_KEY", "locations": ["header:Authorization"] }] }
    }
  ]
}
```

Each entry names the host, method and path, the decision, the status code, and the transform pipeline, including which secret was swapped and where it was placed, for example a header named Authorization. Rejected requests carry a rejected_by field and log at WARN level. The same entries feed OpenTelemetry, since the module list includes the OTLP log exporters over both gRPC and HTTP, so the audit trail can leave the container.

The proxy also handles the awkward parts of agent traffic. WebSocket upgrades and Server-Sent Events are proxied natively with no special configuration, which matters for tools holding long-lived connections, and an optional tunnel listener serves tools that natively support HTTP_PROXY, HTTPS_PROXY or SOCKS5 settings.

On cadence, the release list shows v0.51.0 and v0.52.0 on 2026-09-29 and v0.53.0 on 2026-10-06, and the last push was on 2026-10-16. The repository is not archived, the licence is Apache-2.0, and the documentation lives at docs.iron.sh. The project also ships a Helm chart in charts/, an egress-rules.yaml, an install.sh, a release signing script and a public key, so distribution is treated as a product rather than a hobby.

## Conclusion

Adopt iron-proxy when you run code you do not trust, meaning CI jobs, coding agents and sandboxed containers, and you need both an allowlist and a record of what left the machine. Do not adopt it as a plain network firewall, because it must terminate TLS and clients must trust its certificate authority, and do not enable the PostgreSQL listener unless you can run PgBouncer in session pooling mode. Verify first by starting the examples/docker-compose stack and reading one structured audit entry per request, then check that the upstream IP deny list still covers the metadata addresses 169.254.169.254, fd00:ec2::254 and fd20:ce::254 in your configuration.

## FAQ

### What does iron-proxy protect against?

It is an egress firewall for untrusted workloads such as CI jobs, AI coding agents and sandboxed containers. It blocks outbound requests by default unless the destination matches your allowlist, and it rewrites proxy tokens into real credentials at the boundary so a compromised workload cannot read the secrets it is using.

### How do I run iron-proxy for a first test?

The README gives a compose example: change into the examples/docker-compose directory and run docker compose up, which starts iron-proxy and a demo client that fires five requests. Reading the proxy logs with docker compose logs proxy shows the allowed, blocked and secret-rewritten requests, each as a structured JSON entry.

### What is the requirement for the PostgreSQL proxy listener?

It requires PgBouncer, if you use it, to run in pool_mode = session. Transaction or statement pool modes silently rebind backends between queries and would defeat the role policy the listener enforces.

## Sources

- [License: Apache-2.0](https://github.com/paradigmxyz/iron-proxy/blob/main/LICENSE)
- [paradigmxyz/iron-proxy on GitHub](https://github.com/paradigmxyz/iron-proxy)
- [Project website](https://docs.iron.sh)
- [README](https://github.com/paradigmxyz/iron-proxy/blob/main/README.md)
- [Releases](https://github.com/paradigmxyz/iron-proxy/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/paradigmxyz-iron-proxy
