feat(egress): iron-proxy credential-injection firewall for sandboxes

Adds a TLS-intercepting egress proxy for remote terminal sandboxes (Docker
v1; Modal/SSH to follow).  When enabled, the sandbox holds opaque proxy
tokens; iron-proxy swaps them for real provider API keys at the egress
boundary.  Compromising the sandbox leaks tokens that only work from behind
the proxy.

Wraps ironsh/iron-proxy (Apache-2.0, Go binary).  Same lazy-install pattern
as the recently merged Bitwarden Secrets Manager integration — pinned
version, SHA-256 verified download into ~/.hermes/bin/iron-proxy, no apt
or sudo required.

Disabled by default.  Run `hermes egress setup` to mint tokens and
`hermes egress start` to launch.  The Docker backend then automatically
mounts the CA, sets HTTPS_PROXY + CA-bundle env vars, and adds the
host-gateway hostmap.

New surfaces:
  hermes egress install   — download the pinned iron-proxy binary
  hermes egress setup     — interactive wizard (supports --from-bitwarden)
  hermes egress start     — spawn the managed proxy daemon
  hermes egress stop      — SIGTERM (+SIGKILL after 5s grace)
  hermes egress status    — binary + config + pid + listening + mappings
  hermes egress disable   — flip proxy.enabled = false
  hermes egress config    — print the path to the generated proxy.yaml

Optional Bitwarden integration: `--from-bitwarden` sources the real
upstream credentials from a BSM project at proxy startup, so rotating a
key in the Bitwarden web app propagates to sandboxes on the next proxy
start without touching .env.

Hermes-side scope (v1):
  agent/proxy_sources/iron_proxy.py   — install + CA + config + lifecycle
  hermes_cli/proxy_cli.py             — `hermes egress` subcommand tree
  hermes_cli/config.py                — "proxy:" section in DEFAULT_CONFIG
  hermes_cli/main.py                  — argparse wiring (uses 'egress'
                                         because 'proxy' is the existing
                                         inbound OAuth reverse proxy)
  tools/environments/docker.py        — CA mount, HTTPS_PROXY, CA-bundle
                                         env vars, --add-host wiring

Hermetic tests cover the full lifecycle: token mint, mapping discovery,
config + mappings I/O, install pipeline (HTTP + tar + checksum all mocked),
subprocess lifecycle (Popen mocked), Docker backend arg builder.

A live E2E test (gated on HERMES_RUN_E2E=1) downloads the real iron-proxy
binary, spawns it, routes a curl request through it against a local fake
upstream, and verifies the Authorization header was swapped from the proxy
token to the real secret value (and the proxy token did NOT leak through
to upstream).

Failures (binary missing, port collision, bad token) never block agent
startup — they emit a warning and continue.  The Docker backend refuses to
start a sandbox when proxy.enabled=true but the daemon is dead, unless
proxy.enforce_on_docker is explicitly set to false.

Docs: website/docs/user-guide/egress/{index,iron-proxy}.md
Tests: tests/test_iron_proxy.py (35), tests/test_iron_proxy_e2e.py (1)
This commit is contained in:
Teknium 2026-05-21 19:31:42 -07:00
parent 7ce6b504a2
commit 69ffb9cfd4
No known key found for this signature in database
11 changed files with 2403 additions and 3 deletions

533
tests/test_iron_proxy.py Normal file
View file

