diff --git a/CLAUDE.md b/CLAUDE.md
index 33df6c1..f407fdd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,14 +22,14 @@ make docker # Multi-stage Docker image; data persisted at /data/.agent-vau
## Core concepts (mental model)
-- **Single ingress into the broker — transparent MITM** (on by default, port 14322, disable with `--mitm-port 0`): TLS-encrypted HTTPS_PROXY-compatible ingress backed by [internal/mitm](internal/mitm/) + [internal/ca](internal/ca/) (software CA, root key encrypted with the master key). The listener itself is TLS-wrapped (cert signed by the MITM CA) so the CONNECT handshake carrying the session token is encrypted. Clients use `HTTPS_PROXY=https://...`. Credential injection lives in `brokercore`. HTTP/1.1 at the ingress, with transparent WebSocket upgrade support (HTTP/2 not yet). Bind failures are non-fatal — the core HTTP server keeps running.
+- **Single ingress into the broker — transparent MITM** (on by default, port 14322, disable with `--mitm-port 0`): TLS-encrypted HTTPS_PROXY/HTTP_PROXY-compatible ingress backed by [internal/mitm](internal/mitm/) + [internal/ca](internal/ca/) (software CA, root key encrypted with the master key). The listener is TLS-wrapped (cert signed by the MITM CA) and accepts both `CONNECT host:port` (HTTPS upstreams) and absolute-form forward-proxy requests (`POST http://host/path HTTP/1.1`, RFC 7230 §5.3.2) for plain-HTTP upstreams on the same port. Clients use `HTTPS_PROXY=https://...` and `HTTP_PROXY=https://...` — both point at the same TLS-wrapped proxy URL. Credential injection lives in `brokercore`. HTTP/1.1 at the ingress, with transparent WebSocket upgrade support (HTTP/2 not yet). Bind failures are non-fatal — the core HTTP server keeps running.
- **Proposals = GitHub-PR-style change requests.** Agents cannot edit services or credentials directly; they create proposals, a human approves in CLI or browser, and apply merges atomically. Per-vault sequential IDs. 7-day TTL.
- **Two independent permission axes**:
- Instance role: `owner` vs `member` (applies to both users and agents).
- Vault role: `proxy` < `member` < `admin`. Proxy can use the proxy and raise proposals; member can manage credentials/services; admin can invite humans.
- **KEK/DEK key wrapping**: A random DEK (Data Encryption Key) encrypts credentials and the CA key at rest (AES-256-GCM). If a master password is set, Argon2id derives a KEK (Key Encryption Key) that wraps the DEK; changing the password re-wraps the DEK without re-encrypting credentials. If no password is set (passwordless mode), the DEK is stored in plaintext — suitable for PaaS deploys where volume security is the trust boundary. Login uses email+password. The first user to register becomes the instance owner and is auto-granted vault admin on `default`.
- **Agent skills are the agent-facing contract.** [cmd/skill_cli.md](cmd/skill_cli.md) and [cmd/skill_http.md](cmd/skill_http.md) are embedded into the binary, installed by `vault run`, and served publicly at `/v1/skills/{cli,http}`. They are the authoritative reference for what agents can do.
-- **Two isolation modes for `vault run`** (selected via `--isolation` or `AGENT_VAULT_ISOLATION`): `host` (default, cooperative — fork+exec on the host with `HTTPS_PROXY` envvars) and `container` (non-cooperative — Docker container with iptables egress locked to the Agent Vault proxy). Container mode lives in [internal/isolation/](internal/isolation/) with an embedded Dockerfile + init-firewall.sh + entrypoint.sh, built on first use and cached by content hash.
+- **Two isolation modes for `vault run`** (selected via `--isolation` or `AGENT_VAULT_ISOLATION`): `host` (default, cooperative — fork+exec on the host with `HTTPS_PROXY`/`HTTP_PROXY` envvars) and `container` (non-cooperative — Docker container with iptables egress locked to the Agent Vault proxy). Container mode lives in [internal/isolation/](internal/isolation/) with an embedded Dockerfile + init-firewall.sh + entrypoint.sh, built on first use and cached by content hash.
## Where to look for details
diff --git a/README.md b/README.md
index ff2a457..1206d02 100644
--- a/README.md
+++ b/README.md
@@ -77,7 +77,7 @@ The server starts the HTTP API on port `14321` and a TLS-encrypted transparent H
### CLI — local agents (Claude Code, Cursor, Codex, OpenClaw, Hermes, OpenCode)
-Wrap any local agent process with `agent-vault run` (long form: `agent-vault vault run`). Agent Vault creates a scoped session, sets `HTTPS_PROXY` and CA-trust env vars, and launches the agent — all HTTPS traffic is transparently proxied and authenticated:
+Wrap any local agent process with `agent-vault run` (long form: `agent-vault vault run`). Agent Vault creates a scoped session, sets `HTTPS_PROXY`/`HTTP_PROXY` and CA-trust env vars, and launches the agent — all HTTP and HTTPS traffic is transparently proxied and authenticated:
```bash
agent-vault run -- claude
@@ -120,9 +120,9 @@ const session = await av
// certPath is where you'll mount the CA certificate inside the sandbox.
const certPath = "/etc/ssl/agent-vault-ca.pem";
-// env: { HTTPS_PROXY, NO_PROXY, NODE_USE_ENV_PROXY, SSL_CERT_FILE,
-// NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE,
-// GIT_SSL_CAINFO, DENO_CERT }
+// env: { HTTPS_PROXY, HTTP_PROXY, NO_PROXY, NODE_USE_ENV_PROXY,
+// SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE,
+// CURL_CA_BUNDLE, GIT_SSL_CAINFO, DENO_CERT }
const env = buildProxyEnv(session.containerConfig!, certPath);
const caCert = session.containerConfig!.caCertificate;
diff --git a/cmd/run.go b/cmd/run.go
index f493df5..8eecc41 100644
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -42,14 +42,16 @@ Environment variables set on the child:
AGENT_VAULT_ADDR — base URL of the Agent Vault HTTP control server
AGENT_VAULT_VAULT — vault the session is scoped to
-The child also inherits HTTPS_PROXY / NO_PROXY / NODE_USE_ENV_PROXY plus
-the root CA trust variables (SSL_CERT_FILE, NODE_EXTRA_CA_CERTS,
-REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, GIT_SSL_CAINFO, DENO_CERT) so standard
-HTTPS clients transparently route through the broker. NODE_USE_ENV_PROXY=1
-enables Node.js built-in proxy support (v22.21.0+) so fetch() and
-https.get() honor HTTPS_PROXY natively. HTTP_PROXY is intentionally not
-set — the MITM proxy only handles HTTPS (CONNECT) and would 405 any plain
-http:// request. The root CA PEM is written to ~/.agent-vault/mitm-ca.pem.
+The child also inherits HTTPS_PROXY / HTTP_PROXY / NO_PROXY /
+NODE_USE_ENV_PROXY plus the root CA trust variables (SSL_CERT_FILE,
+NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, CURL_CA_BUNDLE, GIT_SSL_CAINFO,
+DENO_CERT) so both HTTPS and plain-HTTP clients transparently route
+through the broker. NODE_USE_ENV_PROXY=1 enables Node.js built-in proxy
+support (v22.21.0+) so fetch() and http.get()/https.get() honor the
+proxy env natively. HTTPS_PROXY and HTTP_PROXY both point at the same
+TLS-wrapped proxy URL — the listener accepts CONNECT for https://
+upstreams and absolute-form forward-proxy requests for http:// on the
+same port. The root CA PEM is written to ~/.agent-vault/mitm-ca.pem.
Example:
` + examplePrefix + ` -- claude
@@ -381,15 +383,16 @@ func requireMITMEnv(env []string, addr, token, vault, caPath string) ([]string,
return newEnv, port, nil
}
-// augmentEnvWithMITM extends env so the child transparently routes HTTPS
-// through the broker. Returns (env, 0, false, nil) when the server has
-// MITM disabled. The second return value is the port the server reported;
-// callers log it so operators see the actual listen port (not a constant).
-// caPath is a test seam — pass "" for the default location.
+// augmentEnvWithMITM extends env so the child transparently routes HTTP
+// and HTTPS through the broker. Returns (env, 0, false, nil) when the
+// server has MITM disabled. The second return value is the port the
+// server reported; callers log it so operators see the actual listen
+// port (not a constant). caPath is a test seam — pass "" for the
+// default location.
//
-// Only HTTPS_PROXY is injected — not HTTP_PROXY. The MITM proxy handles
-// HTTP CONNECT only and returns 405 for every other method, so setting
-// HTTP_PROXY would route plain http:// requests into a dead end.
+// Both HTTPS_PROXY and HTTP_PROXY are injected, pointing at the same
+// TLS-wrapped proxy URL. The listener handles CONNECT for https://
+// upstreams and absolute-form forward-proxy requests for http://.
func augmentEnvWithMITM(env []string, addr, token, vault, caPath string) ([]string, int, bool, error) {
pem, port, enabled, mitmTLS, err := fetchMITMCA(addr)
if err != nil {
diff --git a/cmd/run_test.go b/cmd/run_test.go
index 16f5a03..54ea976 100644
--- a/cmd/run_test.go
+++ b/cmd/run_test.go
@@ -163,6 +163,7 @@ func TestAugmentEnvWithMITM_Enabled(t *testing.T) {
want := map[string]string{
"HTTPS_PROXY": "", // checked separately below
+ "HTTP_PROXY": "", // checked separately below
"NO_PROXY": "localhost,127.0.0.1",
"NODE_USE_ENV_PROXY": "1",
"SSL_CERT_FILE": caPath,
@@ -184,10 +185,11 @@ func TestAugmentEnvWithMITM_Enabled(t *testing.T) {
}
}
- // HTTP_PROXY must NOT be set — the MITM proxy is HTTPS-only and would
- // return 405 for plain http:// requests routed through it.
- if v, ok := vars["HTTP_PROXY"]; ok {
- t.Errorf("HTTP_PROXY should not be set (MITM is HTTPS-only), got %q", v)
+ // HTTP_PROXY must equal HTTPS_PROXY — both point at the same TLS-
+ // wrapped MITM ingress so plain http:// upstreams route through the
+ // broker via absolute-form forward-proxy requests.
+ if vars["HTTP_PROXY"] != vars["HTTPS_PROXY"] {
+ t.Errorf("HTTP_PROXY = %q, want it to equal HTTPS_PROXY = %q", vars["HTTP_PROXY"], vars["HTTPS_PROXY"])
}
// Proxy URL must parse cleanly and carry token:vault userinfo.
@@ -254,6 +256,7 @@ func TestAugmentEnvWithMITM_DedupesParentEnv(t *testing.T) {
parentEnv := []string{
"FOO=bar",
"HTTPS_PROXY=http://corp-proxy:3128",
+ "HTTP_PROXY=http://corp-proxy:3128",
"NO_PROXY=internal.example.com",
"SSL_CERT_FILE=/etc/ssl/corp-ca.pem",
"NODE_EXTRA_CA_CERTS=/etc/ssl/corp-ca.pem",
@@ -276,7 +279,7 @@ func TestAugmentEnvWithMITM_DedupesParentEnv(t *testing.T) {
counts[kv[:i]]++
}
}
- for _, k := range []string{"HTTPS_PROXY", "NO_PROXY", "NODE_USE_ENV_PROXY", "SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "DENO_CERT"} {
+ for _, k := range []string{"HTTPS_PROXY", "HTTP_PROXY", "NO_PROXY", "NODE_USE_ENV_PROXY", "SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "GIT_SSL_CAINFO", "DENO_CERT"} {
if counts[k] != 1 {
t.Errorf("%s appears %d times in env, want exactly 1 (POSIX getenv returns first match)", k, counts[k])
}
diff --git a/cmd/skill_cli.md b/cmd/skill_cli.md
index 5538f3d..5cb7866 100644
--- a/cmd/skill_cli.md
+++ b/cmd/skill_cli.md
@@ -41,7 +41,7 @@ By default each vault forwards unmatched hosts as plain proxy traffic (no creden
| `AGENT_VAULT_SESSION_TOKEN` | Bearer token for authenticating with Agent Vault's control-plane endpoints (`discover`, proposals, etc.) |
| `AGENT_VAULT_VAULT` | Vault name (set for user-scoped sessions via `agent-vault run`) |
-`agent-vault run` also pre-configures `HTTPS_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`, and CA-trust variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT`) so HTTPS calls from your process route through the broker transparently. You don't manage these yourself.
+`agent-vault run` also pre-configures `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`, and CA-trust variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT`) so HTTP and HTTPS calls from your process both route through the broker transparently. You don't manage these yourself.
Under `--isolation=container`, the same env shape is injected inside a Docker container, but the proxy URL host is `host.docker.internal` instead of `127.0.0.1` and egress to any other destination is blocked by iptables. From your perspective nothing changes — standard HTTP clients pick up the envvars as normal.
@@ -59,18 +59,19 @@ Response includes `vault`, `services` (host + description), and `available_crede
## Making Requests
-**Just call the real API URL.** When you were launched via `agent-vault run` (or the long form `agent-vault vault run`), your HTTPS traffic already routes through Agent Vault transparently — `HTTPS_PROXY` and the broker's CA cert are pre-configured in your environment. Agent Vault intercepts the call, looks up the host in the vault's services, injects the credential, and forwards over HTTPS.
+**Just call the real API URL.** When you were launched via `agent-vault run` (or the long form `agent-vault vault run`), your HTTP and HTTPS traffic already route through Agent Vault transparently — `HTTPS_PROXY`, `HTTP_PROXY`, and the broker's CA cert are pre-configured in your environment. Agent Vault intercepts the call, looks up the host in the vault's services, injects the credential, and forwards to the upstream.
```bash
curl https://api.stripe.com/v1/charges
curl https://api.github.com/user
+curl http://internal.example/api/v1/items # plain http:// works the same way
```
-Your code can leave the upstream auth header blank or set it to a placeholder — Agent Vault attaches the real credential at the proxy boundary, so the value in your env can be anything (or absent). Standard HTTP clients (curl, fetch, requests, axios, the Go stdlib, etc.) honor `HTTPS_PROXY` automatically.
+Your code can leave the upstream auth header blank or set it to a placeholder — Agent Vault attaches the real credential at the proxy boundary, so the value in your env can be anything (or absent). Standard HTTP clients (curl, fetch, requests, axios, the Go stdlib, etc.) honor `HTTPS_PROXY`/`HTTP_PROXY` automatically.
### WebSocket / Streaming
-`wss://` URLs are brokered through the same `HTTPS_PROXY` mechanism as regular HTTPS. Credentials are injected into the WebSocket handshake (`Authorization`, `Sec-WebSocket-Protocol`) the same way as on a normal request — point your client at the real `wss://` URL and Agent Vault attaches the real credential at the proxy boundary.
+`wss://` and `ws://` URLs are brokered through the same proxy mechanism as regular HTTP/HTTPS. Credentials are injected into the WebSocket handshake (`Authorization`, `Sec-WebSocket-Protocol`) the same way as on a normal request — point your client at the real WebSocket URL and Agent Vault attaches the real credential at the proxy boundary.
```
wss://api.openai.com/v1/realtime?model=gpt-realtime
diff --git a/cmd/skill_http.md b/cmd/skill_http.md
index 9eb907a..2cd608c 100644
--- a/cmd/skill_http.md
+++ b/cmd/skill_http.md
@@ -40,7 +40,7 @@ By default each vault forwards unmatched hosts as plain proxy traffic (no creden
| `AGENT_VAULT_SESSION_TOKEN` | Bearer token for authenticating with Agent Vault's control-plane endpoints (`/discover`, proposals, etc.) |
| `AGENT_VAULT_VAULT` | Vault name (set for user-scoped sessions via `vault run`) |
-`vault run` also pre-configures `HTTPS_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`, and CA-trust variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT`) so HTTPS calls from your process route through the broker transparently. You don't manage these yourself.
+`vault run` also pre-configures `HTTPS_PROXY`, `HTTP_PROXY`, `NO_PROXY`, `NODE_USE_ENV_PROXY`, and CA-trust variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT`) so HTTP and HTTPS calls from your process both route through the broker transparently. You don't manage these yourself.
Under `--isolation=container`, the same env shape is injected inside a Docker container, but the proxy URL host is `host.docker.internal` instead of `127.0.0.1` and egress to any other destination is blocked by iptables. From your perspective nothing changes — standard HTTP clients pick up the envvars as normal.
@@ -68,18 +68,19 @@ Returns built-in service templates with suggested credential keys and auth types
## Making Requests
-**Just call the real API URL.** When you were launched via `agent-vault vault run`, your HTTPS traffic already routes through Agent Vault transparently — `HTTPS_PROXY` and the broker's CA cert are pre-configured in your environment. Agent Vault intercepts the call, looks up the host in the vault's services, injects the credential, and forwards over HTTPS.
+**Just call the real API URL.** When you were launched via `agent-vault vault run`, your HTTP and HTTPS traffic already route through Agent Vault transparently — `HTTPS_PROXY`, `HTTP_PROXY`, and the broker's CA cert are pre-configured in your environment. Agent Vault intercepts the call, looks up the host in the vault's services, injects the credential, and forwards to the upstream.
```
GET https://api.stripe.com/v1/charges
GET https://api.github.com/user
+GET http://internal.example/api/v1/items # plain http:// works the same way
```
-Your code can leave the upstream auth header blank or set it to a placeholder — Agent Vault attaches the real credential at the proxy boundary, so the value in your env can be anything (or absent). Standard HTTP clients (curl, fetch, requests, axios, the Go stdlib, etc.) honor `HTTPS_PROXY` automatically.
+Your code can leave the upstream auth header blank or set it to a placeholder — Agent Vault attaches the real credential at the proxy boundary, so the value in your env can be anything (or absent). Standard HTTP clients (curl, fetch, requests, axios, the Go stdlib, etc.) honor `HTTPS_PROXY`/`HTTP_PROXY` automatically.
### WebSocket / Streaming
-`wss://` URLs are brokered through the same `HTTPS_PROXY` mechanism as regular HTTPS. Credentials are injected into the WebSocket handshake (`Authorization`, `Sec-WebSocket-Protocol`) the same way as on a normal request — point your client at the real `wss://` URL and Agent Vault attaches the real credential at the proxy boundary.
+`wss://` and `ws://` URLs are brokered through the same proxy mechanism as regular HTTP/HTTPS. Credentials are injected into the WebSocket handshake (`Authorization`, `Sec-WebSocket-Protocol`) the same way as on a normal request — point your client at the real WebSocket URL and Agent Vault attaches the real credential at the proxy boundary.
```
wss://api.openai.com/v1/realtime?model=gpt-realtime
diff --git a/docs/agents/protocol.mdx b/docs/agents/protocol.mdx
index c2b9d14..a5de423 100644
--- a/docs/agents/protocol.mdx
+++ b/docs/agents/protocol.mdx
@@ -87,12 +87,13 @@ The agent writes this line unchanged. No URL rewriting. No `Authorization` heade
| Variable | Purpose |
|---|---|
| `HTTPS_PROXY` | Points at the MITM listener (`http://{token}:{vault}@host:14322`) |
+| `HTTP_PROXY` | Same URL as `HTTPS_PROXY` — plain `http://` upstreams route through the same TLS-wrapped listener via absolute-form forward-proxy requests |
| `NO_PROXY` | `localhost,127.0.0.1` — so agent-to-vault traffic skips the proxy |
-| `NODE_USE_ENV_PROXY` | `1` — enables Node.js v22.21+ built-in `HTTPS_PROXY` support for `fetch()` and `https.get()` |
+| `NODE_USE_ENV_PROXY` | `1` — enables Node.js v22.21+ built-in proxy support for `fetch()` and `http.get()`/`https.get()` |
| `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT` | Point standard HTTP libraries at the Agent Vault root CA so the proxied TLS handshake validates |
-HTTP_PROXY is intentionally not set. The MITM listener only handles HTTPS (CONNECT) and would 405 on plain `http://` requests.
+The MITM listener accepts both `CONNECT host:port` (HTTPS upstreams) and absolute-form forward-proxy requests (`POST http://host/path HTTP/1.1`) on the same port. Plain-HTTP upstreams are intercepted, audited, and credential-injected just like HTTPS upstreams.
## Propose changes
diff --git a/docs/guides/connect-coding-agent.mdx b/docs/guides/connect-coding-agent.mdx
index 0661200..f305627 100644
--- a/docs/guides/connect-coding-agent.mdx
+++ b/docs/guides/connect-coding-agent.mdx
@@ -16,7 +16,7 @@ For agents that don't have a chat interface or need raw environment variables, s
## Option 1: Wrap with `agent-vault vault run`
-The simplest approach for local development. Wraps your agent process with the environment variables it needs — no invite, no token management. `vault run` also pre-configures `HTTPS_PROXY` and the CA trust chain on the child, so the agent calls upstream URLs directly and Agent Vault transparently injects credentials.
+The simplest approach for local development. Wraps your agent process with the environment variables it needs — no invite, no token management. `vault run` also pre-configures `HTTPS_PROXY`, `HTTP_PROXY`, and the CA trust chain on the child, so the agent calls upstream URLs directly (over either `http://` or `https://`) and Agent Vault transparently injects credentials.
```bash
agent-vault vault run -- claude # Claude Code
@@ -35,7 +35,7 @@ agent-vault vault run --vault my-vault -- claude
```
-`vault run` is a convenience wrapper, not a sandbox. A child process can unset `HTTPS_PROXY` or bypass the injected CA and reach the network directly — local credentials and network access are still fully available to the agent. Stronger isolation for local development is on the roadmap.
+`vault run` is a convenience wrapper, not a sandbox. A child process can unset `HTTPS_PROXY`/`HTTP_PROXY` or bypass the injected CA and reach the network directly — local credentials and network access are still fully available to the agent. Stronger isolation for local development is on the roadmap.
## Option 2: Paste an invite prompt
@@ -78,7 +78,7 @@ For agents you can't wrap with `vault run` (e.g. cloud-hosted agents, or when yo
Once connected, the agent follows the Agent Vault protocol automatically:
-1. Calls upstream APIs normally — `HTTPS_PROXY` and CA trust are pre-wired so Agent Vault transparently intercepts the call
+1. Calls upstream APIs normally over `http://` or `https://` — `HTTPS_PROXY`/`HTTP_PROXY` and CA trust are pre-wired so Agent Vault transparently intercepts the call
2. If a service isn't configured yet, the agent creates a [proposal](/learn/proposals) and shares an approval link
3. You click the link, provide any required credentials, and approve
4. The agent retries and the request succeeds
diff --git a/docs/guides/container-isolation.mdx b/docs/guides/container-isolation.mdx
index 6052d60..19d28bb 100644
--- a/docs/guides/container-isolation.mdx
+++ b/docs/guides/container-isolation.mdx
@@ -5,7 +5,7 @@ description: "Run `vault run` agents inside a Docker container with iptables-loc
`agent-vault vault run` has two isolation modes:
-- **`--isolation=host`** (default) — forks the agent on the host with `HTTPS_PROXY` and CA-trust env vars pointing at Agent Vault. Cooperative: a misbehaving or malicious agent can unset the env, spawn a subprocess that doesn't inherit them, use raw sockets, or exfiltrate over DNS.
+- **`--isolation=host`** (default) — forks the agent on the host with `HTTPS_PROXY`/`HTTP_PROXY` and CA-trust env vars pointing at Agent Vault. Cooperative: a misbehaving or malicious agent can unset the env, spawn a subprocess that doesn't inherit them, use raw sockets, or exfiltrate over DNS.
- **`--isolation=container`** — launches the agent inside a Docker container whose egress is locked down with iptables. Non-cooperative: the only TCP destination the container can reach is the Agent Vault proxy. Everything else is dropped at the kernel.
diff --git a/docs/installation.mdx b/docs/installation.mdx
index d7af2d0..afec5ea 100644
--- a/docs/installation.mdx
+++ b/docs/installation.mdx
@@ -118,7 +118,7 @@ agent-vault server
On first run, Agent Vault generates a random **data encryption key (DEK)** that encrypts all [credentials](/learn/credentials) at rest with AES-256-GCM. You can optionally set a **master password** to wrap the DEK (leave it empty for passwordless mode). The master password is never stored on disk. For non-interactive or automated environments, set the `AGENT_VAULT_MASTER_PASSWORD` environment variable or pass `--password-stdin`. Omit it entirely for passwordless mode. See [environment variables](/self-hosting/environment-variables) for all options.
-The server listens on port `14321` for the HTTP API and web UI, and on port `14322` for the transparent HTTPS proxy that agents' `HTTPS_PROXY` points at. Disable the transparent proxy with `--mitm-port 0`. See [transparent proxy setup](/self-hosting/local#transparent-proxy) for the CA-trust flow.
+The server listens on port `14321` for the HTTP API and web UI, and on port `14322` for the transparent HTTP/HTTPS proxy that agents' `HTTPS_PROXY` and `HTTP_PROXY` point at — the same listener handles `CONNECT` for `https://` upstreams and absolute-form forward-proxy requests for `http://` upstreams. Disable the transparent proxy with `--mitm-port 0`. See [transparent proxy setup](/self-hosting/local#transparent-proxy) for the CA-trust flow.
To run in the background:
diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx
index 4a0395c..e15f56a 100644
--- a/docs/reference/cli.mdx
+++ b/docs/reference/cli.mdx
@@ -262,14 +262,14 @@ description: "Complete reference for all Agent Vault CLI commands."
Wrap a process with Agent Vault environment variables. `agent-vault run` is the shorthand; `agent-vault vault run` is the long form. Both are identical in behavior and flags.
- The child process receives `AGENT_VAULT_ADDR`, `AGENT_VAULT_SESSION_TOKEN`, and `AGENT_VAULT_VAULT`, plus `HTTPS_PROXY` / `NO_PROXY` / `NODE_USE_ENV_PROXY` and CA-trust variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT`) pointing at `~/.agent-vault/mitm-ca.pem`, so standard HTTPS clients transparently route through the broker. If the server's MITM proxy is unreachable, `vault run` aborts.
+ The child process receives `AGENT_VAULT_ADDR`, `AGENT_VAULT_SESSION_TOKEN`, and `AGENT_VAULT_VAULT`, plus `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY` / `NODE_USE_ENV_PROXY` and CA-trust variables (`SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `GIT_SSL_CAINFO`, `DENO_CERT`) pointing at `~/.agent-vault/mitm-ca.pem`, so standard HTTP and HTTPS clients transparently route through the broker. `HTTPS_PROXY` and `HTTP_PROXY` both point at the same TLS-wrapped proxy URL — the listener handles CONNECT for `https://` upstreams and absolute-form forward-proxy requests for `http://` upstreams on the same port. If the server's MITM proxy is unreachable, `vault run` aborts.
| Flag | Default | Description |
|------|---------|-------------|
| `--address` | | Server address override |
| `--role` | `proxy` | Vault role for the session: `proxy`, `member`, or `admin` |
| `--ttl` | `0` | Session TTL in seconds (300–604800). Default: server default (24h). |
- | `--isolation` | `host` | Isolation mode for the child: `host` (default, cooperative — runs on the host with HTTPS_PROXY) or `container` (non-cooperative Docker container; see [Container isolation](/guides/container-isolation)). Also read from `AGENT_VAULT_ISOLATION`. |
+ | `--isolation` | `host` | Isolation mode for the child: `host` (default, cooperative — runs on the host with `HTTPS_PROXY`/`HTTP_PROXY`) or `container` (non-cooperative Docker container; see [Container isolation](/guides/container-isolation)). Also read from `AGENT_VAULT_ISOLATION`. |
| `--image` | | Override the bundled container image (`--isolation=container` only). |
| `--mount` | | Extra bind mount `src:dst[:ro]`; repeatable (`--isolation=container` only). Host paths are EvalSymlinks-resolved; reserved paths rejected. |
| `--keep` | `false` | Omit `--rm` from `docker run` (`--isolation=container` only; useful for debugging). |
diff --git a/docs/self-hosting/environment-variables.mdx b/docs/self-hosting/environment-variables.mdx
index 7334864..f406f8d 100644
--- a/docs/self-hosting/environment-variables.mdx
+++ b/docs/self-hosting/environment-variables.mdx
@@ -74,13 +74,14 @@ These variables are set automatically by `agent-vault vault run` on the agent's
### Transparent proxy
-`vault run` also wires up `HTTPS_PROXY` and the CA trust chain so standard HTTP clients transparently route through Agent Vault.
+`vault run` wires up `HTTPS_PROXY`, `HTTP_PROXY`, and the CA trust chain so standard HTTP clients transparently route both HTTPS and plain-HTTP traffic through Agent Vault.
| Variable | Description |
|----------|-------------|
-| `HTTPS_PROXY` | Points at the MITM listener (`https://{token}:{vault}@host:14322`). HTTP_PROXY is intentionally not set — the MITM only handles HTTPS CONNECT. |
+| `HTTPS_PROXY` | Points at the MITM listener (`https://{token}:{vault}@host:14322`). Used for `https://` upstreams via the CONNECT method. |
+| `HTTP_PROXY` | Same URL as `HTTPS_PROXY`. Plain `http://` upstreams reach the same TLS-wrapped listener via absolute-form forward-proxy requests, so the broker still authenticates, injects credentials, and audits the traffic. |
| `NO_PROXY` | `localhost,127.0.0.1` — so agent-to-vault control-plane traffic skips the proxy |
-| `NODE_USE_ENV_PROXY` | `1` — enables Node.js v22.21+ built-in proxy support for `fetch()` and `https.get()` |
+| `NODE_USE_ENV_PROXY` | `1` — enables Node.js v22.21+ built-in proxy support for `fetch()` and `http.get()`/`https.get()` |
| `SSL_CERT_FILE` | Points OpenSSL-linked clients (Python, Ruby, PHP) at the Agent Vault root CA |
| `NODE_EXTRA_CA_CERTS` | Trusts the root CA in Node.js |
| `REQUESTS_CA_BUNDLE` | Trusts the root CA in Python `requests` |
diff --git a/internal/isolation/env.go b/internal/isolation/env.go
index 2068b72..daf19dc 100644
--- a/internal/isolation/env.go
+++ b/internal/isolation/env.go
@@ -26,11 +26,16 @@ type ProxyEnvParams struct {
MITMTLS bool // true → HTTPS_PROXY uses https://, false → http://
}
-// BuildProxyEnv returns the nine env vars that point an HTTPS client at
+// BuildProxyEnv returns the env vars that point an HTTP/HTTPS client at
// agent-vault's MITM proxy with the right credentials and CA trust.
// Canonical source for both the process path (augmentEnvWithMITM) and
// the container path (BuildContainerEnv) so the list can't drift.
//
+// HTTPS_PROXY and HTTP_PROXY both point at the same TLS-wrapped proxy
+// URL so the client uses the same listener for https:// and http://
+// upstreams; the proxy listener accepts CONNECT and absolute-form
+// forward-proxy requests on the same port.
+//
// NB: keep in sync with buildProxyEnv() in
// sdks/sdk-typescript/src/resources/sessions.ts.
func BuildProxyEnv(p ProxyEnvParams) []string {
@@ -45,6 +50,7 @@ func BuildProxyEnv(p ProxyEnvParams) []string {
}).String()
return []string{
"HTTPS_PROXY=" + proxyURL,
+ "HTTP_PROXY=" + proxyURL,
"NO_PROXY=localhost,127.0.0.1",
"NODE_USE_ENV_PROXY=1",
"SSL_CERT_FILE=" + p.CAPath,
@@ -61,6 +67,7 @@ func BuildProxyEnv(p ProxyEnvParams) []string {
// stripped before appending these.
var ProxyEnvKeys = []string{
"HTTPS_PROXY",
+ "HTTP_PROXY",
"NO_PROXY",
"NODE_USE_ENV_PROXY",
"SSL_CERT_FILE",
diff --git a/internal/isolation/env_test.go b/internal/isolation/env_test.go
index f7707ac..f2aa692 100644
--- a/internal/isolation/env_test.go
+++ b/internal/isolation/env_test.go
@@ -95,12 +95,16 @@ func TestBuildContainerEnv_FirewallPortsEmitted(t *testing.T) {
}
}
-// HTTP_PROXY must not be set — the MITM proxy is HTTPS-only and would
-// 405 any plain http:// request routed through it.
-func TestBuildContainerEnv_NoHTTPProxy(t *testing.T) {
+// HTTP_PROXY mirrors HTTPS_PROXY: both point at the same TLS-wrapped
+// MITM ingress so plain http:// upstreams route through the broker via
+// absolute-form forward-proxy requests.
+func TestBuildContainerEnv_HTTPProxyMatchesHTTPS(t *testing.T) {
env := BuildContainerEnv("tok", "v", 14321, 14322, true)
vars := envMap(env)
- if v, ok := vars["HTTP_PROXY"]; ok {
- t.Errorf("HTTP_PROXY must not be set, got %q", v)
+ if vars["HTTP_PROXY"] == "" {
+ t.Fatal("HTTP_PROXY not set; expected to mirror HTTPS_PROXY")
+ }
+ if vars["HTTP_PROXY"] != vars["HTTPS_PROXY"] {
+ t.Errorf("HTTP_PROXY = %q, want it to equal HTTPS_PROXY = %q", vars["HTTP_PROXY"], vars["HTTPS_PROXY"])
}
}
diff --git a/internal/isolation/integration_test.go b/internal/isolation/integration_test.go
index dcd090f..c55d436 100644
--- a/internal/isolation/integration_test.go
+++ b/internal/isolation/integration_test.go
@@ -231,7 +231,7 @@ func TestIntegration_EgressBlocked_Bypasses(t *testing.T) {
"curl --max-time 3 -fsS --noproxy '*' https://example.com >/dev/null && echo REACHED || echo BLOCKED",
"--noproxy bypass must still hit kernel-level block"},
{"ProxyEnvStripped",
- "env -u HTTPS_PROXY -u https_proxy curl --max-time 3 -fsS https://example.com >/dev/null && echo REACHED || echo BLOCKED",
+ "env -u HTTPS_PROXY -u https_proxy -u HTTP_PROXY -u http_proxy curl --max-time 3 -fsS https://example.com >/dev/null && echo REACHED || echo BLOCKED",
"env-stripped bypass must still hit kernel-level block"},
}
for _, tc := range cases {
diff --git a/internal/mitm/connect.go b/internal/mitm/connect.go
index 2cf71c7..c8ced5a 100644
--- a/internal/mitm/connect.go
+++ b/internal/mitm/connect.go
@@ -12,10 +12,13 @@ import (
"github.com/Infisical/agent-vault/internal/ratelimit"
)
-// mitmConnectIPKey is the rate-limit key for the CONNECT-flood
-// limiter. X-Forwarded-For doesn't exist at this layer (the HTTP
-// request is tunnelled); only the direct peer IP is meaningful.
-func mitmConnectIPKey(r *http.Request) string {
+// mitmIPKey is the rate-limit key for the per-IP flood gate shared by
+// the CONNECT and absolute-form forward-proxy paths. X-Forwarded-For
+// doesn't exist at this layer (the HTTP request is tunnelled or sent
+// over a TLS-wrapped proxy connection); only the direct peer IP is
+// meaningful. CONNECT and forward share one budget — a peer is one peer
+// regardless of which ingress shape they use.
+func mitmIPKey(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil || host == "" {
host = r.RemoteAddr
@@ -48,7 +51,7 @@ func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) {
// can't burn CPU. Per-IP on the raw TCP peer, shared with the
// TierAuth budget. Loopback is exempt — see isLoopbackPeer.
if p.rateLimit != nil && !isLoopbackPeer(r) {
- if d := p.rateLimit.Allow(ratelimit.TierAuth, mitmConnectIPKey(r)); !d.Allow {
+ if d := p.rateLimit.Allow(ratelimit.TierAuth, mitmIPKey(r)); !d.Allow {
ratelimit.WriteDenial(w, d, "Too many CONNECT attempts")
return
}
diff --git a/internal/mitm/forward.go b/internal/mitm/forward.go
index eda75eb..f45eca9 100644
--- a/internal/mitm/forward.go
+++ b/internal/mitm/forward.go
@@ -4,8 +4,10 @@ import (
"errors"
"io"
"log/slog"
+ "net"
"net/http"
"net/url"
+ "strings"
"time"
"github.com/Infisical/agent-vault/internal/brokercore"
@@ -28,6 +30,95 @@ func actorFromScope(scope *brokercore.ProxyScope) (string, string) {
return "", ""
}
+// isAbsoluteForwardProxyRequest reports whether r is a well-formed
+// absolute-form forward-proxy request that handleForward can serve.
+//
+// Per RFC 7230 §5.3.2 a forward-proxy request looks like:
+//
+// POST http://upstream.example/path HTTP/1.1
+//
+// We accept only http://. https:// is rejected because we will not
+// silently TLS-strip — clients must use CONNECT for HTTPS upstreams.
+// Origin-form (POST /path) lacks a scheme/host and is rejected so the
+// proxy ingress can never be used as if it were an origin server.
+// Other schemes (ws, ftp, file, gopher, …) are rejected likewise.
+func isAbsoluteForwardProxyRequest(r *http.Request) bool {
+ if r.URL == nil {
+ return false
+ }
+ if !strings.EqualFold(r.URL.Scheme, "http") {
+ return false
+ }
+ if r.URL.Host == "" {
+ return false
+ }
+ // url.ParseRequestURI rejects fragments in the request line, but be
+ // belt-and-braces — RFC 7230 §5.3.2 forbids them.
+ if r.URL.Fragment != "" {
+ return false
+ }
+ return true
+}
+
+// handleForward serves an absolute-form forward-proxy request for an
+// http:// upstream. Compared to the CONNECT path: no hijack (the
+// response is a normal HTTP/1.1 reply over the existing TLS-wrapped
+// connection), and the scope is resolved per request rather than once
+// per tunnel.
+func (p *Proxy) handleForward(w http.ResponseWriter, r *http.Request) {
+ // Per-IP flood gate before ParseProxyAuth + session lookup so a
+ // bad-auth flood can't burn CPU. Loopback is exempt — see
+ // isLoopbackPeer. Shares the TierAuth budget and key shape with
+ // CONNECT: one peer = one budget regardless of ingress shape.
+ if p.rateLimit != nil && !isLoopbackPeer(r) {
+ if d := p.rateLimit.Allow(ratelimit.TierAuth, mitmIPKey(r)); !d.Allow {
+ ratelimit.WriteDenial(w, d, "Too many proxy requests")
+ return
+ }
+ }
+
+ // Canonicalise host and target. r.URL.Host is "host" (no port) or
+ // "host:port" depending on the request line. Default to :80 when
+ // omitted so event.Host and outURL.Host stay consistent with the
+ // CONNECT-path invariant ("host:port present").
+ urlHost := r.URL.Host
+ host, port, err := net.SplitHostPort(urlHost)
+ if err != nil {
+ host = urlHost
+ port = "80"
+ }
+ target := net.JoinHostPort(host, port)
+
+ if !isValidHost(host) {
+ http.Error(w, "invalid host", http.StatusBadRequest)
+ return
+ }
+
+ // Some upstreams reject empty request-targets. Per RFC 7230 §5.3.1
+ // a client SHOULD send "/" when no path is present; normalise so
+ // the outbound URL we build always has one.
+ if r.URL.Path == "" {
+ r.URL.Path = "/"
+ }
+
+ // Per RFC 7230 §5.4 a proxy receiving an absolute-form request MUST
+ // ignore the Host header — r.URL.Host is authoritative. We don't
+ // reject on mismatch; we just don't read r.Host for routing.
+
+ token, hint, err := brokercore.ParseProxyAuth(r)
+ if err != nil {
+ writeProxyAuthChallenge(w, "Proxy-Authorization required")
+ return
+ }
+ scope, err := p.sessions.ResolveForProxy(r.Context(), token, hint)
+ if err != nil {
+ writeAuthError(w, err)
+ return
+ }
+
+ p.forwardRequest(w, r, target, host, false, scope)
+}
+
// forwardHandler returns an http.Handler that forwards each request to
// target (the host:port captured from the original CONNECT line). Using
// a closed-over target rather than r.Host defeats post-tunnel host
@@ -35,131 +126,148 @@ func actorFromScope(scope *brokercore.ProxyScope) (string, string) {
// handleConnect; scope is the vault context resolved at CONNECT time.
func (p *Proxy) forwardHandler(target, host string, scope *brokercore.ProxyScope) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- start := time.Now()
- event := brokercore.ProxyEvent{
- Ingress: brokercore.IngressMITM,
- Method: r.Method,
- Host: target,
- Path: r.URL.Path,
- }
- actorType, actorID := actorFromScope(scope)
- emit := func(status int, errCode string) {
- event.Emit(p.logger, start, status, errCode)
- if p.logSink != nil {
- p.logSink.Record(r.Context(), requestlog.FromEvent(event, scope.VaultID, actorType, actorID))
- }
- }
+ p.forwardRequest(w, r, target, host, true, scope)
+ })
+}
- enf := p.rateLimit.EnforceProxy(r.Context(), scope.ActorID(), scope.VaultID)
- if !enf.Allowed {
- ratelimit.WriteDenial(w, enf.Decision, enf.Message)
- emit(http.StatusTooManyRequests, enf.ErrCode)
- return
- }
- defer enf.Release()
+// forwardRequest is the shared body for both the CONNECT-tunnelled HTTPS
+// path (forwardHandler) and the plain-HTTP forward-proxy path
+// (handleForward). target is the canonical "host:port"; host is the
+// port-stripped form used for credential lookup. useTLSUpstream selects
+// https vs http for the outbound URL.
+func (p *Proxy) forwardRequest(
+ w http.ResponseWriter,
+ r *http.Request,
+ target, host string,
+ useTLSUpstream bool,
+ scope *brokercore.ProxyScope,
+) {
+ start := time.Now()
+ event := brokercore.ProxyEvent{
+ Ingress: brokercore.IngressMITM,
+ Method: r.Method,
+ Host: target,
+ Path: r.URL.Path,
+ }
+ actorType, actorID := actorFromScope(scope)
+ emit := func(status int, errCode string) {
+ event.Emit(p.logger, start, status, errCode)
+ p.logSink.Record(r.Context(), requestlog.FromEvent(event, scope.VaultID, actorType, actorID))
+ }
- r.Body = http.MaxBytesReader(w, r.Body, brokercore.MaxProxyBodyBytes)
+ enf := p.rateLimit.EnforceProxy(r.Context(), scope.ActorID(), scope.VaultID)
+ if !enf.Allowed {
+ ratelimit.WriteDenial(w, enf.Decision, enf.Message)
+ emit(http.StatusTooManyRequests, enf.ErrCode)
+ return
+ }
+ defer enf.Release()
- outURL := &url.URL{
- Scheme: "https",
- Host: target,
- Path: r.URL.Path,
- RawPath: r.URL.RawPath,
- RawQuery: r.URL.RawQuery,
- }
+ r.Body = http.MaxBytesReader(w, r.Body, brokercore.MaxProxyBodyBytes)
- body, contentLength, err := brokercore.MaterializeRequestBody(r.Body)
- if err != nil {
- status, code := brokercore.RequestBodyErrorCode(err)
- http.Error(w, http.StatusText(status), status)
- emit(status, code)
- return
- }
+ scheme := "http"
+ if useTLSUpstream {
+ scheme = "https"
+ }
+ outURL := &url.URL{
+ Scheme: scheme,
+ Host: target,
+ Path: r.URL.Path,
+ RawPath: r.URL.RawPath,
+ RawQuery: r.URL.RawQuery,
+ }
- outReq, err := http.NewRequestWithContext(r.Context(), r.Method, outURL.String(), body)
- if err != nil {
- http.Error(w, "bad gateway", http.StatusBadGateway)
- emit(http.StatusBadGateway, "internal")
- return
- }
- outReq.Host = host
- outReq.ContentLength = contentLength
-
- inject, err := p.creds.Inject(r.Context(), scope.VaultID, host)
- if inject != nil {
- event.MatchedService = inject.MatchedHost
- event.CredentialKeys = inject.CredentialKeys
- event.Passthrough = inject.Passthrough
- }
- if err != nil {
- errCode := "no_match"
- status := http.StatusForbidden
- if errors.Is(err, brokercore.ErrCredentialMissing) {
- errCode = "credential_not_found"
- status = http.StatusBadGateway
- brokercore.LogCredentialMissing(p.logger, scope.VaultID, event.MatchedService, event.CredentialKeys)
- }
- brokercore.WriteInjectError(w, err, host, scope.VaultName, p.baseURL)
- emit(status, errCode)
- return
- }
+ body, contentLength, err := brokercore.MaterializeRequestBody(r.Body)
+ if err != nil {
+ status, code := brokercore.RequestBodyErrorCode(err)
+ http.Error(w, http.StatusText(status), status)
+ emit(status, code)
+ return
+ }
- wsUpgrade := isWebSocketUpgrade(r)
-
- // WS handshake needs Connection/Upgrade through, but ApplyInjection
- // would drop them as hop-by-hop. Copy the full handshake set
- // manually, then tell ApplyInjection to skip them so the
- // non-hop-by-hop ones (Origin, Sec-*) aren't duplicated. Injection
- // still wins on overlapping names (Authorization etc.) because
- // inject.Headers is Set last by ApplyInjection.
- if wsUpgrade {
- copyWebSocketHandshakeHeaders(r.Header, outReq.Header)
- brokercore.ApplyInjection(r.Header, outReq.Header, inject, websocketHandshakeHeaderNames...)
- } else {
- // No extraStrip: Proxy-Authorization is already in the broker
- // denylist, and Authorization is the client's upstream header.
- brokercore.ApplyInjection(r.Header, outReq.Header, inject)
- }
+ outReq, err := http.NewRequestWithContext(r.Context(), r.Method, outURL.String(), body)
+ if err != nil {
+ http.Error(w, "bad gateway", http.StatusBadGateway)
+ emit(http.StatusBadGateway, "internal")
+ return
+ }
+ outReq.Host = host
+ outReq.ContentLength = contentLength
- // Apply any declared substitutions to the outbound URL and
- // headers. Surfaces not listed in the substitution's `in:` are
- // not scanned — scope is the security boundary.
- if err := brokercore.ApplySubstitutions(outReq.URL, outReq.Header, inject.Substitutions); err != nil {
- http.Error(w, "bad gateway", http.StatusBadGateway)
- emit(http.StatusBadGateway, "substitution_error")
- return
+ inject, err := p.creds.Inject(r.Context(), scope.VaultID, host)
+ if inject != nil {
+ event.MatchedService = inject.MatchedHost
+ event.CredentialKeys = inject.CredentialKeys
+ event.Passthrough = inject.Passthrough
+ }
+ if err != nil {
+ errCode := "no_match"
+ status := http.StatusForbidden
+ if errors.Is(err, brokercore.ErrCredentialMissing) {
+ errCode = "credential_not_found"
+ status = http.StatusBadGateway
+ brokercore.LogCredentialMissing(p.logger, scope.VaultID, event.MatchedService, event.CredentialKeys)
}
+ brokercore.WriteInjectError(w, err, host, scope.VaultName, p.baseURL)
+ emit(status, errCode)
+ return
+ }
- if wsUpgrade {
- p.forwardWebSocket(w, r, outReq, emit)
- return
- }
+ wsUpgrade := isWebSocketUpgrade(r)
- resp, err := p.upstream.RoundTrip(outReq)
- if err != nil {
- // Log the actual error for operators while sending generic message to client.
- p.logger.Debug("upstream request failed",
- slog.String("vault_id", scope.VaultID),
- slog.String("vault_name", scope.VaultName),
- slog.String("target_host", target),
- slog.String("error", err.Error()),
- )
- http.Error(w, "bad gateway", http.StatusBadGateway)
- emit(http.StatusBadGateway, "upstream_error")
- return
+ // WS handshake needs Connection/Upgrade through, but ApplyInjection
+ // would drop them as hop-by-hop. Copy the full handshake set
+ // manually, then tell ApplyInjection to skip them so the
+ // non-hop-by-hop ones (Origin, Sec-*) aren't duplicated. Injection
+ // still wins on overlapping names (Authorization etc.) because
+ // inject.Headers is Set last by ApplyInjection.
+ if wsUpgrade {
+ copyWebSocketHandshakeHeaders(r.Header, outReq.Header)
+ brokercore.ApplyInjection(r.Header, outReq.Header, inject, websocketHandshakeHeaderNames...)
+ } else {
+ // No extraStrip: Proxy-Authorization is already in the broker
+ // denylist, and Authorization is the client's upstream header.
+ brokercore.ApplyInjection(r.Header, outReq.Header, inject)
+ }
+
+ // Apply any declared substitutions to the outbound URL and
+ // headers. Surfaces not listed in the substitution's `in:` are
+ // not scanned — scope is the security boundary.
+ if err := brokercore.ApplySubstitutions(outReq.URL, outReq.Header, inject.Substitutions); err != nil {
+ http.Error(w, "bad gateway", http.StatusBadGateway)
+ emit(http.StatusBadGateway, "substitution_error")
+ return
+ }
+
+ if wsUpgrade {
+ p.forwardWebSocket(w, r, outReq, emit)
+ return
+ }
+
+ resp, err := p.upstream.RoundTrip(outReq)
+ if err != nil {
+ // Log the actual error for operators while sending generic message to client.
+ p.logger.Debug("upstream request failed",
+ slog.String("vault_id", scope.VaultID),
+ slog.String("vault_name", scope.VaultName),
+ slog.String("target_host", target),
+ slog.String("error", err.Error()),
+ )
+ http.Error(w, "bad gateway", http.StatusBadGateway)
+ emit(http.StatusBadGateway, "upstream_error")
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ for k, vv := range resp.Header {
+ if brokercore.ShouldStripResponseHeader(k) {
+ continue
}
- defer func() { _ = resp.Body.Close() }()
-
- for k, vv := range resp.Header {
- if brokercore.ShouldStripResponseHeader(k) {
- continue
- }
- for _, v := range vv {
- w.Header().Add(k, v)
- }
+ for _, v := range vv {
+ w.Header().Add(k, v)
}
- w.WriteHeader(resp.StatusCode)
- _, _ = io.Copy(w, io.LimitReader(resp.Body, brokercore.MaxResponseBytes))
- emit(resp.StatusCode, "")
- })
+ }
+ w.WriteHeader(resp.StatusCode)
+ _, _ = io.Copy(w, io.LimitReader(resp.Body, brokercore.MaxResponseBytes))
+ emit(resp.StatusCode, "")
}
diff --git a/internal/mitm/forward_test.go b/internal/mitm/forward_test.go
new file mode 100644
index 0000000..51e6e4b
--- /dev/null
+++ b/internal/mitm/forward_test.go
@@ -0,0 +1,589 @@
+package mitm
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/base64"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+
+ "github.com/Infisical/agent-vault/internal/brokercore"
+ "github.com/Infisical/agent-vault/internal/netguard"
+ "github.com/Infisical/agent-vault/internal/requestlog"
+)
+
+// recordingSink captures records from the forward path so tests can
+// assert the audit log shape end-to-end.
+type recordingSink struct {
+ mu sync.Mutex
+ records []requestlog.Record
+}
+
+func (s *recordingSink) Record(_ context.Context, r requestlog.Record) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.records = append(s.records, r)
+}
+
+func (s *recordingSink) snapshot() []requestlog.Record {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ out := make([]requestlog.Record, len(s.records))
+ copy(out, s.records)
+ return out
+}
+
+// dialProxyTLS opens a raw TLS connection to the proxy listener (no
+// CONNECT, no absolute-form request). Tests use it to write malformed
+// or hand-shaped request lines so we can exercise the dispatch
+// validator directly.
+func dialProxyTLS(t *testing.T, proxyURL *url.URL, roots *x509.CertPool) net.Conn {
+ t.Helper()
+ conn, err := tls.Dial("tcp", proxyURL.Host, &tls.Config{
+ MinVersion: tls.VersionTLS12,
+ RootCAs: roots,
+ })
+ if err != nil {
+ t.Fatalf("tls.Dial proxy: %v", err)
+ }
+ return conn
+}
+
+// writeRawRequestLine writes an arbitrary request line + headers to the
+// proxy and returns the parsed response. The HTTP method passed to
+// http.ReadResponse is informational only (controls how the reader
+// handles bodies for HEAD); for our tests "GET" is fine across the
+// board.
+func writeRawRequestLine(t *testing.T, conn net.Conn, line string, headers map[string]string) *http.Response {
+ t.Helper()
+ var buf bytes.Buffer
+ buf.WriteString(line + "\r\n")
+ for k, v := range headers {
+ fmt.Fprintf(&buf, "%s: %s\r\n", k, v)
+ }
+ buf.WriteString("\r\n")
+ if _, err := conn.Write(buf.Bytes()); err != nil {
+ t.Fatalf("write request: %v", err)
+ }
+ resp, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: http.MethodGet})
+ if err != nil {
+ t.Fatalf("read response: %v", err)
+ }
+ return resp
+}
+
+// TestMITMForwardPlainHTTPInjectsCredentials is the flagship test for
+// the plain-HTTP forward-proxy path. A standard Go client with
+// HTTPS_PROXY pointing at the TLS-wrapped proxy sends an absolute-form
+// request to a plain-HTTP upstream; the proxy authenticates, injects
+// the configured credential, strips broker-scoped headers, and returns
+// the upstream response.
+func TestMITMForwardPlainHTTPInjectsCredentials(t *testing.T) {
+ var sawAuth, sawClientHeader, sawProxyAuth, sawHost, sawMethod, sawPath string
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ sawAuth = r.Header.Get("Authorization")
+ sawClientHeader = r.Header.Get("X-Client-Header")
+ sawProxyAuth = r.Header.Get("Proxy-Authorization")
+ sawHost = r.Host
+ sawMethod = r.Method
+ sawPath = r.URL.Path
+ _, _ = io.WriteString(w, "plain-http-ok")
+ }))
+ defer upstream.Close()
+
+ upstreamHost, _, _ := net.SplitHostPort(strings.TrimPrefix(upstream.URL, "http://"))
+
+ sr := validTokenResolver("av_sess_ok",
+ &brokercore.ProxyScope{VaultID: "v1", VaultName: "default", VaultRole: "proxy"})
+ cp := &fakeCredProvider{byHost: map[string]fakeInjectResult{
+ upstreamHost: {result: &brokercore.InjectResult{
+ Headers: map[string]string{"Authorization": "Bearer injected-secret"},
+ }},
+ }}
+
+ proxyURL, clientRoots, _ := setupProxy(t, sr, cp)
+
+ client := newTrustingClient(proxyURL, url.User("av_sess_ok"), clientRoots)
+
+ req, err := http.NewRequest("POST", upstream.URL+"/v1/messages", strings.NewReader("hello"))
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ req.Header.Set("Authorization", "Bearer client-should-not-win")
+ req.Header.Set("X-Client-Header", "client-value")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("client.Do: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ if string(body) != "plain-http-ok" {
+ t.Fatalf("body = %q", body)
+ }
+ if sawMethod != "POST" {
+ t.Errorf("upstream method = %q, want POST", sawMethod)
+ }
+ if sawPath != "/v1/messages" {
+ t.Errorf("upstream path = %q, want /v1/messages", sawPath)
+ }
+ if sawAuth != "Bearer injected-secret" {
+ t.Errorf("upstream Authorization = %q, want injected", sawAuth)
+ }
+ if sawClientHeader != "client-value" {
+ t.Errorf("upstream X-Client-Header = %q, want passthrough", sawClientHeader)
+ }
+ if sawProxyAuth != "" {
+ t.Errorf("upstream Proxy-Authorization = %q; must be stripped", sawProxyAuth)
+ }
+ // handleForward sets outReq.Host to the port-stripped form (passed
+ // as `host` into forwardRequest); upstream sees that exact value.
+ if sawHost != upstreamHost {
+ t.Errorf("upstream Host = %q, want %q", sawHost, upstreamHost)
+ }
+}
+
+// TestMITMForwardRejectsHTTPSScheme: a client erroneously sending
+// `POST https://upstream/...` (absolute form) to the forward-proxy
+// must be rejected with 400, not silently TLS-stripped. The hint
+// nudges the client toward CONNECT for HTTPS upstreams.
+func TestMITMForwardRejectsHTTPSScheme(t *testing.T) {
+ proxyURL, clientRoots, _ := setupProxy(t,
+ validTokenResolver("av_sess_ok", &brokercore.ProxyScope{VaultID: "v1", VaultName: "default", VaultRole: "proxy"}),
+ &fakeCredProvider{})
+
+ conn := dialProxyTLS(t, proxyURL, clientRoots)
+ defer conn.Close()
+
+ auth := base64.StdEncoding.EncodeToString([]byte("av_sess_ok:"))
+ resp := writeRawRequestLine(t, conn,
+ "POST https://api.example.com/x HTTP/1.1",
+ map[string]string{
+ "Host": "api.example.com",
+ "Proxy-Authorization": "Basic " + auth,
+ "Content-Length": "0",
+ })
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400 for https:// forward-proxy form", resp.StatusCode)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(strings.ToLower(string(body)), "connect") {
+ t.Errorf("400 body should hint at CONNECT for https; got %q", body)
+ }
+}
+
+// TestMITMForwardRejectsNonHTTPSchemes: schemes other than http (file,
+// gopher, ftp, ws) reach the listener over TLS but are rejected as
+// malformed forward-proxy requests.
+func TestMITMForwardRejectsNonHTTPSchemes(t *testing.T) {
+ proxyURL, clientRoots, _ := setupProxy(t,
+ validTokenResolver("av_sess_ok", &brokercore.ProxyScope{VaultID: "v1", VaultName: "default", VaultRole: "proxy"}),
+ &fakeCredProvider{})
+
+ auth := base64.StdEncoding.EncodeToString([]byte("av_sess_ok:"))
+ for _, scheme := range []string{"file", "gopher", "ftp", "ws"} {
+ t.Run(scheme, func(t *testing.T) {
+ conn := dialProxyTLS(t, proxyURL, clientRoots)
+ defer conn.Close()
+
+ line := fmt.Sprintf("GET %s://example.com/x HTTP/1.1", scheme)
+ resp := writeRawRequestLine(t, conn, line, map[string]string{
+ "Host": "example.com",
+ "Proxy-Authorization": "Basic " + auth,
+ })
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("scheme %q: status = %d, want 400", scheme, resp.StatusCode)
+ }
+ })
+ }
+}
+
+// TestMITMForwardRequiresProxyAuthorization: a forward request without
+// Proxy-Authorization gets a 407 challenge with Proxy-Authenticate.
+func TestMITMForwardRequiresProxyAuthorization(t *testing.T) {
+ proxyURL, clientRoots, _ := setupProxy(t,
+ validTokenResolver("av_sess_ok", &brokercore.ProxyScope{VaultID: "v1", VaultName: "default", VaultRole: "proxy"}),
+ &fakeCredProvider{})
+
+ conn := dialProxyTLS(t, proxyURL, clientRoots)
+ defer conn.Close()
+
+ resp := writeRawRequestLine(t, conn,
+ "GET http://example.com/x HTTP/1.1",
+ map[string]string{"Host": "example.com"})
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusProxyAuthRequired {
+ t.Fatalf("status = %d, want 407", resp.StatusCode)
+ }
+ if got := resp.Header.Get("Proxy-Authenticate"); !strings.Contains(got, "Basic") {
+ t.Errorf("Proxy-Authenticate = %q, want Basic challenge", got)
+ }
+}
+
+// TestMITMForwardInvalidSessionReturns407: a forward request with an
+// unknown token gets the same 407 challenge.
+func TestMITMForwardInvalidSessionReturns407(t *testing.T) {
+ proxyURL, clientRoots, _ := setupProxy(t,
+ errResolver(brokercore.ErrInvalidSession),
+ &fakeCredProvider{})
+
+ conn := dialProxyTLS(t, proxyURL, clientRoots)
+ defer conn.Close()
+
+ auth := base64.StdEncoding.EncodeToString([]byte("av_sess_bad:"))
+ resp := writeRawRequestLine(t, conn,
+ "GET http://example.com/x HTTP/1.1",
+ map[string]string{
+ "Host": "example.com",
+ "Proxy-Authorization": "Basic " + auth,
+ })
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusProxyAuthRequired {
+ t.Fatalf("status = %d, want 407", resp.StatusCode)
+ }
+}
+
+// TestMITMForwardVaultHintMismatchReturns403: parallels the CONNECT-
+// path test — ErrVaultHintMismatch from the resolver maps to 403.
+func TestMITMForwardVaultHintMismatchReturns403(t *testing.T) {
+ proxyURL, clientRoots, _ := setupProxy(t,
+ errResolver(brokercore.ErrVaultHintMismatch),
+ &fakeCredProvider{})
+
+ conn := dialProxyTLS(t, proxyURL, clientRoots)
+ defer conn.Close()
+
+ auth := base64.StdEncoding.EncodeToString([]byte("av_sess_ok:wrongvault"))
+ resp := writeRawRequestLine(t, conn,
+ "GET http://example.com/x HTTP/1.1",
+ map[string]string{
+ "Host": "example.com",
+ "Proxy-Authorization": "Basic " + auth,
+ })
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusForbidden {
+ t.Fatalf("status = %d, want 403", resp.StatusCode)
+ }
+}
+
+// TestMITMForwardStripsHopByHopHeaders verifies RFC 7230 §6.1: any
+// header named in the client's Connection field is stripped before
+// forwarding, alongside the static hop-by-hop set and the broker-
+// scoped Proxy-Authorization / X-Vault headers.
+func TestMITMForwardStripsHopByHopHeaders(t *testing.T) {
+ var sawCustom, sawConnection, sawProxyAuth, sawXVault, sawTE string
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ sawCustom = r.Header.Get("X-Custom")
+ sawConnection = r.Header.Get("Connection")
+ sawProxyAuth = r.Header.Get("Proxy-Authorization")
+ sawXVault = r.Header.Get("X-Vault")
+ sawTE = r.Header.Get("Te")
+ _, _ = io.WriteString(w, "ok")
+ }))
+ defer upstream.Close()
+
+ upstreamHost, _, _ := net.SplitHostPort(strings.TrimPrefix(upstream.URL, "http://"))
+ sr := validTokenResolver("av_sess_ok",
+ &brokercore.ProxyScope{VaultID: "v1", VaultName: "default", VaultRole: "proxy"})
+ cp := &fakeCredProvider{byHost: map[string]fakeInjectResult{
+ upstreamHost: {result: &brokercore.InjectResult{Passthrough: true}},
+ }}
+
+ proxyURL, clientRoots, _ := setupProxy(t, sr, cp)
+
+ conn := dialProxyTLS(t, proxyURL, clientRoots)
+ defer conn.Close()
+
+ auth := base64.StdEncoding.EncodeToString([]byte("av_sess_ok:"))
+ resp := writeRawRequestLine(t, conn,
+ "GET "+upstream.URL+"/x HTTP/1.1",
+ map[string]string{
+ "Host": upstreamHost,
+ "Proxy-Authorization": "Basic " + auth,
+ "X-Vault": "default",
+ "Connection": "X-Custom, close",
+ "X-Custom": "secret",
+ "Te": "trailers",
+ })
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if sawCustom != "" {
+ t.Errorf("upstream X-Custom = %q; must be stripped (named in Connection)", sawCustom)
+ }
+ if sawConnection != "" {
+ t.Errorf("upstream Connection = %q; must be stripped (hop-by-hop)", sawConnection)
+ }
+ if sawProxyAuth != "" {
+ t.Errorf("upstream Proxy-Authorization = %q; must be stripped (broker-scoped)", sawProxyAuth)
+ }
+ if sawXVault != "" {
+ t.Errorf("upstream X-Vault = %q; must be stripped (broker-scoped)", sawXVault)
+ }
+ if sawTE != "" {
+ t.Errorf("upstream TE = %q; must be stripped (hop-by-hop)", sawTE)
+ }
+}
+
+// TestMITMForwardWebSocketPlainHTTP: a ws:// upgrade through the
+// forward proxy succeeds; frames pipe both ways. Exercises the
+// scheme-aware branch in dialWebSocketUpstream that skips TLS for
+// http:// upstreams.
+func TestMITMForwardWebSocketPlainHTTP(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") {
+ http.Error(w, "expected websocket", http.StatusBadRequest)
+ return
+ }
+ key := r.Header.Get("Sec-WebSocket-Key")
+ hijacker, ok := w.(http.Hijacker)
+ if !ok {
+ http.Error(w, "no hijacker", http.StatusInternalServerError)
+ return
+ }
+ conn, buf, err := hijacker.Hijack()
+ if err != nil {
+ t.Errorf("upstream hijack: %v", err)
+ return
+ }
+ defer conn.Close()
+
+ acc := websocketAccept(key)
+ fmt.Fprintf(buf,
+ "HTTP/1.1 101 Switching Protocols\r\n"+
+ "Upgrade: websocket\r\n"+
+ "Connection: Upgrade\r\n"+
+ "Sec-WebSocket-Accept: %s\r\n\r\n",
+ acc)
+ _ = buf.Flush()
+
+ frame, ferr := readWebSocketTextFrame(buf.Reader)
+ if ferr != nil {
+ return
+ }
+ _ = writeWebSocketTextFrame(conn, "echo:"+frame, false)
+ }))
+ defer upstream.Close()
+
+ upstreamHost, _, _ := net.SplitHostPort(strings.TrimPrefix(upstream.URL, "http://"))
+ sr := validTokenResolver("av_sess_ok",
+ &brokercore.ProxyScope{VaultID: "v1", VaultName: "default", VaultRole: "proxy"})
+ cp := &fakeCredProvider{byHost: map[string]fakeInjectResult{
+ upstreamHost: {result: &brokercore.InjectResult{Passthrough: true}},
+ }}
+ proxyURL, clientRoots, _ := setupProxy(t, sr, cp)
+
+ conn := dialProxyTLS(t, proxyURL, clientRoots)
+ defer conn.Close()
+
+ keyBytes := make([]byte, 16)
+ for i := range keyBytes {
+ keyBytes[i] = byte(i + 1)
+ }
+ clientKey := base64.StdEncoding.EncodeToString(keyBytes)
+ auth := base64.StdEncoding.EncodeToString([]byte("av_sess_ok:"))
+ fmt.Fprintf(conn,
+ "GET %s/ws HTTP/1.1\r\n"+
+ "Host: %s\r\n"+
+ "Proxy-Authorization: Basic %s\r\n"+
+ "Upgrade: websocket\r\n"+
+ "Connection: Upgrade\r\n"+
+ "Sec-WebSocket-Key: %s\r\n"+
+ "Sec-WebSocket-Version: 13\r\n\r\n",
+ upstream.URL, upstreamHost, auth, clientKey)
+
+ reader := bufio.NewReader(conn)
+ resp, err := http.ReadResponse(reader, &http.Request{Method: http.MethodGet})
+ if err != nil {
+ t.Fatalf("read switching response: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusSwitchingProtocols {
+ t.Fatalf("status = %d, want 101", resp.StatusCode)
+ }
+ wantAccept := websocketAccept(clientKey)
+ if got := resp.Header.Get("Sec-WebSocket-Accept"); got != wantAccept {
+ t.Errorf("Sec-WebSocket-Accept = %q, want %q", got, wantAccept)
+ }
+
+ if err := writeWebSocketTextFrame(conn, "hi", true); err != nil {
+ t.Fatalf("write frame: %v", err)
+ }
+ got, err := readWebSocketTextFrame(reader)
+ if err != nil {
+ t.Fatalf("read frame: %v", err)
+ }
+ if got != "echo:hi" {
+ t.Fatalf("got %q, want echo:hi", got)
+ }
+}
+
+// TestMITMForwardSSRFLoopbackBlocked: with the upstream Transport
+// configured with netguard.SafeDialContext(false) (no allowlist), a
+// forward request to 127.0.0.1 must be rejected at dial time and
+// surfaced as 502.
+func TestMITMForwardSSRFLoopbackBlocked(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, "should-not-reach")
+ }))
+ defer upstream.Close()
+
+ upstreamHost, _, _ := net.SplitHostPort(strings.TrimPrefix(upstream.URL, "http://"))
+ sr := validTokenResolver("av_sess_ok",
+ &brokercore.ProxyScope{VaultID: "v1", VaultName: "default", VaultRole: "proxy"})
+ cp := &fakeCredProvider{byHost: map[string]fakeInjectResult{
+ upstreamHost: {result: &brokercore.InjectResult{Passthrough: true}},
+ }}
+ proxyURL, clientRoots, p := setupProxy(t, sr, cp)
+ // Override the dialler with a stricter SafeDialContext that blocks
+ // loopback (setupProxy seeded ALLOW_PRIVATE_RANGES=true for the
+ // other tests; we bypass that policy here directly).
+ p.upstream.DialContext = netguard.SafeDialContext(false)
+
+ client := newTrustingClient(proxyURL, url.User("av_sess_ok"), clientRoots)
+ resp, err := client.Get(upstream.URL + "/x")
+ if err != nil {
+ t.Fatalf("client.Get: %v", err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusBadGateway {
+ t.Fatalf("status = %d, want 502 (loopback blocked at dial)", resp.StatusCode)
+ }
+}
+
+// TestMITMForwardEmitsRequestLogRow asserts the audit row shape on the
+// plain-HTTP forward path: ingress, method, host (with port), path,
+// vault id, actor type+id, status.
+func TestMITMForwardEmitsRequestLogRow(t *testing.T) {
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = io.WriteString(w, "ok")
+ }))
+ defer upstream.Close()
+
+ upstreamHost, _, _ := net.SplitHostPort(strings.TrimPrefix(upstream.URL, "http://"))
+ sr := validTokenResolver("av_sess_ok",
+ &brokercore.ProxyScope{
+ AgentID: "agent-42",
+ VaultID: "v1",
+ VaultName: "default",
+ VaultRole: "proxy",
+ })
+ cp := &fakeCredProvider{byHost: map[string]fakeInjectResult{
+ upstreamHost: {result: &brokercore.InjectResult{
+ MatchedHost: upstreamHost,
+ Headers: map[string]string{"Authorization": "Bearer x"},
+ }},
+ }}
+ sink := &recordingSink{}
+ proxyURL, clientRoots, _ := setupProxy(t, sr, cp, func(o *Options) { o.LogSink = sink })
+
+ client := newTrustingClient(proxyURL, url.User("av_sess_ok"), clientRoots)
+ req, err := http.NewRequest("POST", upstream.URL+"/v1/things", strings.NewReader("payload"))
+ if err != nil {
+ t.Fatalf("new request: %v", err)
+ }
+ resp, err := client.Do(req)
+ if err != nil {
+ t.Fatalf("client.Do: %v", err)
+ }
+ _ = resp.Body.Close()
+
+ rows := sink.snapshot()
+ if len(rows) != 1 {
+ t.Fatalf("got %d records, want 1", len(rows))
+ }
+ row := rows[0]
+ if row.Ingress != brokercore.IngressMITM {
+ t.Errorf("Ingress = %q, want %q", row.Ingress, brokercore.IngressMITM)
+ }
+ if row.Method != "POST" {
+ t.Errorf("Method = %q, want POST", row.Method)
+ }
+ // handleForward canonicalises the target so event.Host is "host:port".
+ if !strings.Contains(row.Host, ":") {
+ t.Errorf("Host = %q, want host:port form (handleForward canonicalises)", row.Host)
+ }
+ if !strings.HasPrefix(row.Host, strings.SplitN(upstreamHost, ":", 2)[0]) {
+ t.Errorf("Host = %q, want it to reference upstream host %q", row.Host, upstreamHost)
+ }
+ if row.Path != "/v1/things" {
+ t.Errorf("Path = %q, want /v1/things", row.Path)
+ }
+ if row.VaultID != "v1" {
+ t.Errorf("VaultID = %q, want v1", row.VaultID)
+ }
+ if row.ActorType != brokercore.ActorTypeAgent {
+ t.Errorf("ActorType = %q, want agent", row.ActorType)
+ }
+ if row.ActorID != "agent-42" {
+ t.Errorf("ActorID = %q, want agent-42", row.ActorID)
+ }
+ if row.Status != http.StatusOK {
+ t.Errorf("Status = %d, want 200", row.Status)
+ }
+ if row.MatchedService != upstreamHost {
+ t.Errorf("MatchedService = %q, want %q", row.MatchedService, upstreamHost)
+ }
+}
+
+// TestMITMForwardKeepalivePersistsAcrossRequests: two back-to-back
+// forward requests over the same TLS-to-proxy connection both succeed.
+// Per-request scope resolution and per-request EnforceProxy must work
+// without state leaks between requests on the keepalive channel.
+func TestMITMForwardKeepalivePersistsAcrossRequests(t *testing.T) {
+ var hits atomic.Int32
+ upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ hits.Add(1)
+ _, _ = io.WriteString(w, "ok")
+ }))
+ defer upstream.Close()
+
+ upstreamHost, _, _ := net.SplitHostPort(strings.TrimPrefix(upstream.URL, "http://"))
+ sr := validTokenResolver("av_sess_ok",
+ &brokercore.ProxyScope{VaultID: "v1", VaultName: "default", VaultRole: "proxy"})
+ cp := &fakeCredProvider{byHost: map[string]fakeInjectResult{
+ upstreamHost: {result: &brokercore.InjectResult{Passthrough: true}},
+ }}
+ sink := &recordingSink{}
+ proxyURL, clientRoots, _ := setupProxy(t, sr, cp, func(o *Options) { o.LogSink = sink })
+
+ client := newTrustingClient(proxyURL, url.User("av_sess_ok"), clientRoots)
+
+ for i := 0; i < 2; i++ {
+ resp, err := client.Get(upstream.URL + "/x")
+ if err != nil {
+ t.Fatalf("request %d: %v", i, err)
+ }
+ _, _ = io.Copy(io.Discard, resp.Body)
+ _ = resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("request %d: status = %d, want 200", i, resp.StatusCode)
+ }
+ }
+
+ if got := hits.Load(); got != 2 {
+ t.Fatalf("upstream hits = %d, want 2", got)
+ }
+ if rows := sink.snapshot(); len(rows) != 2 {
+ t.Fatalf("got %d log rows, want 2", len(rows))
+ }
+}
diff --git a/internal/mitm/proxy.go b/internal/mitm/proxy.go
index b41dd1c..ebda33e 100644
--- a/internal/mitm/proxy.go
+++ b/internal/mitm/proxy.go
@@ -1,23 +1,35 @@
-// Package mitm implements a transparent TLS-intercepting HTTP proxy.
+// Package mitm implements a TLS-wrapped HTTP/1.1 proxy ingress for
+// agent traffic.
//
-// A Proxy accepts HTTP CONNECT on a TLS-encrypted listener, hijacks the
-// connection, terminates client-side TLS using a certificate minted on
-// demand by a ca.Provider, and forwards each HTTP/1.1 request to the
-// originally-requested upstream over a fresh TLS connection with strict
-// verification against the system trust store.
+// A Proxy accepts two request shapes on the same TLS-wrapped listener:
//
-// The listener itself is TLS-wrapped so that the CONNECT handshake
-// (which carries session tokens in Proxy-Authorization) is encrypted.
-// Clients use HTTPS_PROXY=https://... and trust the same CA that signs
+// - CONNECT host:port — for HTTPS upstreams. The proxy hijacks the
+// connection, terminates client-side TLS using a leaf minted on
+// demand by a ca.Provider, and forwards each tunnelled HTTP/1.1
+// request to the originally-requested upstream over a fresh TLS
+// connection with strict verification against the system trust
+// store.
+//
+// - Absolute-form forward-proxy requests (e.g. POST http://host/path
+// HTTP/1.1, RFC 7230 §5.3.2) — for plain-HTTP upstreams. The proxy
+// authenticates the request inline (no hijack), forwards the body
+// to the upstream over plain HTTP, and applies the same credential
+// injection, host policy, and request logging as the CONNECT path.
+//
+// The listener itself is TLS-wrapped so that the CONNECT handshake and
+// the absolute-form request line (both of which carry session tokens
+// in Proxy-Authorization) are encrypted. Clients use HTTPS_PROXY and
+// HTTP_PROXY pointing at https://... and trust the same CA that signs
// the per-host MITM leaves.
//
-// v1 scope: HTTP/1.1 only (ALPN pinned).
+// v1 scope: HTTP/1.1 only (ALPN pinned). HTTPS upstreams must use
+// CONNECT — the forward-proxy path rejects https:// URLs to avoid
+// silently TLS-stripping.
package mitm
import (
"context"
"crypto/tls"
- "fmt"
"log/slog"
"net"
"net/http"
@@ -168,5 +180,13 @@ func (p *Proxy) dispatch(w http.ResponseWriter, r *http.Request) {
p.handleConnect(w, r)
return
}
- http.Error(w, fmt.Sprintf("method %s not supported on transparent proxy", r.Method), http.StatusMethodNotAllowed)
+ if isAbsoluteForwardProxyRequest(r) {
+ p.handleForward(w, r)
+ return
+ }
+ // Origin-form (no scheme/host), https://, ws://, file://, gopher://,
+ // etc. all land here. The CONNECT-vs-forward split above already
+ // covers every legitimate forward-proxy shape; anything else is a
+ // malformed request, not a method-not-allowed.
+ http.Error(w, "this endpoint is an HTTP forward proxy; non-CONNECT requests must use absolute-form URLs (http://host/path). Use CONNECT for https:// upstreams.", http.StatusBadRequest)
}
diff --git a/internal/mitm/proxy_test.go b/internal/mitm/proxy_test.go
index f1124f0..9203162 100644
--- a/internal/mitm/proxy_test.go
+++ b/internal/mitm/proxy_test.go
@@ -80,8 +80,11 @@ func (f *fakeCredProvider) Inject(_ context.Context, _, targetHost string) (*bro
// setupProxy starts a mitm.Proxy backed by a freshly-generated SoftCA and
// the given session + credential stubs. Returns the listening URL, the
-// root-cert pool for client-side trust, and the proxy instance.
-func setupProxy(t *testing.T, sr brokercore.SessionResolver, cp brokercore.CredentialProvider) (proxyURL *url.URL, clientRoots *x509.CertPool, p *Proxy) {
+// root-cert pool for client-side trust, and the proxy instance. Tests
+// that need to customise Options (e.g. inject a LogSink) pass option
+// mutators that run before New() so the field is set before the
+// serving goroutine starts and is therefore race-free.
+func setupProxy(t *testing.T, sr brokercore.SessionResolver, cp brokercore.CredentialProvider, optMutators ...func(*Options)) (proxyURL *url.URL, clientRoots *x509.CertPool, p *Proxy) {
t.Helper()
t.Setenv("AGENT_VAULT_ALLOW_PRIVATE_RANGES", "true")
@@ -100,13 +103,17 @@ func setupProxy(t *testing.T, sr brokercore.SessionResolver, cp brokercore.Crede
t.Fatal("failed to load CA root PEM into pool")
}
- p = New("127.0.0.1:0", Options{
+ opts := Options{
CA: caProv,
Sessions: sr,
Credentials: cp,
BaseURL: "http://127.0.0.1:14321",
Logger: slog.New(slog.DiscardHandler),
- })
+ }
+ for _, m := range optMutators {
+ m(&opts)
+ }
+ p = New("127.0.0.1:0", opts)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
@@ -1330,7 +1337,12 @@ func TestMITMUpstreamCertUntrusted(t *testing.T) {
}
}
-func TestMITMRejectsNonConnectRequests(t *testing.T) {
+// Origin-form requests (GET /path) sent directly to the proxy listener
+// — i.e. requests that try to use the proxy as if it were an origin
+// server — must be rejected. The dispatch only routes CONNECT and
+// absolute-form forward-proxy shapes; everything else is malformed for
+// this ingress and returns 400.
+func TestMITMRejectsOriginFormRequests(t *testing.T) {
proxyURL, clientRoots, _ := setupProxy(t, errResolver(brokercore.ErrInvalidSession), &fakeCredProvider{})
client := &http.Client{
@@ -1341,8 +1353,8 @@ func TestMITMRejectsNonConnectRequests(t *testing.T) {
t.Fatalf("Get: %v", err)
}
defer resp.Body.Close()
- if resp.StatusCode != http.StatusMethodNotAllowed {
- t.Fatalf("status = %d, want 405", resp.StatusCode)
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
diff --git a/internal/mitm/websocket.go b/internal/mitm/websocket.go
index 40a7681..48a95e2 100644
--- a/internal/mitm/websocket.go
+++ b/internal/mitm/websocket.go
@@ -131,6 +131,26 @@ func (p *Proxy) dialWebSocketUpstream(
return nil, nil, nil, err
}
+ // Plain-HTTP upstream (ws://): skip TLS, deadlines apply directly to
+ // rawConn. pipeWebSocket/copyWithIdleTimeout downstream use only
+ // net.Conn methods, so a *net.TCPConn substitutes for *tls.Conn.
+ if outReq.URL.Scheme == "http" {
+ headerTimeout := p.responseHeaderTimeout()
+ _ = rawConn.SetDeadline(time.Now().Add(headerTimeout))
+ if err := outReq.Write(rawConn); err != nil {
+ _ = rawConn.Close()
+ return nil, nil, nil, err
+ }
+ reader := bufio.NewReader(rawConn)
+ resp, err := http.ReadResponse(reader, outReq)
+ if err != nil {
+ _ = rawConn.Close()
+ return nil, nil, nil, err
+ }
+ _ = rawConn.SetDeadline(time.Time{})
+ return rawConn, reader, resp, nil
+ }
+
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12}
if p.upstream.TLSClientConfig != nil {
tlsConfig = p.upstream.TLSClientConfig.Clone()
diff --git a/sdks/sdk-typescript/src/resources/sessions.ts b/sdks/sdk-typescript/src/resources/sessions.ts
index 91902e6..b851b46 100644
--- a/sdks/sdk-typescript/src/resources/sessions.ts
+++ b/sdks/sdk-typescript/src/resources/sessions.ts
@@ -12,14 +12,16 @@ export interface CreateSessionOptions {
}
/**
- * Container configuration for routing a sandboxed agent's HTTPS traffic
- * through Agent Vault's transparent MITM proxy.
+ * Container configuration for routing a sandboxed agent's HTTP and
+ * HTTPS traffic through Agent Vault's transparent MITM proxy.
*/
export interface ContainerConfig {
/** Environment variables to inject into the container. */
env: {
/** MITM proxy URL with embedded credentials. */
HTTPS_PROXY: string;
+ /** Same TLS-wrapped MITM proxy URL — used for plain http:// upstreams via absolute-form forward-proxy requests. */
+ HTTP_PROXY: string;
/** Hosts to bypass the proxy. */
NO_PROXY: string;
};
@@ -64,6 +66,7 @@ export function buildProxyEnv(
// Proxy and CA trust variables must stay in sync with augmentEnvWithMITM() in cmd/run.go.
return {
HTTPS_PROXY: config.env.HTTPS_PROXY,
+ HTTP_PROXY: config.env.HTTP_PROXY,
NO_PROXY: config.env.NO_PROXY,
NODE_USE_ENV_PROXY: "1",
SSL_CERT_FILE: certPath,
@@ -124,6 +127,7 @@ export class SessionsResource {
containerConfig = {
env: {
HTTPS_PROXY: proxyUrl,
+ HTTP_PROXY: proxyUrl,
NO_PROXY: "localhost,127.0.0.1",
},
caCertificate: mitmInfo.caCertificate,
diff --git a/sdks/sdk-typescript/tests/sessions.test.ts b/sdks/sdk-typescript/tests/sessions.test.ts
index 06fe867..6c0ae11 100644
--- a/sdks/sdk-typescript/tests/sessions.test.ts
+++ b/sdks/sdk-typescript/tests/sessions.test.ts
@@ -108,6 +108,7 @@ describe("SessionsResource", () => {
expect(session.containerConfig!.env.HTTPS_PROXY).toContain("scoped-token-123");
expect(session.containerConfig!.env.HTTPS_PROXY).toContain("test");
expect(session.containerConfig!.env.HTTPS_PROXY).toContain("14322");
+ expect(session.containerConfig!.env.HTTP_PROXY).toBe(session.containerConfig!.env.HTTPS_PROXY);
expect(session.containerConfig!.env.NO_PROXY).toBe("localhost,127.0.0.1");
expect(session.containerConfig!.caCertificate).toBe(FAKE_PEM);
});
@@ -226,6 +227,7 @@ describe("buildProxyEnv()", () => {
const config: ContainerConfig = {
env: {
HTTPS_PROXY: "https://tok:vault@127.0.0.1:14322",
+ HTTP_PROXY: "https://tok:vault@127.0.0.1:14322",
NO_PROXY: "localhost,127.0.0.1",
},
caCertificate: FAKE_PEM,
@@ -234,6 +236,7 @@ describe("buildProxyEnv()", () => {
const env = buildProxyEnv(config, "/etc/ssl/agent-vault-ca.pem");
expect(env.HTTPS_PROXY).toBe("https://tok:vault@127.0.0.1:14322");
+ expect(env.HTTP_PROXY).toBe("https://tok:vault@127.0.0.1:14322");
expect(env.NO_PROXY).toBe("localhost,127.0.0.1");
expect(env.NODE_USE_ENV_PROXY).toBe("1");
expect(env.SSL_CERT_FILE).toBe("/etc/ssl/agent-vault-ca.pem");