From 6bde1ee14e713ad4e4e44fed696963e350dbc206 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 5 May 2026 20:04:02 -0700 Subject: [PATCH 1/3] mitm: address remaining review on #151 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings from the post-merge review pass: 1. cmd/run.go:149 hardcoded 127.0.0.1 in the "routing HTTP/HTTPS through MITM proxy" banner while augmentEnvWithMITM derives the actual host from the parsed --address. With a remote vault server the banner misled operators debugging proxy connectivity. Extracted resolveMITMHost(addr) so both the env-var path and the banner pull from the same source. 2. internal/mitm/forward_test.go:601 was named TestMITMForwardIPv6LiteralCanonicalises but the request line carries an explicit port — net.SplitHostPort succeeds in the old code, the buggy double-bracket fallback is never taken, and what the assertion actually verifies is the Host-header port-preservation fix. Renamed to TestMITMForwardIPv6PreservesHostHeader and rewrote the comment to match. End-to-end coverage of the no-port canonicalisation branch would need to bind port 80 on ::1, which is impractical for CI. 3. Only persistent_instructions_admin.txt is //go:embed-ed in handle_agents.go; both invite-redeem paths hardcode persistentInstructionsAdmin for every redeeming agent regardless of role. The other four files (persistent_instructions_member.txt, persistent_instructions_proxy.txt, persistent_agent_instructions.txt, instructions.txt) shipped on disk but never reached any agent. Doc cross-syncs kept repainting them. Deleted. --- cmd/run.go | 25 ++++--- internal/mitm/forward_test.go | 16 +++-- internal/server/instructions.txt | 64 ------------------ .../server/persistent_agent_instructions.txt | 66 ------------------- .../server/persistent_instructions_member.txt | 36 ---------- .../server/persistent_instructions_proxy.txt | 27 -------- 6 files changed, 25 insertions(+), 209 deletions(-) delete mode 100644 internal/server/instructions.txt delete mode 100644 internal/server/persistent_agent_instructions.txt delete mode 100644 internal/server/persistent_instructions_member.txt delete mode 100644 internal/server/persistent_instructions_proxy.txt diff --git a/cmd/run.go b/cmd/run.go index 21cd556..f8e2eba 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -146,7 +146,7 @@ func runCmdRunE(cmd *cobra.Command, args []string) error { return err } env = newEnv - fmt.Fprintf(os.Stderr, "%s routing HTTP/HTTPS through MITM proxy (127.0.0.1:%d)\n", successText("agent-vault:"), mitmPort) + fmt.Fprintf(os.Stderr, "%s routing HTTP/HTTPS through MITM proxy (%s:%d)\n", successText("agent-vault:"), resolveMITMHost(addr), mitmPort) // 7. If the target command is a supported agent, offer to install the // Agent Vault skill (only when not already present). @@ -369,6 +369,20 @@ func stripEnvKeys(env []string, keys map[string]struct{}) []string { return out } +// resolveMITMHost extracts the host the child process should dial for +// the MITM proxy from the configured server address. Loopback default +// matches the historical behaviour for users who never set --address. +// Single source of truth for both the env-var path (BuildProxyEnv) and +// the operator-facing banner. +func resolveMITMHost(addr string) string { + if u, err := url.Parse(addr); err == nil { + if h := u.Hostname(); h != "" { + return h + } + } + return "127.0.0.1" +} + // requireMITMEnv calls augmentEnvWithMITM and converts both transport // failures and a server-side --mitm-port 0 into actionable errors. // MITM is the only ingress, so neither case is recoverable for vault run. @@ -424,16 +438,9 @@ func augmentEnvWithMITM(env []string, addr, token, vault, caPath string) ([]stri return env, 0, false, fmt.Errorf("write CA: %w", err) } - mitmHost := "127.0.0.1" - if u, err := url.Parse(addr); err == nil { - if h := u.Hostname(); h != "" { - mitmHost = h - } - } - env = stripEnvKeys(env, mitmInjectedKeys) env = append(env, isolation.BuildProxyEnv(isolation.ProxyEnvParams{ - Host: mitmHost, + Host: resolveMITMHost(addr), Port: port, Token: token, Vault: vault, diff --git a/internal/mitm/forward_test.go b/internal/mitm/forward_test.go index 46b7337..089c0fc 100644 --- a/internal/mitm/forward_test.go +++ b/internal/mitm/forward_test.go @@ -591,14 +591,16 @@ func TestMITMForwardKeepalivePersistsAcrossRequests(t *testing.T) { } } -// TestMITMForwardIPv6LiteralCanonicalises guards against the IPv6 -// double-bracket regression: net.SplitHostPort/JoinHostPort on the -// bracketed-no-port form ("[::1]") produces "[[::1]]:80". Going -// through r.URL.Hostname() / r.URL.Port() instead yields the -// canonical "[::1]:80" target. Sending raw via dialProxyTLS because -// Go's http.Client routinely rewrites URLs through ProxyURL in ways +// TestMITMForwardIPv6PreservesHostHeader locks in the Host-header +// port-preservation fix on the IPv6 forward-proxy path: outReq.Host +// must equal target ("[::1]:port"), not the port-stripped form ("::1") +// the old code emitted. The request line carries an explicit port so +// the no-port canonicalisation branch (URL.Hostname/Port instead of +// net.SplitHostPort) is not driven here — exercising that end-to-end +// would require binding port 80 on ::1. Sending raw via dialProxyTLS +// because Go's http.Client rewrites URLs through ProxyURL in ways // that would obscure what we want to assert. -func TestMITMForwardIPv6LiteralCanonicalises(t *testing.T) { +func TestMITMForwardIPv6PreservesHostHeader(t *testing.T) { // Bind an upstream on ::1 so the URL we send is exactly the // IPv6-literal-no-port form. SkipNow if the host has no IPv6 // loopback (CI sometimes). diff --git a/internal/server/instructions.txt b/internal/server/instructions.txt deleted file mode 100644 index 9466fff..0000000 --- a/internal/server/instructions.txt +++ /dev/null @@ -1,64 +0,0 @@ -You have access to Agent Vault, a transparent HTTP/HTTPS proxy that attaches credentials to your outbound requests. You never see or handle credentials directly. - -## Quick Start - -1. Store env vars from the redemption response: AGENT_VAULT_ADDR (server URL), AGENT_VAULT_SESSION_TOKEN (bearer token), AGENT_VAULT_VAULT (vault name). -2. Configure your HTTP client to route both HTTP and HTTPS through Agent Vault: fetch the root CA from {AGENT_VAULT_ADDR}/v1/mitm/ca.pem (the response carries the MITM port in the X-MITM-Port header, default 14322) and set HTTPS_PROXY=https://{AGENT_VAULT_SESSION_TOKEN}:{AGENT_VAULT_VAULT}@{host}:{port} (and HTTP_PROXY to the same value) where {host} is the host portion of {AGENT_VAULT_ADDR}. The same TLS-wrapped listener handles CONNECT for https:// upstreams and absolute-form forward-proxy requests for http:// upstreams. -3. Call GET {AGENT_VAULT_ADDR}/discover with Authorization: Bearer {AGENT_VAULT_SESSION_TOKEN} to learn which hosts have credentials configured. -4. For any host listed in /discover, just call the real upstream URL (over https:// or http://) — Agent Vault intercepts the request, injects credentials, and forwards. For all others, call directly (the proxy passes them through unmodified). - -Example: GET https://api.stripe.com/v1/charges?limit=10 - -## Proposals — Requesting Access to New Services - -When you need a credential or proxy access to a host not in /discover, raise a proposal. A 403 response includes a proposal_hint with the denied host. - -IMPORTANT: Before raising a proposal, you MUST look up how the target service authenticates API requests. Check the service's API docs to determine the correct auth type. Do not guess — wrong auth wastes the operator's time and will fail at the proxy. - -### Auth Types - - bearer — Authorization: Bearer {"auth": {"type": "bearer", "token": "SECRET_KEY"}} - basic — HTTP Basic (user, optional password) {"auth": {"type": "basic", "username": "API_KEY"}} - api-key — key in a named header, optional prefix {"auth": {"type": "api-key", "key": "SECRET", "header": "x-api-key"}} - custom — freeform header templates {"auth": {"type": "custom", "headers": {"X-Key": "{{ SECRET }}"}}} - -Common services: Stripe (bearer), GitHub (bearer), OpenAI (bearer), Ashby (basic — API key as username), Jira (basic — email + token), Anthropic (api-key, header: x-api-key). If unlisted, check the API docs. - -### Creating a Proposal - - POST {AGENT_VAULT_ADDR}/v1/proposals - Authorization: Bearer {AGENT_VAULT_SESSION_TOKEN} - Content-Type: application/json - - { - "services": [{"action": "set", "host": "api.stripe.com", "description": "Stripe API", "auth": {"type": "bearer", "token": "STRIPE_KEY"}}], - "credentials": [{"action": "set", "key": "STRIPE_KEY", "description": "Stripe credential key", "obtain": "https://dashboard.stripe.com/apikeys", "obtain_instructions": "Developers → API Keys → Reveal test key"}], - "message": "Need Stripe API key for billing feature", - "user_message": "I need access to your Stripe account to build the checkout page." - } - -Key fields: -- services[].action: "set" (upsert, needs host + auth) or "delete" (needs host only) -- credentials[].action: "set" (omit value for human to supply; include value to store back) or "delete" -- credentials: only declare credentials not already in available_credentials from /discover -- message: developer-facing explanation; user_message: shown on approval page -- credentials[].obtain: URL where the human can get the credential; obtain_instructions: steps to find it - -Response includes approval_url. After creating: -1. Present approval_url to the user conversationally -2. Immediately start polling GET {AGENT_VAULT_ADDR}/v1/proposals/{id} — do NOT wait for the user to say "go on" or confirm. Poll every 3s for the first 30s, then every 10s. Stop after 10 minutes (proposal may have expired). -3. Once status is "applied", automatically retry your original request and continue your task - -## Error Handling - -- 401: Invalid/expired token — check AGENT_VAULT_SESSION_TOKEN -- 403: Host not allowed — raise a proposal -- 429: Too many pending proposals — wait for review -- 502: Missing credential or upstream unreachable — tell user - -## Rules - -- Never attempt to extract, log, or display credentials -- Never hardcode tokens — always use AGENT_VAULT_SESSION_TOKEN -- Only proxy hosts returned by /discover — if not listed, raise a proposal -- If you receive a credential_not_found error, inform the user which credential is missing diff --git a/internal/server/persistent_agent_instructions.txt b/internal/server/persistent_agent_instructions.txt deleted file mode 100644 index a362664..0000000 --- a/internal/server/persistent_agent_instructions.txt +++ /dev/null @@ -1,66 +0,0 @@ -You are a persistent agent registered with Agent Vault, an HTTP proxy that attaches credentials to your outbound requests. You never see or handle credentials directly. - -## Token - -You received an agent token (av_agent_token). Use it for all proxy/discover/proposal calls. If your token has no expiry, it works indefinitely. If it does expire, contact your operator for a new token. - -## Discovery & Proxy - - GET {AGENT_VAULT_ADDR}/discover — list brokerable services + available_credentials - -Authorization: Bearer {av_agent_token}, plus X-Vault: {vault_name} on every vault-scoped request. - -Configure your HTTP client to route both HTTP and HTTPS through Agent Vault: fetch the root CA from {AGENT_VAULT_ADDR}/v1/mitm/ca.pem (the response's X-MITM-Port header gives you the proxy port, default 14322) and set HTTPS_PROXY=https://{av_agent_token}:{vault_name}@{host}:{port} (and HTTP_PROXY to the same value) where {host} is the host portion of {AGENT_VAULT_ADDR}. The same TLS-wrapped listener handles CONNECT for https:// upstreams and absolute-form forward-proxy requests for http:// upstreams. Then call the real upstream URL (over https:// or http://) — Agent Vault attaches real credentials and forwards. Hosts not in /discover pass through unmodified. - -## Proposals — Requesting Access to New Services - -When you need a credential or proxy access to a host not in /discover, raise a proposal. A 403 response includes a proposal_hint with the denied host. - -IMPORTANT: Before raising a proposal, you MUST look up how the target service authenticates API requests. Check the service's API docs to determine the correct auth type. Do not guess — wrong auth wastes the operator's time and will fail at the proxy. - -### Auth Types - - bearer — Authorization: Bearer {"auth": {"type": "bearer", "token": "SECRET_KEY"}} - basic — HTTP Basic (user, optional password) {"auth": {"type": "basic", "username": "API_KEY"}} - api-key — key in a named header, optional prefix {"auth": {"type": "api-key", "key": "SECRET", "header": "x-api-key"}} - custom — freeform header templates {"auth": {"type": "custom", "headers": {"X-Key": "{{ SECRET }}"}}} - -Common services: Stripe (bearer), GitHub (bearer), OpenAI (bearer), Ashby (basic — API key as username), Jira (basic — email + token), Anthropic (api-key, header: x-api-key). If unlisted, check the API docs. - -### Creating a Proposal - - POST {AGENT_VAULT_ADDR}/v1/proposals - Authorization: Bearer {av_agent_token} - Content-Type: application/json - - { - "services": [{"action": "set", "host": "api.stripe.com", "description": "Stripe API", "auth": {"type": "bearer", "token": "STRIPE_KEY"}}], - "credentials": [{"action": "set", "key": "STRIPE_KEY", "description": "Stripe API key", "obtain": "https://dashboard.stripe.com/apikeys", "obtain_instructions": "Developers → API Keys → Reveal test key"}], - "message": "Need Stripe API key for billing feature", - "user_message": "I need access to your Stripe account to build the checkout page." - } - -Key fields: -- services[].action: "set" (upsert, needs host + auth) or "delete" (needs host only) -- credentials[].action: "set" (omit value for human to supply; include value to store back) or "delete" -- credentials: only declare credentials not already in available_credentials from /discover -- message: developer-facing explanation; user_message: shown on approval page -- credentials[].obtain: URL where the human can get the credential; obtain_instructions: steps to find it - -Response includes approval_url. After creating: -1. Present approval_url to the user conversationally -2. Immediately start polling GET {AGENT_VAULT_ADDR}/v1/proposals/{id} — do NOT wait for the user to say "go on" or confirm. Poll every 3s for the first 30s, then every 10s. Stop after 10 minutes (proposal may have expired). -3. Once status is "applied", automatically retry your original request and continue your task - -## Error Handling - -- 401: Invalid/expired token — contact your operator for a new token -- 403: Host not allowed — raise a proposal -- 429: Too many pending proposals — wait for review -- 502: Missing credential or upstream unreachable — tell user - -## Rules - -- Never attempt to extract, log, or expose credentials -- Only proxy hosts returned by /discover — if not listed, raise a proposal -- If you receive a credential_not_found error, inform the user which credential is missing diff --git a/internal/server/persistent_instructions_member.txt b/internal/server/persistent_instructions_member.txt deleted file mode 100644 index 295e0c8..0000000 --- a/internal/server/persistent_instructions_member.txt +++ /dev/null @@ -1,36 +0,0 @@ -You are a persistent agent registered with Agent Vault, an HTTP proxy that attaches credentials to your outbound requests. You never see or handle credentials directly. - -IMPORTANT: Whenever your task involves ANY external service or API (Linear, GitHub, Stripe, Jira, Slack, Asana, GitLab, or any other authenticated service), you MUST check Agent Vault BEFORE telling the user you lack access. Call /discover to see what's available, then route requests through the proxy. If the service isn't listed, raise a proposal — never just say "I can't access that." - -Your vault role is **member**. You can proxy requests, discover services, raise proposals, set/delete credentials directly, approve/reject proposals, manage vault services, and invite proxy-level agents. - -## Token - -You received an agent token (av_agent_token). Use it for all proxy/discover/proposal calls. If your token has no expiry, it works indefinitely. If it does expire, contact your operator for a new token. - -## Quick Start - -1. Call GET {AGENT_VAULT_ADDR}/discover with Authorization: Bearer {av_agent_token} — do this FIRST. -2. Configure your HTTP client to route both HTTP and HTTPS through Agent Vault: fetch the root CA from {AGENT_VAULT_ADDR}/v1/mitm/ca.pem (X-MITM-Port response header gives the port, default 14322) and set HTTPS_PROXY=https://{av_agent_token}:{vault_name}@{host}:{port} (and HTTP_PROXY to the same value) where {host} is the host portion of {AGENT_VAULT_ADDR}. The same TLS-wrapped listener handles CONNECT for https:// upstreams and absolute-form forward-proxy requests for http:// upstreams. -3. For hosts in the discover response, just call the real upstream URL (over https:// or http://) — Agent Vault injects credentials. For others, requests pass through unmodified. -4. If a needed service isn't listed, raise a proposal. - -## Member Capabilities - -As a member, you can also: -- Set credentials: POST {AGENT_VAULT_ADDR}/v1/credentials with {"vault": "...", "credentials": {"KEY": "value"}} -- Delete credentials: DELETE {AGENT_VAULT_ADDR}/v1/credentials with {"vault": "...", "keys": ["KEY"]} -- Approve proposals: POST {AGENT_VAULT_ADDR}/v1/admin/proposals/{id}/approve with {"vault": "...", "credentials": {"KEY": "value"}} -- Reject proposals: POST {AGENT_VAULT_ADDR}/v1/admin/proposals/{id}/reject with {"vault": "...", "reason": "..."} -- Invite proxy agents: POST {AGENT_VAULT_ADDR}/v1/invites with {"vault": "...", "vault_role": "proxy"} - -## Full Reference - -For the complete Agent Vault reference including auth types, proposal format, error handling, and behavioral rules, fetch: - - GET {AGENT_VAULT_ADDR}/v1/skills/http - -## Rules - -- Never attempt to extract, log, or expose credentials -- Only proxy hosts returned by /discover — if not listed, raise a proposal diff --git a/internal/server/persistent_instructions_proxy.txt b/internal/server/persistent_instructions_proxy.txt deleted file mode 100644 index 2f8c2f8..0000000 --- a/internal/server/persistent_instructions_proxy.txt +++ /dev/null @@ -1,27 +0,0 @@ -You are a persistent agent registered with Agent Vault, an HTTP proxy that attaches credentials to your outbound requests. You never see or handle credentials directly. - -IMPORTANT: Whenever your task involves ANY external service or API (Linear, GitHub, Stripe, Jira, Slack, Asana, GitLab, or any other authenticated service), you MUST check Agent Vault BEFORE telling the user you lack access. Call /discover to see what's available, then route requests through the proxy. If the service isn't listed, raise a proposal — never just say "I can't access that." - -Your vault role is **proxy**. You can proxy requests, discover services, and raise proposals. You cannot set/delete credentials directly or approve/reject proposals — a member or admin must do that. - -## Token - -You received an agent token (av_agent_token). Use it for all proxy/discover/proposal calls. If your token has no expiry, it works indefinitely. If it does expire, contact your operator for a new token. - -## Quick Start - -1. Call GET {AGENT_VAULT_ADDR}/discover with Authorization: Bearer {av_agent_token} — do this FIRST. -2. Configure your HTTP client to route both HTTP and HTTPS through Agent Vault: fetch the root CA from {AGENT_VAULT_ADDR}/v1/mitm/ca.pem (X-MITM-Port response header gives the port, default 14322) and set HTTPS_PROXY=https://{av_agent_token}:{vault_name}@{host}:{port} (and HTTP_PROXY to the same value) where {host} is the host portion of {AGENT_VAULT_ADDR}. The same TLS-wrapped listener handles CONNECT for https:// upstreams and absolute-form forward-proxy requests for http:// upstreams. -3. For hosts in the discover response, just call the real upstream URL (over https:// or http://) — Agent Vault injects credentials. For others, requests pass through unmodified. -4. If a needed service isn't listed, raise a proposal. - -## Full Reference - -For the complete Agent Vault reference including auth types, proposal format, error handling, and behavioral rules, fetch: - - GET {AGENT_VAULT_ADDR}/v1/skills/http - -## Rules - -- Never attempt to extract, log, or expose credentials -- Only proxy hosts returned by /discover — if not listed, raise a proposal From f4c7d6b022a2310a8a6708320b6afe55b5301684 Mon Sep 17 00:00:00 2001 From: Tuan Dang Date: Tue, 5 May 2026 20:06:44 -0700 Subject: [PATCH 2/3] mitm: tighten resolveMITMHost doc comment /simplify finding: the comment narrated the change ("Single source of truth for both the env-var path and the operator-facing banner") rather than describing the function contract. Trimmed to purpose + fallback behaviour, dropped the call-site enumeration that would go stale on the next caller. --- cmd/run.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/run.go b/cmd/run.go index f8e2eba..e4c89bb 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -370,10 +370,8 @@ func stripEnvKeys(env []string, keys map[string]struct{}) []string { } // resolveMITMHost extracts the host the child process should dial for -// the MITM proxy from the configured server address. Loopback default -// matches the historical behaviour for users who never set --address. -// Single source of truth for both the env-var path (BuildProxyEnv) and -// the operator-facing banner. +// the MITM proxy from the configured server address. Falls back to +// loopback when addr is unparseable or has no host. func resolveMITMHost(addr string) string { if u, err := url.Parse(addr); err == nil { if h := u.Hostname(); h != "" { From bab1f4601c3c78beba6c0512c6641f638bcaa35c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 03:29:23 +0000 Subject: [PATCH 3/3] mitm: bracket IPv6 hosts in proxy URL authority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review on #152: - BuildProxyEnv composed HTTPS_PROXY/HTTP_PROXY via fmt.Sprintf("%s:%d", host, port). For IPv6 --address (e.g. http://[::1]:14321), resolveMITMHost returns the bare "::1" (url.Hostname strips brackets), so the proxy URL came out as "https://tok:v@::1:14322" — net.SplitHostPort rejects the authority as "too many colons", failing every outbound request from the child agent. Switch to net.JoinHostPort for both env.go:49 and the cosmetic banner at run.go:149. - forward_test.go inline comment for TestMITMForwardIPv6PreservesHostHeader still claimed the URL was the no-port form; it actually carries an explicit ephemeral port. Realigned with the new outer docstring. https://claude.ai/code/session_011CrrNcAGDQvCLgvj79Cui2 --- cmd/run.go | 4 +++- internal/isolation/env.go | 4 +++- internal/isolation/env_test.go | 28 ++++++++++++++++++++++++++++ internal/mitm/forward_test.go | 6 +++--- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/cmd/run.go b/cmd/run.go index e4c89bb..eec4c71 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -6,11 +6,13 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "net/url" "os" "os/exec" "path/filepath" + "strconv" "strings" "syscall" @@ -146,7 +148,7 @@ func runCmdRunE(cmd *cobra.Command, args []string) error { return err } env = newEnv - fmt.Fprintf(os.Stderr, "%s routing HTTP/HTTPS through MITM proxy (%s:%d)\n", successText("agent-vault:"), resolveMITMHost(addr), mitmPort) + fmt.Fprintf(os.Stderr, "%s routing HTTP/HTTPS through MITM proxy (%s)\n", successText("agent-vault:"), net.JoinHostPort(resolveMITMHost(addr), strconv.Itoa(mitmPort))) // 7. If the target command is a supported agent, offer to install the // Agent Vault skill (only when not already present). diff --git a/internal/isolation/env.go b/internal/isolation/env.go index daf19dc..23d957b 100644 --- a/internal/isolation/env.go +++ b/internal/isolation/env.go @@ -4,7 +4,9 @@ package isolation import ( "fmt" + "net" "net/url" + "strconv" ) const ( @@ -46,7 +48,7 @@ func BuildProxyEnv(p ProxyEnvParams) []string { proxyURL := (&url.URL{ Scheme: scheme, User: url.UserPassword(p.Token, p.Vault), - Host: fmt.Sprintf("%s:%d", p.Host, p.Port), + Host: net.JoinHostPort(p.Host, strconv.Itoa(p.Port)), }).String() return []string{ "HTTPS_PROXY=" + proxyURL, diff --git a/internal/isolation/env_test.go b/internal/isolation/env_test.go index f2aa692..781b4f2 100644 --- a/internal/isolation/env_test.go +++ b/internal/isolation/env_test.go @@ -95,6 +95,34 @@ func TestBuildContainerEnv_FirewallPortsEmitted(t *testing.T) { } } +// IPv6 literals must be bracketed in the proxy URL authority so +// net.SplitHostPort and downstream HTTP clients accept them. The bare +// "::1:port" form is rejected as "too many colons". +func TestBuildProxyEnv_IPv6HostIsBracketed(t *testing.T) { + env := BuildProxyEnv(ProxyEnvParams{ + Host: "::1", + Port: 14322, + Token: "tok", + Vault: "v", + CAPath: "/tmp/ca.pem", + MITMTLS: true, + }) + vars := envMap(env) + u, err := url.Parse(vars["HTTPS_PROXY"]) + if err != nil { + t.Fatalf("parse HTTPS_PROXY %q: %v", vars["HTTPS_PROXY"], err) + } + if u.Hostname() != "::1" { + t.Errorf("hostname = %q, want ::1", u.Hostname()) + } + if u.Port() != "14322" { + t.Errorf("port = %q, want 14322", u.Port()) + } + if !strings.Contains(vars["HTTPS_PROXY"], "[::1]:14322") { + t.Errorf("HTTPS_PROXY = %q, want bracketed [::1]:14322 authority", vars["HTTPS_PROXY"]) + } +} + // 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. diff --git a/internal/mitm/forward_test.go b/internal/mitm/forward_test.go index 089c0fc..97bc385 100644 --- a/internal/mitm/forward_test.go +++ b/internal/mitm/forward_test.go @@ -601,9 +601,9 @@ func TestMITMForwardKeepalivePersistsAcrossRequests(t *testing.T) { // because Go's http.Client rewrites URLs through ProxyURL in ways // that would obscure what we want to assert. func TestMITMForwardIPv6PreservesHostHeader(t *testing.T) { - // Bind an upstream on ::1 so the URL we send is exactly the - // IPv6-literal-no-port form. SkipNow if the host has no IPv6 - // loopback (CI sometimes). + // Bind an upstream on an ephemeral ::1 port so we can send an + // IPv6-literal-with-port URL through the forward proxy. SkipNow if + // the host has no IPv6 loopback (CI sometimes). l, err := net.Listen("tcp", "[::1]:0") if err != nil { t.Skipf("no IPv6 loopback available: %v", err)