@ -0,0 +1,533 @@
"""Hermetic tests for the iron-proxy egress integration.
Covers the pure-function surface (token mint, mapping discovery, config build,
config + mappings I/O), the binary install path (HTTP downloads + tar
extraction + checksum verification fully mocked), the subprocess lifecycle
(spawn / PID / pid_alive / stop, with subprocess.Popen mocked), and the
docker backend's egress arg builder.
Live network and the real ``iron-proxy`` binary are NEVER touched. See
``tests/test_iron_proxy_e2e.py`` (gated behind a marker) for the real-binary
smoke test.
"""
from __future__ import annotations
import io
import os
import tarfile
from pathlib import Path
from typing import Dict
from unittest.mock import MagicMock, patch
import pytest
from agent.proxy_sources import iron_proxy as ip
# ---------------------------------------------------------------------------
# Per-test isolation
# ---------------------------------------------------------------------------
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
"""Point HERMES_HOME at a temp dir so install paths don't touch the real $HOME."""
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# Make sure no stale provider keys influence discovery.
for key in list(os.environ):
if key.endswith("_API_KEY"):
monkeypatch.delenv(key, raising=False)
return home
# ---------------------------------------------------------------------------
# Token mint + mapping discovery
# ---------------------------------------------------------------------------
def test_mint_proxy_token_has_prefix_and_length():
t = ip.mint_proxy_token("alpha")
assert t.startswith("alpha-")
assert len(t) >= len("alpha-") + 32
def test_mint_proxy_token_is_random():
a = ip.mint_proxy_token("x")
b = ip.mint_proxy_token("x")
assert a != b
def test_discover_provider_mappings_from_env(hermes_home, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-real-1")
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai-real-2")
monkeypatch.delenv("MISTRAL_API_KEY", raising=False)
ms = ip.discover_provider_mappings()
names = [m.real_env_name for m in ms]
assert "OPENROUTER_API_KEY" in names
assert "OPENAI_API_KEY" in names
assert "MISTRAL_API_KEY" not in names
def test_discover_provider_mappings_explicit_names(hermes_home):
ms = ip.discover_provider_mappings(
available_env_names=["OPENROUTER_API_KEY", "GROQ_API_KEY", "UNKNOWN_KEY"]
)
names = {m.real_env_name for m in ms}
assert names == {"OPENROUTER_API_KEY", "GROQ_API_KEY"}
# Unknown providers (no entry in _BEARER_PROVIDERS) are skipped, not warned.
def test_discover_provider_mappings_empty(hermes_home):
ms = ip.discover_provider_mappings(available_env_names=[])
assert ms == []
# ---------------------------------------------------------------------------
# Config / mapping serialization
# ---------------------------------------------------------------------------
def _sample_mapping(env_name: str = "OPENROUTER_API_KEY") -> ip.TokenMapping:
return ip.TokenMapping(
proxy_token=ip.mint_proxy_token("test"),
real_env_name=env_name,
upstream_hosts=("openrouter.ai", "*.openrouter.ai"),
)
def test_build_proxy_config_shape():
m = _sample_mapping()
cfg = ip.build_proxy_config(
mappings=[m],
ca_cert=Path("/tmp/ca.crt"),
ca_key=Path("/tmp/ca.key"),
)
# Top-level sections — note `dns` is required by iron-proxy even when
# we only use the CONNECT tunnel.
assert set(cfg.keys()) >= {"dns", "proxy", "tls", "transforms", "log"}
# Transforms in expected order
assert [t["name"] for t in cfg["transforms"]] == ["allowlist", "secrets"]
# Allowlist uses `domains:` (iron-proxy schema), not `hosts:`
domains = cfg["transforms"][0]["config"]["domains"]
assert "openrouter.ai" in domains
# Secrets transform encodes our mapping
rules = cfg["transforms"][1]["config"]["secrets"]
assert len(rules) == 1
rule = rules[0]
# Real secret value is sourced from env at egress time, NOT inlined.
assert rule["source"] == {"type": "env", "var": "OPENROUTER_API_KEY"}
# The proxy token is the replacement target.
assert rule["replace"]["proxy_value"] == m.proxy_token
assert "Authorization" in rule["replace"]["match_headers"]
# Rules list contains one entry per upstream host.
rule_hosts = {r["host"] for r in rule["rules"]}
assert rule_hosts == set(m.upstream_hosts)
# TLS section names the CA paths
assert cfg["tls"]["ca_cert"] == "/tmp/ca.crt"
def test_build_proxy_config_custom_allowed_hosts():
m = _sample_mapping("OPENAI_API_KEY")
cfg = ip.build_proxy_config(
mappings=[m],
ca_cert=Path("/tmp/ca.crt"),
ca_key=Path("/tmp/ca.key"),
allowed_hosts=["only.example.com"],
)
domains = cfg["transforms"][0]["config"]["domains"]
# Custom allowed_hosts wins as the base; mapping's hosts get appended.
assert "only.example.com" in domains
assert "openrouter.ai" in domains # comes from the mapping
def test_write_and_load_mappings_roundtrip(hermes_home):
ms = [_sample_mapping("OPENROUTER_API_KEY"), _sample_mapping("OPENAI_API_KEY")]
path = ip.write_mappings(ms)
assert path.exists()
loaded = ip.load_mappings()
assert len(loaded) == 2
assert {m.real_env_name for m in loaded} == {"OPENROUTER_API_KEY", "OPENAI_API_KEY"}
# Tokens preserved
assert loaded[0].proxy_token == ms[0].proxy_token
def test_load_mappings_handles_missing_file(hermes_home):
assert ip.load_mappings() == []
def test_load_mappings_handles_corrupt_json(hermes_home):
state = ip._proxy_state_dir()
(state / "mappings.json").write_text("{not json", encoding="utf-8")
assert ip.load_mappings() == []
def test_write_proxy_config_serializes_yaml(hermes_home):
cfg = ip.build_proxy_config(
mappings=[_sample_mapping()],
ca_cert=Path("/tmp/ca.crt"),
ca_key=Path("/tmp/ca.key"),
)
out = ip.write_proxy_config(cfg)
assert out.exists()
text = out.read_text(encoding="utf-8")
assert "tunnel_listen" in text
assert "ca_cert: /tmp/ca.crt" in text
# ---------------------------------------------------------------------------
# Binary discovery + lazy install
# ---------------------------------------------------------------------------
def test_find_iron_proxy_returns_none_when_missing(hermes_home, monkeypatch):
monkeypatch.setattr("shutil.which", lambda name: None)
assert ip.find_iron_proxy(install_if_missing=False) is None
def test_find_iron_proxy_returns_managed_first(hermes_home, monkeypatch):
managed = ip._hermes_bin_dir() / ip._platform_binary_name()
managed.parent.mkdir(parents=True, exist_ok=True)
managed.write_bytes(b"#!/bin/sh\necho ok\n")
managed.chmod(0o755)
# Even with a system one on PATH, the managed copy should win.
monkeypatch.setattr("shutil.which", lambda name: "/usr/bin/iron-proxy")
assert ip.find_iron_proxy() == managed
def _make_fake_tar(binary_name: str, payload: bytes = b"#!/bin/sh\necho ok\n") -> bytes:
"""Build a tar.gz with one file at the root, named ``binary_name``."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
info = tarfile.TarInfo(name=binary_name)
info.size = len(payload)
info.mode = 0o755
tf.addfile(info, io.BytesIO(payload))
return buf.getvalue()
def test_install_iron_proxy_verifies_checksum_and_extracts(hermes_home, monkeypatch):
fake_payload = _make_fake_tar(ip._platform_binary_name())
import hashlib
expected_sha = hashlib.sha256(fake_payload).hexdigest()
asset_name = ip._platform_asset_name()
checksum_text = f"{expected_sha} {asset_name}\nffff other-asset.tar.gz\n"
def fake_download(url: str, dest: Path) -> None:
if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME):
dest.write_text(checksum_text)
else:
dest.write_bytes(fake_payload)
monkeypatch.setattr(ip, "_http_download", fake_download)
target = ip.install_iron_proxy()
assert target.exists()
assert target.read_bytes() == b"#!/bin/sh\necho ok\n"
# Executable bit is set
assert os.access(target, os.X_OK)
def test_install_iron_proxy_rejects_bad_checksum(hermes_home, monkeypatch):
fake_payload = _make_fake_tar(ip._platform_binary_name())
asset_name = ip._platform_asset_name()
bad_text = f"deadbeef {asset_name}\n"
def fake_download(url: str, dest: Path) -> None:
if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME):
dest.write_text(bad_text)
else:
dest.write_bytes(fake_payload)
monkeypatch.setattr(ip, "_http_download", fake_download)
with pytest.raises(RuntimeError, match="Checksum mismatch"):
ip.install_iron_proxy()
def test_install_iron_proxy_rejects_missing_checksum_entry(hermes_home, monkeypatch):
fake_payload = _make_fake_tar(ip._platform_binary_name())
def fake_download(url: str, dest: Path) -> None:
if url.endswith(ip._IRON_PROXY_CHECKSUM_NAME):
dest.write_text("aaaa some-other-file.tar.gz\n")
else:
dest.write_bytes(fake_payload)
monkeypatch.setattr(ip, "_http_download", fake_download)
with pytest.raises(RuntimeError, match="No checksum entry"):
ip.install_iron_proxy()
def test_pick_tar_member_rejects_path_traversal():
"""A malicious tar that escapes via '..' must be refused."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
info = tarfile.TarInfo(name="../iron-proxy")
info.size = 1
info.mode = 0o755
tf.addfile(info, io.BytesIO(b"x"))
buf.seek(0)
with tarfile.open(fileobj=buf, mode="r:gz") as tf:
with pytest.raises(RuntimeError, match="Could not find iron-proxy"):
ip._pick_tar_member(tf, "iron-proxy")
# ---------------------------------------------------------------------------
# Subprocess lifecycle
# ---------------------------------------------------------------------------
def test_get_status_when_nothing_configured(hermes_home):
status = ip.get_status()
assert status.binary_path is None
assert status.config_path is None
assert status.ca_cert_path is None
assert status.pid is None
assert status.listening is False
assert not status.installed
assert not status.configured
def test_get_status_with_config_present(hermes_home, monkeypatch):
# Materialize binary, config, and ca cert.
bin_path = ip._hermes_bin_dir() / ip._platform_binary_name()
bin_path.parent.mkdir(parents=True, exist_ok=True)
bin_path.write_bytes(b"")
bin_path.chmod(0o755)
state = ip._proxy_state_dir()
(state / "ca.crt").write_text("fake")
cfg = ip.build_proxy_config(
mappings=[_sample_mapping()],
ca_cert=state / "ca.crt",
ca_key=state / "ca.key",
tunnel_port=9999,
)
ip.write_proxy_config(cfg)
monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "iron-proxy v0.0.0-test")
status = ip.get_status()
assert status.installed
assert status.configured
assert status.tunnel_port == 9999
assert "test" in (status.binary_version or "")
def test_stop_proxy_handles_missing_pidfile(hermes_home):
# No pidfile → stop returns False, doesn't raise.
assert ip.stop_proxy() is False
def test_stop_proxy_cleans_stale_pidfile(hermes_home, monkeypatch):
pid_file = ip._proxy_state_dir() / "iron-proxy.pid"
pid_file.write_text("999999999")
monkeypatch.setattr(ip, "_pid_alive", lambda pid: False)
assert ip.stop_proxy() is False
assert not pid_file.exists()
def test_start_proxy_refuses_without_binary(hermes_home, monkeypatch):
# No binary, auto_install fails → RuntimeError surfaces.
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: None)
state = ip._proxy_state_dir()
(state / "proxy.yaml").write_text("proxy: {}")
with pytest.raises(RuntimeError, match="binary not available"):
ip.start_proxy()
def test_start_proxy_refuses_without_config(hermes_home, monkeypatch):
binary = ip._hermes_bin_dir() / ip._platform_binary_name()
binary.parent.mkdir(parents=True, exist_ok=True)
binary.write_bytes(b"")
binary.chmod(0o755)
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: binary)
with pytest.raises(RuntimeError, match="config not found"):
ip.start_proxy()
def test_start_proxy_writes_pidfile_when_alive(hermes_home, monkeypatch):
binary = ip._hermes_bin_dir() / ip._platform_binary_name()
binary.parent.mkdir(parents=True, exist_ok=True)
binary.write_bytes(b"")
binary.chmod(0o755)
state = ip._proxy_state_dir()
(state / "proxy.yaml").write_text("proxy: {}")
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: binary)
monkeypatch.setattr(ip, "_STARTUP_GRACE_SECONDS", 0)
# Pre-stub everything start_proxy's get_status() call will touch — it
# runs INSIDE start_proxy, so by the time Popen is mocked these have
# to already be hermetic.
monkeypatch.setattr(ip, "_pid_alive", lambda pid: True)
monkeypatch.setattr(ip, "_port_listening", lambda h, p: False)
monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "iron-proxy test")
fake_proc = MagicMock()
fake_proc.pid = 4242
fake_proc.poll.return_value = None # still alive
with patch("subprocess.Popen", lambda *a, **k: fake_proc):
status = ip.start_proxy()
assert (state / "iron-proxy.pid").read_text() == "4242"
assert status.pid == 4242
def test_start_proxy_raises_when_immediate_exit(hermes_home, monkeypatch):
binary = ip._hermes_bin_dir() / ip._platform_binary_name()
binary.parent.mkdir(parents=True, exist_ok=True)
binary.write_bytes(b"")
binary.chmod(0o755)
state = ip._proxy_state_dir()
(state / "proxy.yaml").write_text("proxy: {}")
(state / "iron-proxy.log").write_text("bind: address already in use\n")
monkeypatch.setattr(ip, "find_iron_proxy", lambda **kwargs: binary)
monkeypatch.setattr(ip, "_STARTUP_GRACE_SECONDS", 0)
fake_proc = MagicMock()
fake_proc.pid = 5151
fake_proc.poll.return_value = 1 # exited immediately
fake_proc.returncode = 1
with patch("subprocess.Popen", lambda *a, **k: fake_proc):
with pytest.raises(RuntimeError, match="exited immediately"):
ip.start_proxy()
def test_start_proxy_idempotent_when_already_running(hermes_home, monkeypatch):
state = ip._proxy_state_dir()
pid_file = state / "iron-proxy.pid"
pid_file.write_text("12345")
monkeypatch.setattr(ip, "_pid_alive", lambda pid: True)
monkeypatch.setattr(ip, "_port_listening", lambda h, p: True)
monkeypatch.setattr(ip, "iron_proxy_version", lambda b: "test")
# Materialize config so we get past that check (we shouldn't reach it,
# but if the idempotent path regresses we want a clean failure mode).
(state / "proxy.yaml").write_text("proxy: {}")
# Sentinel: subprocess.Popen must NOT be called.
with patch("subprocess.Popen", lambda *a, **k: pytest.fail("should not spawn")):
status = ip.start_proxy()
# Should return without spawning anything.
assert status is not None
# ---------------------------------------------------------------------------
# Docker integration
# ---------------------------------------------------------------------------
def test_docker_egress_args_empty_when_disabled(hermes_home, monkeypatch):
from tools.environments.docker import _egress_proxy_args_for_docker
# Default config has proxy.enabled=False; helper should return all empties.
vol, env, host = _egress_proxy_args_for_docker()
assert vol == []
assert env == {}
assert host == []
def test_docker_egress_args_when_enabled_but_unconfigured_raises(hermes_home, monkeypatch):
from tools.environments.docker import _egress_proxy_args_for_docker
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["enforce_on_docker"] = True
save_config(cfg)
# No proxy.yaml exists → enforce_on_docker should raise.
with pytest.raises(RuntimeError, match="not configured"):
_egress_proxy_args_for_docker()
def test_docker_egress_args_when_unconfigured_no_enforce(hermes_home, monkeypatch):
from tools.environments.docker import _egress_proxy_args_for_docker
from hermes_cli.config import load_config, save_config
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["enforce_on_docker"] = False
save_config(cfg)
# Without enforcement, missing config returns empties (warning only).
vol, env, host = _egress_proxy_args_for_docker()
assert vol == []
assert env == {}
assert host == []
def test_docker_egress_args_full_path(hermes_home, monkeypatch):
"""Wire up everything (config, CA, mappings, fake running proxy) and
verify the docker helper emits the right mounts and env."""
from tools.environments.docker import _egress_proxy_args_for_docker
from hermes_cli.config import load_config, save_config
# Materialize config, CA, mappings.
state = ip._proxy_state_dir()
ca = state / "ca.crt"
ca.write_text("fake-ca")
(state / "ca.key").write_text("fake-key")
mapping = _sample_mapping("OPENROUTER_API_KEY")
proxy_cfg = ip.build_proxy_config(
mappings=[mapping], ca_cert=ca, ca_key=state / "ca.key", tunnel_port=9090,
)
ip.write_proxy_config(proxy_cfg)
ip.write_mappings([mapping])
cfg = load_config()
cfg.setdefault("proxy", {})["enabled"] = True
cfg["proxy"]["enforce_on_docker"] = True
save_config(cfg)
# Fake running proxy.
(state / "iron-proxy.pid").write_text("99999")
monkeypatch.setattr(ip, "_pid_alive", lambda pid: True)
monkeypatch.setattr(ip, "_port_listening", lambda h, p: True)
vol, env, host = _egress_proxy_args_for_docker()
# CA mount present and in -v form
assert "-v" in vol
assert any("hermes-egress-ca.crt" in arg for arg in vol)
# Env contains both casings of HTTPS_PROXY and the CA env vars
assert env["HTTPS_PROXY"].endswith(":9090")
assert env["https_proxy"] == env["HTTPS_PROXY"]
assert env["REQUESTS_CA_BUNDLE"].endswith("hermes-egress-ca.crt")
assert env["NODE_EXTRA_CA_CERTS"] == env["REQUESTS_CA_BUNDLE"]
# NO_PROXY excludes loopback
assert "127.0.0.1" in env["NO_PROXY"]
# Per-mapping proxy token surfaced
assert env["HERMES_PROXY_TOKEN_OPENROUTER_API_KEY"] == mapping.proxy_token
# Linux host-gateway mapping
assert host == ["--add-host", "host.docker.internal:host-gateway"]
# ---------------------------------------------------------------------------
# Platform asset name resolution
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"system,machine,expected_substring",
[
("Linux", "x86_64", "linux_amd64"),
("Linux", "aarch64", "linux_arm64"),
("Darwin", "arm64", "darwin_arm64"),
("Darwin", "x86_64", "darwin_amd64"),
],
)
def test_platform_asset_name(monkeypatch, system, machine, expected_substring):
monkeypatch.setattr("platform.system", lambda: system)
monkeypatch.setattr("platform.machine", lambda: machine)
assert expected_substring in ip._platform_asset_name()
def test_platform_asset_name_rejects_windows(monkeypatch):
monkeypatch.setattr("platform.system", lambda: "Windows")
monkeypatch.setattr("platform.machine", lambda: "AMD64")
with pytest.raises(RuntimeError, match="does not ship native Windows"):
ip._platform_asset_name()

View file

@ -0,0 +1,165 @@
"""End-to-end smoke test for the iron-proxy egress integration.
Spins up the REAL iron-proxy binary (auto-installed if not present), routes
a curl request through it against a local fake upstream, and verifies that
the Authorization header was swapped from a proxy token to a real secret.
Gated on the network. Skipped by default in CI unless the user explicitly
opts in with --run-e2e or HERMES_RUN_E2E=1. This is intentional — the test
downloads ~16MB and requires both `openssl` and `curl` to be present.
"""
from __future__ import annotations
import os
import socket
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Optional
import pytest
from agent.proxy_sources import iron_proxy as ip
pytestmark = pytest.mark.skipif(
os.environ.get("HERMES_RUN_E2E", "0") != "1",
reason="E2E proxy test — set HERMES_RUN_E2E=1 to run (requires network + curl + openssl)",
)
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class _CaptureHandler(BaseHTTPRequestHandler):
"""Records the Authorization header of every incoming request."""
captured_auth: Optional[str] = None # class-level so tests can read it
def do_GET(self):
type(self).captured_auth = self.headers.get("Authorization")
body = b'{"ok": true}'
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *args, **kwargs):
return # silence access log
def test_iron_proxy_swaps_authorization_header_end_to_end(hermes_home, monkeypatch):
"""Real binary, real CA, real curl. Verify the proxy swaps a proxy-token
Authorization header for the real bearer value before forwarding."""
if not __import__("shutil").which("curl"):
pytest.skip("curl not available")
if not __import__("shutil").which("openssl"):
pytest.skip("openssl not available")
# ----- fake upstream ----------------------------------------------------
upstream_port = _free_port()
server = HTTPServer(("127.0.0.1", upstream_port), _CaptureHandler)
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
try:
# ----- iron-proxy install + CA + config ---------------------------
binary = ip.install_iron_proxy()
assert binary.exists()
ca_crt, ca_key = ip.ensure_ca_cert()
assert ca_crt.exists()
real_secret = "sk-real-upstream-value-deadbeef"
monkeypatch.setenv("TEST_UPSTREAM_KEY", real_secret)
proxy_token = ip.mint_proxy_token("test")
mapping = ip.TokenMapping(
proxy_token=proxy_token,
real_env_name="TEST_UPSTREAM_KEY",
upstream_hosts=("127.0.0.1",),
)
tunnel_port = _free_port()
cfg = ip.build_proxy_config(
mappings=[mapping],
ca_cert=ca_crt,
ca_key=ca_key,
tunnel_port=tunnel_port,
allowed_hosts=["127.0.0.1"],
# Test target is on loopback — clear the default IMDS+loopback
# deny list so iron-proxy will dial 127.0.0.1.
upstream_deny_cidrs=[],
)
ip.write_proxy_config(cfg)
ip.write_mappings([mapping])
# ----- start the proxy --------------------------------------------
try:
status = ip.start_proxy()
except RuntimeError as exc:
pytest.skip(f"iron-proxy could not start in this environment: {exc}")
assert status.pid is not None
# Wait up to 10s for the listener to come up.
for _ in range(50):
if ip._port_listening("127.0.0.1", tunnel_port):
break
time.sleep(0.2)
else:
pytest.fail("iron-proxy never started listening on the tunnel port")
# ----- request through the proxy ----------------------------------
# The fake upstream listens on plain HTTP (not HTTPS), so we use the
# proxy's tunnel for the CONNECT but talk plaintext to upstream via
# `--proxy-insecure` semantics: iron-proxy accepts HTTPS_PROXY-style
# CONNECT to any host on its allowlist. For a clean E2E we hit
# http://127.0.0.1:<port>/ which goes through the proxy as a plain
# HTTP forward (no MITM needed) and the secrets transform still fires
# on the Authorization header.
result = subprocess.run(
[
"curl",
"--silent",
"--max-time", "10",
"-x", f"http://127.0.0.1:{tunnel_port}",
"-H", f"Authorization: Bearer {proxy_token}",
f"http://127.0.0.1:{upstream_port}/",
],
capture_output=True,
text=True,
)
assert result.returncode == 0, f"curl failed: {result.stderr}"
# Some iron-proxy versions return 200 with no body; only the swap matters.
captured = _CaptureHandler.captured_auth
assert captured is not None, "upstream never received the request"
assert real_secret in captured, (
f"Authorization header was not swapped — upstream saw: {captured!r}"
)
assert proxy_token not in captured, (
f"Proxy token leaked through to upstream: {captured!r}"
)
finally:
# ----- cleanup ------------------------------------------------------
try:
ip.stop_proxy()
except Exception:
pass
server.shutdown()
server.server_close()