From 8a9ea27899c863d8d58bd86b04f7b48b1edf0ae8 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:31:00 +0530 Subject: [PATCH 01/26] feat: secrets agent-proxy start and connect commands MITM agent proxy that brokers credentials on the wire (header rewriting and credential substitution), plus the connect wrapper that fetches the root CA, placeholders, and secrets and forks the agent process. --- packages/agentproxy/ca.go | 149 ++++++++++++ packages/agentproxy/cache.go | 204 +++++++++++++++++ packages/agentproxy/match.go | 99 ++++++++ packages/agentproxy/match_test.go | 74 ++++++ packages/agentproxy/proxy.go | 251 ++++++++++++++++++++ packages/agentproxy/rewrite.go | 127 +++++++++++ packages/agentproxy/rewrite_test.go | 112 +++++++++ packages/api/agent_proxy.go | 117 ++++++++++ packages/cmd/agent_proxy.go | 340 ++++++++++++++++++++++++++++ packages/cmd/agent_proxy_start.go | 52 +++++ 10 files changed, 1525 insertions(+) create mode 100644 packages/agentproxy/ca.go create mode 100644 packages/agentproxy/cache.go create mode 100644 packages/agentproxy/match.go create mode 100644 packages/agentproxy/match_test.go create mode 100644 packages/agentproxy/proxy.go create mode 100644 packages/agentproxy/rewrite.go create mode 100644 packages/agentproxy/rewrite_test.go create mode 100644 packages/api/agent_proxy.go create mode 100644 packages/cmd/agent_proxy.go create mode 100644 packages/cmd/agent_proxy_start.go diff --git a/packages/agentproxy/ca.go b/packages/agentproxy/ca.go new file mode 100644 index 00000000..4077f9ac --- /dev/null +++ b/packages/agentproxy/ca.go @@ -0,0 +1,149 @@ +package agentproxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "sync" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/go-resty/resty/v2" +) + +// caManager holds the intermediate CA (signed by the org root CA in Infisical) and mints +// short-lived leaf certificates per upstream hostname, presenting the leaf+intermediate chain. +type caManager struct { + httpClient *resty.Client + + mu sync.Mutex + intermediateKey *ecdsa.PrivateKey + intermediateCert *x509.Certificate + intermediateExp time.Time + + leafMu sync.Mutex + leafCache map[string]*leafEntry +} + +type leafEntry struct { + cert tls.Certificate + expiration time.Time +} + +func newCaManager(httpClient *resty.Client) *caManager { + return &caManager{ + httpClient: httpClient, + leafCache: make(map[string]*leafEntry), + } +} + +// ensureIntermediate lazily generates an intermediate keypair and has Infisical sign it, +// re-signing when it is near expiry. +func (c *caManager) ensureIntermediate() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.intermediateCert != nil && time.Until(c.intermediateExp) > 12*time.Hour { + return nil + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return fmt.Errorf("failed to generate intermediate CA key: %w", err) + } + + pubDer, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return fmt.Errorf("failed to marshal intermediate public key: %w", err) + } + pubPem := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDer}) + + resp, err := api.CallSignAgentProxyIntermediateCa(c.httpClient, api.SignAgentProxyIntermediateCaRequest{ + PublicKey: string(pubPem), + }) + if err != nil { + return fmt.Errorf("failed to get intermediate CA signed: %w", err) + } + + block, _ := pem.Decode([]byte(resp.Certificate)) + if block == nil { + return fmt.Errorf("invalid intermediate CA certificate returned") + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return fmt.Errorf("failed to parse intermediate CA certificate: %w", err) + } + + c.intermediateKey = key + c.intermediateCert = cert + c.intermediateExp = cert.NotAfter + // new intermediate invalidates cached leaves (they chain to the old one) + c.leafMu.Lock() + c.leafCache = make(map[string]*leafEntry) + c.leafMu.Unlock() + + return nil +} + +// mintLeaf returns a TLS certificate for the hostname, signed by the intermediate CA, +// with the intermediate appended so the agent can build the chain to the trusted root. +func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { + if err := c.ensureIntermediate(); err != nil { + return tls.Certificate{}, err + } + + c.leafMu.Lock() + if entry, ok := c.leafCache[hostname]; ok && time.Until(entry.expiration) > time.Hour { + c.leafMu.Unlock() + return entry.cert, nil + } + c.leafMu.Unlock() + + c.mu.Lock() + interKey := c.intermediateKey + interCert := c.intermediateCert + c.mu.Unlock() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, err + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return tls.Certificate{}, err + } + + notAfter := time.Now().Add(24 * time.Hour) + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: hostname}, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{hostname}, + } + + leafDer, err := x509.CreateCertificate(rand.Reader, template, interCert, &key.PublicKey, interKey) + if err != nil { + return tls.Certificate{}, err + } + + cert := tls.Certificate{ + Certificate: [][]byte{leafDer, interCert.Raw}, + PrivateKey: key, + } + + c.leafMu.Lock() + c.leafCache[hostname] = &leafEntry{cert: cert, expiration: notAfter} + c.leafMu.Unlock() + + return cert, nil +} diff --git a/packages/agentproxy/cache.go b/packages/agentproxy/cache.go new file mode 100644 index 00000000..5527ce83 --- /dev/null +++ b/packages/agentproxy/cache.go @@ -0,0 +1,204 @@ +package agentproxy + +import ( + "fmt" + "strings" + "sync" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" +) + +const agentInactiveTTL = 10 * time.Minute + +type resolvedCredential struct { + role string + headerName string + headerPrefix string + headerPurpose string + placeholder string + surfaces []string + value string // real secret value resolved via the proxy's own MI +} + +type resolvedService struct { + name string + hostPatterns []hostPattern + isEnabled bool + credentials []resolvedCredential +} + +type agentScope struct { + projectID string + environment string + secretPath string +} + +type agentEntry struct { + jwt string + scope agentScope + services []*resolvedService + lastSeen time.Time + cachedAt time.Time +} + +// cacheKey scopes cached entries by both the agent JWT and the requested scope, so an agent +// connecting with a different environment/path does not reuse another scope's resolved credentials. +func cacheKey(jwt string, scope agentScope) string { + return strings.Join([]string{jwt, scope.projectID, scope.environment, scope.secretPath}, "\x00") +} + +// agentCache resolves and caches, per agent JWT, the proxied services the agent may use +// along with the real credential values (fetched with the proxy's own token). +type agentCache struct { + proxyToken string + + mu sync.Mutex + entries map[string]*agentEntry +} + +func newAgentCache(proxyToken string) *agentCache { + return &agentCache{ + proxyToken: proxyToken, + entries: make(map[string]*agentEntry), + } +} + +// get returns a snapshot of the resolved services for the agent JWT + scope, resolving on first use. +// The returned slice is read by the caller without the cache lock, so it is snapshotted here +// under the lock to avoid a data race with refreshActive reassigning entry.services. +func (a *agentCache) get(jwt string, scope agentScope) ([]*resolvedService, error) { + key := cacheKey(jwt, scope) + + a.mu.Lock() + entry := a.entries[key] + if entry != nil { + entry.lastSeen = time.Now() + snapshot := entry.services + a.mu.Unlock() + return snapshot, nil + } + a.mu.Unlock() + + resolved, err := a.resolve(jwt, scope) + if err != nil { + return nil, err + } + + entry = &agentEntry{ + jwt: jwt, + scope: scope, + services: resolved, + lastSeen: time.Now(), + cachedAt: time.Now(), + } + a.mu.Lock() + a.entries[key] = entry + a.mu.Unlock() + return resolved, nil +} + +// resolve discovers the agent's proxied services (using the agent JWT) and fills in the real +// secret values (using the proxy's own token). +func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService, error) { + agentClient := resty.New().SetAuthToken(jwt) + listResp, err := api.CallListProxiedServices(agentClient, api.ListProxiedServicesRequest{ + ProjectID: scope.projectID, + Environment: scope.environment, + SecretPath: scope.secretPath, + }) + if err != nil { + return nil, fmt.Errorf("failed to discover proxied services: %w", err) + } + + secretValues, err := a.fetchSecretValues(scope) + if err != nil { + return nil, fmt.Errorf("failed to fetch secret values: %w", err) + } + + var services []*resolvedService + for _, svc := range listResp.Services { + if !svc.CanProxy { + continue + } + rs := &resolvedService{ + name: svc.Name, + hostPatterns: parseHostPatterns(svc.HostPattern), + isEnabled: svc.IsEnabled, + } + for _, cred := range svc.Credentials { + value, ok := secretValues[cred.SecretKey] + if !ok { + // stale reference: secret renamed/deleted after the service was created + util.PrintWarning(fmt.Sprintf("proxied service %q references missing secret %q; skipping", svc.Name, cred.SecretKey)) + continue + } + rs.credentials = append(rs.credentials, resolvedCredential{ + role: cred.Role, + headerName: cred.HeaderName, + headerPrefix: cred.HeaderPrefix, + headerPurpose: cred.HeaderPurpose, + placeholder: cred.PlaceholderValue, + surfaces: cred.SubstitutionSurfaces, + value: value, + }) + } + services = append(services, rs) + } + return services, nil +} + +func (a *agentCache) fetchSecretValues(scope agentScope) (map[string]string, error) { + params := models.GetAllSecretsParameters{ + Environment: scope.environment, + WorkspaceId: scope.projectID, + SecretsPath: scope.secretPath, + UniversalAuthAccessToken: a.proxyToken, + } + secrets, err := util.GetAllEnvironmentVariables(params, "") + if err != nil { + return nil, err + } + values := make(map[string]string, len(secrets)) + for _, s := range secrets { + values[s.Key] = s.Value + } + return values, nil +} + +// refreshActive re-resolves cached agents that were seen recently and drops inactive ones. +func (a *agentCache) refreshActive() { + type refreshTarget struct { + key string + jwt string + scope agentScope + } + + a.mu.Lock() + targets := make([]refreshTarget, 0, len(a.entries)) + for key, entry := range a.entries { + if time.Since(entry.lastSeen) > agentInactiveTTL { + delete(a.entries, key) + continue + } + targets = append(targets, refreshTarget{key: key, jwt: entry.jwt, scope: entry.scope}) + } + a.mu.Unlock() + + for _, t := range targets { + resolved, err := a.resolve(t.jwt, t.scope) + if err != nil { + util.PrintWarning(fmt.Sprintf("failed to refresh agent cache: %v", err)) + continue + } + a.mu.Lock() + if entry, ok := a.entries[t.key]; ok { + entry.services = resolved + entry.cachedAt = time.Now() + } + a.mu.Unlock() + } +} diff --git a/packages/agentproxy/match.go b/packages/agentproxy/match.go new file mode 100644 index 00000000..0d100863 --- /dev/null +++ b/packages/agentproxy/match.go @@ -0,0 +1,99 @@ +package agentproxy + +import ( + "strings" +) + +// hostPattern is a single parsed pattern from a proxied service's comma-separated hostPattern. +type hostPattern struct { + host string // may start with "*." for a wildcard + port string // "" means any port + path string // "" means any path; may end with "*" +} + +func parseHostPatterns(raw string) []hostPattern { + var patterns []hostPattern + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + p := hostPattern{} + // split off path + if idx := strings.Index(part, "/"); idx != -1 { + p.path = part[idx:] + part = part[:idx] + } + // split off port + if idx := strings.LastIndex(part, ":"); idx != -1 { + p.port = part[idx+1:] + part = part[:idx] + } + p.host = part + patterns = append(patterns, p) + } + return patterns +} + +// matchScore returns (matched, score). Higher score = more specific match. +// Scoring: exact host (2) vs wildcard (1); specific port (+2) vs any (0); + path prefix length. +func (p hostPattern) matchScore(host, port, path string) (bool, int) { + score := 0 + + if strings.HasPrefix(p.host, "*.") { + suffix := p.host[1:] // ".github.com" + // wildcard matches exactly one extra label: api.github.com yes, a.b.github.com no + if !strings.HasSuffix(host, suffix) { + return false, 0 + } + prefix := strings.TrimSuffix(host, suffix) + if prefix == "" || strings.Contains(prefix, ".") { + return false, 0 + } + score += 1 + } else { + if !strings.EqualFold(p.host, host) { + return false, 0 + } + score += 2 + } + + if p.port != "" { + if p.port != port { + return false, 0 + } + score += 2 + } + + if p.path != "" { + prefix := strings.TrimSuffix(p.path, "*") + if !strings.HasPrefix(path, prefix) { + return false, 0 + } + score += len(prefix) + } + + return true, score +} + +// bestMatch picks the highest-scoring service for the target. On ties, the first service +// (definition order) wins because callers iterate services in order and use strict ">". +func bestMatch(services []*resolvedService, host, port, path string) *resolvedService { + var best *resolvedService + bestScore := -1 + + for _, svc := range services { + if !svc.isEnabled { + continue + } + for _, pat := range svc.hostPatterns { + matched, score := pat.matchScore(host, port, path) + if matched && score > bestScore { + bestScore = score + best = svc + } + } + } + return best +} diff --git a/packages/agentproxy/match_test.go b/packages/agentproxy/match_test.go new file mode 100644 index 00000000..5a5cdcee --- /dev/null +++ b/packages/agentproxy/match_test.go @@ -0,0 +1,74 @@ +package agentproxy + +import "testing" + +func svc(name, hostPattern string) *resolvedService { + return &resolvedService{ + name: name, + hostPatterns: parseHostPatterns(hostPattern), + isEnabled: true, + } +} + +func TestParseHostPatterns(t *testing.T) { + patterns := parseHostPatterns("api.stripe.com, *.github.com, internal.corp.com:3000/api/*") + if len(patterns) != 3 { + t.Fatalf("expected 3 patterns, got %d", len(patterns)) + } + if patterns[2].host != "internal.corp.com" || patterns[2].port != "3000" || patterns[2].path != "/api/*" { + t.Fatalf("unexpected parse: %+v", patterns[2]) + } +} + +func TestExactBeatsWildcard(t *testing.T) { + exact := svc("exact", "api.github.com") + wildcard := svc("wildcard", "*.github.com") + got := bestMatch([]*resolvedService{wildcard, exact}, "api.github.com", "443", "/") + if got == nil || got.name != "exact" { + t.Fatalf("expected exact match to win, got %v", got) + } +} + +func TestWildcardSingleLabelOnly(t *testing.T) { + wildcard := svc("wildcard", "*.github.com") + if got := bestMatch([]*resolvedService{wildcard}, "api.github.com", "443", "/"); got == nil { + t.Fatal("expected wildcard to match api.github.com") + } + if got := bestMatch([]*resolvedService{wildcard}, "a.b.github.com", "443", "/"); got != nil { + t.Fatal("wildcard should not match a.b.github.com") + } +} + +func TestPortMatching(t *testing.T) { + withPort := svc("withPort", "internal.corp.com:3000") + if got := bestMatch([]*resolvedService{withPort}, "internal.corp.com", "3000", "/"); got == nil { + t.Fatal("expected match on port 3000") + } + if got := bestMatch([]*resolvedService{withPort}, "internal.corp.com", "443", "/"); got != nil { + t.Fatal("should not match a different port") + } +} + +func TestLongestPathPrefixWins(t *testing.T) { + broad := svc("broad", "api.stripe.com") + specific := svc("specific", "api.stripe.com/v1/*") + got := bestMatch([]*resolvedService{broad, specific}, "api.stripe.com", "443", "/v1/charges") + if got == nil || got.name != "specific" { + t.Fatalf("expected longest path prefix to win, got %v", got) + } +} + +func TestNoMatch(t *testing.T) { + s := svc("stripe", "api.stripe.com") + if got := bestMatch([]*resolvedService{s}, "api.github.com", "443", "/"); got != nil { + t.Fatal("expected no match for unrelated host") + } +} + +func TestDisabledServiceNotMatched(t *testing.T) { + s := svc("stripe", "api.stripe.com") + s.isEnabled = false + if got := bestMatch([]*resolvedService{s}, "api.stripe.com", "443", "/"); got != nil { + t.Fatal("disabled service should not match") + } +} diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go new file mode 100644 index 00000000..51cf6ede --- /dev/null +++ b/packages/agentproxy/proxy.go @@ -0,0 +1,251 @@ +package agentproxy + +import ( + "bufio" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" +) + +const ( + UnmatchedAllow = "allow" + UnmatchedBlock = "block" +) + +type Options struct { + Port int + UnmatchedHost string // allow | block + PollInterval time.Duration // cache refresh cadence + ProxyToken string // the agent proxy MI's own access token + CaHTTPClient *resty.Client // authenticated as the proxy MI, for CA signing +} + +type proxyServer struct { + opts Options + ca *caManager + cache *agentCache + transport *http.Transport +} + +// Start runs the agent proxy until the process is terminated. +func Start(opts Options) error { + ps := &proxyServer{ + opts: opts, + ca: newCaManager(opts.CaHTTPClient), + cache: newAgentCache(opts.ProxyToken), + transport: &http.Transport{ + Proxy: nil, + ForceAttemptHTTP2: false, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + }, + } + + // warm up the intermediate CA so the first agent request isn't blocked on signing + if err := ps.ca.ensureIntermediate(); err != nil { + return fmt.Errorf("failed to initialize agent proxy CA: %w", err) + } + + go ps.pollLoop() + + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", opts.Port)) + if err != nil { + return fmt.Errorf("failed to listen on port %d: %w", opts.Port, err) + } + log.Info().Msgf("Infisical agent proxy listening on :%d", opts.Port) + + for { + conn, err := listener.Accept() + if err != nil { + log.Warn().Err(err).Msg("failed to accept connection") + continue + } + go ps.handleConn(conn) + } +} + +func (ps *proxyServer) pollLoop() { + interval := ps.opts.PollInterval + if interval <= 0 { + interval = 60 * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + ps.cache.refreshActive() + } +} + +func (ps *proxyServer) handleConn(clientConn net.Conn) { + defer clientConn.Close() + + reader := bufio.NewReader(clientConn) + req, err := http.ReadRequest(reader) + if err != nil { + return + } + + if req.Method != http.MethodConnect { + writeProxyResponse(clientConn, http.StatusMethodNotAllowed, "only CONNECT is supported") + return + } + + scope, jwt, ok := parseProxyAuth(req.Header.Get("Proxy-Authorization")) + if !ok { + clientConn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\n\r\n")) // #nosec G104 + return + } + + targetHost := req.Host + if !strings.Contains(targetHost, ":") { + targetHost += ":443" + } + hostname, port, err := net.SplitHostPort(targetHost) + if err != nil { + hostname = strings.Split(targetHost, ":")[0] + port = "443" + } + + // leaf is minted for the exact CONNECT hostname (design: CONNECT host is source of truth) + leaf, err := ps.ca.mintLeaf(hostname) + if err != nil { + writeProxyResponse(clientConn, http.StatusInternalServerError, "failed to mint certificate") + return + } + + if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil { + return + } + + tlsConn := tls.Server(clientConn, &tls.Config{ + Certificates: []tls.Certificate{leaf}, + MinVersion: tls.VersionTLS12, + }) + if err := tlsConn.Handshake(); err != nil { + return + } + defer tlsConn.Close() + + ps.serveTunnel(tlsConn, hostname, port, jwt, scope) +} + +// serveTunnel reads requests off the terminated TLS connection, applies credentials, and forwards them. +func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string, scope agentScope) { + tlsReader := bufio.NewReader(tlsConn) + + for { + req, err := http.ReadRequest(tlsReader) + if err != nil { + if !errors.Is(err, io.EOF) { + log.Debug().Err(err).Msg("tunnel read ended") + } + return + } + + resp, err := ps.forward(req, hostname, port, jwt, scope) + if err != nil { + writeHTTPError(tlsConn, http.StatusBadGateway, err.Error()) + return + } + + if err := resp.Write(tlsConn); err != nil { + resp.Body.Close() + return + } + resp.Body.Close() + } +} + +func (ps *proxyServer) forward(req *http.Request, hostname, port, jwt string, scope agentScope) (*http.Response, error) { + services, err := ps.cache.get(jwt, scope) + if err != nil { + return nil, fmt.Errorf("failed to resolve agent permissions: %w", err) + } + + svc := bestMatch(services, hostname, port, req.URL.Path) + + if svc == nil && ps.opts.UnmatchedHost == UnmatchedBlock { + return nil, fmt.Errorf("host %q has no matching proxied service (blocked by policy)", hostname) + } + + // rebuild the outbound request targeting the real upstream + req.URL.Scheme = "https" + req.URL.Host = net.JoinHostPort(hostname, port) + req.RequestURI = "" + + if svc != nil { + if err := applyCredentials(req, svc); err != nil { + return nil, fmt.Errorf("failed to apply credentials: %w", err) + } + } + + req.Header.Del("Proxy-Authorization") + req.Header.Del("Proxy-Connection") + + return ps.transport.RoundTrip(req) +} + +// parseProxyAuth decodes the Proxy-Authorization Basic header into scope + agent JWT. +// userinfo layout: username = projectId, password = "/:" (jwt last). +func parseProxyAuth(header string) (agentScope, string, bool) { + const prefix = "Basic " + if !strings.HasPrefix(header, prefix) { + return agentScope{}, "", false + } + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(header, prefix)) + if err != nil { + return agentScope{}, "", false + } + + userinfo := string(decoded) + firstColon := strings.Index(userinfo, ":") + if firstColon == -1 { + return agentScope{}, "", false + } + projectID := userinfo[:firstColon] + password := userinfo[firstColon+1:] + + // jwt is after the LAST colon (env/path contain no colons; JWT charset has none) + lastColon := strings.LastIndex(password, ":") + if lastColon == -1 { + return agentScope{}, "", false + } + scopeStr := password[:lastColon] + jwt := password[lastColon+1:] + + // scopeStr = "/" + slash := strings.Index(scopeStr, "/") + var environment, secretPath string + if slash == -1 { + environment = scopeStr + secretPath = "/" + } else { + environment = scopeStr[:slash] + secretPath = scopeStr[slash:] // includes leading slash + } + + if projectID == "" || environment == "" || jwt == "" { + return agentScope{}, "", false + } + + return agentScope{projectID: projectID, environment: environment, secretPath: secretPath}, jwt, true +} + +func writeProxyResponse(conn net.Conn, status int, msg string) { + fmt.Fprintf(conn, "HTTP/1.1 %d %s\r\nContent-Length: %d\r\n\r\n%s", status, http.StatusText(status), len(msg), msg) // #nosec G104 +} + +func writeHTTPError(conn io.Writer, status int, msg string) { + fmt.Fprintf(conn, "HTTP/1.1 %d %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", status, http.StatusText(status), len(msg), msg) // #nosec G104 +} diff --git a/packages/agentproxy/rewrite.go b/packages/agentproxy/rewrite.go new file mode 100644 index 00000000..6d17dc7f --- /dev/null +++ b/packages/agentproxy/rewrite.go @@ -0,0 +1,127 @@ +package agentproxy + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + roleHeaderRewrite = "header-rewrite" + roleCredentialSub = "credential-substitution" + purposeUsername = "username" + purposePassword = "password" + surfaceHeader = "header" + surfacePath = "path" + surfaceQuery = "query" + surfaceBody = "body" + maxBodyRewriteSize = 10 * 1024 * 1024 // 10 MiB +) + +// applyCredentials rewrites headers and substitutes placeholder values on the outbound request. +func applyCredentials(req *http.Request, svc *resolvedService) error { + var basicUser, basicPass string + haveBasic := false + + for _, cred := range svc.credentials { + switch cred.role { + case roleHeaderRewrite: + switch cred.headerPurpose { + case purposeUsername: + basicUser = cred.value + haveBasic = true + case purposePassword: + basicPass = cred.value + haveBasic = true + default: + headerName := cred.headerName + if headerName == "" { + headerName = "Authorization" + } + value := cred.value + if cred.headerPrefix != "" { + value = cred.headerPrefix + " " + value + } + req.Header.Set(headerName, value) + } + case roleCredentialSub: + if err := applySubstitution(req, cred); err != nil { + return err + } + } + } + + if haveBasic { + token := base64.StdEncoding.EncodeToString([]byte(basicUser + ":" + basicPass)) + req.Header.Set("Authorization", "Basic "+token) + } + + return nil +} + +func hasSurface(surfaces []string, target string) bool { + for _, s := range surfaces { + if s == target { + return true + } + } + return false +} + +// applySubstitution replaces the placeholder value with the real credential across the +// configured surfaces (header/path/query/body). +func applySubstitution(req *http.Request, cred resolvedCredential) error { + placeholder := cred.placeholder + if placeholder == "" { + return nil + } + real := cred.value + + if hasSurface(cred.surfaces, surfacePath) { + req.URL.Path = strings.ReplaceAll(req.URL.Path, placeholder, real) + req.URL.RawPath = "" + } + + if hasSurface(cred.surfaces, surfaceQuery) { + req.URL.RawQuery = strings.ReplaceAll(req.URL.RawQuery, placeholder, real) + } + + if hasSurface(cred.surfaces, surfaceHeader) { + for name, values := range req.Header { + for i, v := range values { + if strings.Contains(v, placeholder) { + req.Header[name][i] = strings.ReplaceAll(v, placeholder, real) + } + } + } + } + + if hasSurface(cred.surfaces, surfaceBody) && req.Body != nil { + // don't rewrite encoded bodies; the placeholder wouldn't be present verbatim + if req.Header.Get("Content-Encoding") != "" { + return nil + } + // read one byte past the cap to detect oversized bodies + body, err := io.ReadAll(io.LimitReader(req.Body, maxBodyRewriteSize+1)) + if err != nil { + _ = req.Body.Close() + return fmt.Errorf("failed to read request body for substitution: %w", err) + } + if len(body) > maxBodyRewriteSize { + // too large to safely rewrite: forward the already-buffered body unchanged rather than + // truncating it (truncation would corrupt the request) + req.Body = io.NopCloser(io.MultiReader(bytes.NewReader(body), req.Body)) + return nil + } + _ = req.Body.Close() + rewritten := bytes.ReplaceAll(body, []byte(placeholder), []byte(real)) + req.Body = io.NopCloser(bytes.NewReader(rewritten)) + req.ContentLength = int64(len(rewritten)) + req.Header.Set("Content-Length", fmt.Sprintf("%d", len(rewritten))) + } + + return nil +} diff --git a/packages/agentproxy/rewrite_test.go b/packages/agentproxy/rewrite_test.go new file mode 100644 index 00000000..0913934f --- /dev/null +++ b/packages/agentproxy/rewrite_test.go @@ -0,0 +1,112 @@ +package agentproxy + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newReq(t *testing.T, body string) *http.Request { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "https://api.example.com/v1/charges?token=placeholder_x", strings.NewReader(body)) + return req +} + +func TestBearerHeaderRewrite(t *testing.T) { + req := newReq(t, "") + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleHeaderRewrite, headerName: "Authorization", headerPrefix: "Bearer", value: "sk_live_real"}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + if got := req.Header.Get("Authorization"); got != "Bearer sk_live_real" { + t.Fatalf("unexpected Authorization header: %q", got) + } +} + +func TestApiKeyHeaderNoPrefix(t *testing.T) { + req := newReq(t, "") + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleHeaderRewrite, headerName: "x-api-key", value: "abc123"}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + if got := req.Header.Get("x-api-key"); got != "abc123" { + t.Fatalf("unexpected x-api-key header: %q", got) + } +} + +func TestBasicAuthFromTwoCredentials(t *testing.T) { + req := newReq(t, "") + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleHeaderRewrite, headerPurpose: purposeUsername, value: "user"}, + {role: roleHeaderRewrite, headerPurpose: purposePassword, value: "pass"}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + // base64("user:pass") == "dXNlcjpwYXNz" + if got := req.Header.Get("Authorization"); got != "Basic dXNlcjpwYXNz" { + t.Fatalf("unexpected basic auth header: %q", got) + } +} + +func TestSubstitutionInQuery(t *testing.T) { + req := newReq(t, "") + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleCredentialSub, placeholder: "placeholder_x", value: "real_secret", surfaces: []string{surfaceQuery}}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + if !strings.Contains(req.URL.RawQuery, "real_secret") || strings.Contains(req.URL.RawQuery, "placeholder_x") { + t.Fatalf("query not substituted: %q", req.URL.RawQuery) + } +} + +func TestSubstitutionInPath(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "https://api.telegram.org/", nil) + req.URL.Path = "/botplaceholder_tg/sendMessage" + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleCredentialSub, placeholder: "placeholder_tg", value: "12345:realtoken", surfaces: []string{surfacePath}}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + if !strings.Contains(req.URL.Path, "12345:realtoken") { + t.Fatalf("path not substituted: %q", req.URL.Path) + } +} + +func TestSubstitutionInBody(t *testing.T) { + req := newReq(t, `{"token":"placeholder_x"}`) + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleCredentialSub, placeholder: "placeholder_x", value: "real_secret", surfaces: []string{surfaceBody}}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(req.Body) + if !strings.Contains(string(body), "real_secret") || strings.Contains(string(body), "placeholder_x") { + t.Fatalf("body not substituted: %q", string(body)) + } +} + +func TestProxyAuthParsing(t *testing.T) { + // base64("proj-123:prod/myapp:jwt.abc.def") + header := "Basic cHJvai0xMjM6cHJvZC9teWFwcDpqd3QuYWJjLmRlZg==" + scope, jwt, ok := parseProxyAuth(header) + if !ok { + t.Fatal("expected parse to succeed") + } + if scope.projectID != "proj-123" || scope.environment != "prod" || scope.secretPath != "/myapp" { + t.Fatalf("unexpected scope: %+v", scope) + } + if jwt != "jwt.abc.def" { + t.Fatalf("unexpected jwt: %q", jwt) + } +} diff --git a/packages/api/agent_proxy.go b/packages/api/agent_proxy.go new file mode 100644 index 00000000..47e0c58e --- /dev/null +++ b/packages/api/agent_proxy.go @@ -0,0 +1,117 @@ +package api + +import ( + "fmt" + + "github.com/Infisical/infisical-merge/packages/config" + "github.com/go-resty/resty/v2" +) + +// ─── Agent Proxy CA ────────────────────────────────────────────────── + +type GetAgentProxyCaResponse struct { + Certificate string `json:"certificate"` + KeyAlgorithm string `json:"keyAlgorithm"` + IssuedAt string `json:"issuedAt"` + Expiration string `json:"expiration"` + SerialNumber string `json:"serialNumber"` +} + +func CallGetAgentProxyCa(httpClient *resty.Client) (GetAgentProxyCaResponse, error) { + var res GetAgentProxyCaResponse + response, err := httpClient. + R(). + SetResult(&res). + SetHeader("User-Agent", USER_AGENT). + Get(fmt.Sprintf("%v/v1/organization/agent-proxy-ca", config.INFISICAL_URL)) + + if err != nil { + return GetAgentProxyCaResponse{}, NewGenericRequestError("CallGetAgentProxyCa", err) + } + if response.IsError() { + return GetAgentProxyCaResponse{}, NewAPIErrorWithResponse("CallGetAgentProxyCa", response, nil) + } + return res, nil +} + +type SignAgentProxyIntermediateCaRequest struct { + PublicKey string `json:"publicKey"` +} + +type SignAgentProxyIntermediateCaResponse struct { + Certificate string `json:"certificate"` + IssuedAt string `json:"issuedAt"` + Expiration string `json:"expiration"` + SerialNumber string `json:"serialNumber"` +} + +func CallSignAgentProxyIntermediateCa(httpClient *resty.Client, request SignAgentProxyIntermediateCaRequest) (SignAgentProxyIntermediateCaResponse, error) { + var res SignAgentProxyIntermediateCaResponse + response, err := httpClient. + R(). + SetResult(&res). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v1/organization/agent-proxy-ca/sign", config.INFISICAL_URL)) + + if err != nil { + return SignAgentProxyIntermediateCaResponse{}, NewGenericRequestError("CallSignAgentProxyIntermediateCa", err) + } + if response.IsError() { + return SignAgentProxyIntermediateCaResponse{}, NewAPIErrorWithResponse("CallSignAgentProxyIntermediateCa", response, nil) + } + return res, nil +} + +// ─── Proxied Services ──────────────────────────────────────────────── + +type ProxiedServiceCredential struct { + ID string `json:"id"` + SecretKey string `json:"secretKey"` + Role string `json:"role"` + HeaderName string `json:"headerName,omitempty"` + HeaderPrefix string `json:"headerPrefix,omitempty"` + HeaderPurpose string `json:"headerPurpose,omitempty"` + PlaceholderKey string `json:"placeholderKey,omitempty"` + PlaceholderValue string `json:"placeholderValue,omitempty"` + SubstitutionSurfaces []string `json:"substitutionSurfaces,omitempty"` +} + +type ProxiedService struct { + ID string `json:"id"` + Name string `json:"name"` + HostPattern string `json:"hostPattern"` + IsEnabled bool `json:"isEnabled"` + CanProxy bool `json:"canProxy"` + Credentials []ProxiedServiceCredential `json:"credentials"` +} + +type ListProxiedServicesResponse struct { + Services []ProxiedService `json:"services"` +} + +type ListProxiedServicesRequest struct { + ProjectID string + Environment string + SecretPath string +} + +func CallListProxiedServices(httpClient *resty.Client, request ListProxiedServicesRequest) (ListProxiedServicesResponse, error) { + var res ListProxiedServicesResponse + response, err := httpClient. + R(). + SetResult(&res). + SetHeader("User-Agent", USER_AGENT). + SetQueryParam("projectId", request.ProjectID). + SetQueryParam("environment", request.Environment). + SetQueryParam("secretPath", request.SecretPath). + Get(fmt.Sprintf("%v/v1/proxied-services", config.INFISICAL_URL)) + + if err != nil { + return ListProxiedServicesResponse{}, NewGenericRequestError("CallListProxiedServices", err) + } + if response.IsError() { + return ListProxiedServicesResponse{}, NewAPIErrorWithResponse("CallListProxiedServices", response, nil) + } + return res, nil +} diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go new file mode 100644 index 00000000..e5d81fb7 --- /dev/null +++ b/packages/cmd/agent_proxy.go @@ -0,0 +1,340 @@ +package cmd + +import ( + "fmt" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/models" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/fatih/color" + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +var agentProxyCmd = &cobra.Command{ + Use: "agent-proxy", + Short: "Secrets brokering: run an agent proxy and connect agents to it", + DisableFlagsInUseLine: true, +} + +var agentProxyConnectCmd = &cobra.Command{ + Use: "connect [flags] -- [agent start command]", + Short: "Set up the environment and launch an agent behind the agent proxy", + Example: "infisical secrets agent-proxy connect --proxy=proxy:14322 --env=prod --path=/myapp -- claude", + DisableFlagsInUseLine: true, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("provide the agent command to run after '--', e.g. -- claude") + } + return nil + }, + Run: runAgentProxyConnect, +} + +var agentProxyStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the agent proxy (MITM proxy that brokers credentials on the wire)", + Example: "infisical secrets agent-proxy start --port 14322", + DisableFlagsInUseLine: true, + Run: runAgentProxyStart, +} + +const mitmCaRelativePath = ".infisical/agent-proxy/mitm-ca.pem" + +// CA-trust env vars set on the agent so it trusts the proxy's forged certs for upstream hosts. +var caTrustEnvVars = []string{ + "SSL_CERT_FILE", + "NODE_EXTRA_CA_CERTS", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "DENO_CERT", +} + +// proxy env vars (set + stripped) — POSIX getenv returns the first match, so stale copies must be removed. +var proxyEnvKeys = []string{ + "HTTPS_PROXY", + "https_proxy", + "HTTP_PROXY", + "http_proxy", + "NO_PROXY", + "no_proxy", + "NODE_USE_ENV_PROXY", + "OPENCLAW_PROXY_URL", +} + +func runAgentProxyConnect(cmd *cobra.Command, args []string) { + proxyAddr, err := cmd.Flags().GetString("proxy") + if err != nil || proxyAddr == "" { + util.HandleError(fmt.Errorf("the --proxy flag is required (e.g. --proxy=proxy:14322)")) + } + + environment, err := cmd.Flags().GetString("env") + if err != nil { + util.HandleError(err, "Unable to parse --env") + } + if !cmd.Flags().Changed("env") { + if envFromWorkspace := util.GetEnvFromWorkspaceFile(); envFromWorkspace != "" { + environment = envFromWorkspace + } + } + if environment == "" { + util.HandleError(fmt.Errorf("the --env flag is required")) + } + + secretPath, err := cmd.Flags().GetString("path") + if err != nil { + util.HandleError(err, "Unable to parse --path") + } + + projectID, err := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "projectId", []string{util.INFISICAL_PROJECT_ID_NAME}, "") + if err != nil { + util.HandleError(err, "Unable to parse --projectId") + } + if projectID == "" { + if workspaceFile, wsErr := util.GetWorkSpaceFromFile(); wsErr == nil { + projectID = workspaceFile.WorkspaceId + } + } + if projectID == "" { + util.HandleError(fmt.Errorf("project id is required; pass --projectId, set INFISICAL_PROJECT_ID, or run inside a project with .infisical.json")) + } + + token := resolveAgentToken(cmd) + + httpClient := resty.New().SetAuthToken(token.Token) + + // 1. Fetch the org root CA and write it to disk for the agent to trust. + caResp, err := api.CallGetAgentProxyCa(httpClient) + if err != nil { + util.HandleError(err, "Failed to fetch the agent proxy root CA") + } + caPath, err := writeMitmCa(caResp.Certificate) + if err != nil { + util.HandleError(err, "Failed to write the agent proxy CA to disk") + } + + // 2. Fetch proxied services the agent MI can proxy → placeholder env vars. + placeholderEnvs := fetchProxiedServicePlaceholders(httpClient, projectID, environment, secretPath) + + // 3. Fetch regular secrets the agent MI can read (real values injected directly). + realSecrets := fetchAgentRealSecrets(token, projectID, environment, secretPath) + + // 4. Build the environment and launch the agent. + env := buildAgentEnv(proxyURL(proxyAddr, projectID, environment, secretPath, token.Token), caPath, token.Token, placeholderEnvs, realSecrets) + + if err := runAgentProcess(args, env); err != nil { + util.HandleError(err, "Agent process failed") + } +} + +// resolveAgentToken authenticates the agent machine identity: it prefers universal-auth +// client id/secret (flags or env vars) and falls back to an already-issued token. +func resolveAgentToken(cmd *cobra.Command) *models.TokenDetails { + clientID, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-id", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}, "") + clientSecret, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-secret", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}, "") + + if clientID != "" && clientSecret != "" { + loginResp, err := util.UniversalAuthLogin(clientID, clientSecret) + if err != nil { + util.HandleError(err, "Failed to authenticate the agent machine identity") + } + return &models.TokenDetails{ + Type: util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER, + Token: loginResp.AccessToken, + } + } + + token, err := util.GetInfisicalToken(cmd) + if err != nil { + util.HandleError(err, "Unable to resolve authentication") + } + if token == nil { + util.HandleError(fmt.Errorf("authentication required; provide --client-id/--client-secret, env vars, or a token")) + } + return token +} + +// proxyURL builds http://:/:@host:port. +// projectId is the username; the password is "/:" (JWT last, per RFC 3986 §3.2.1). +// url.URL handles percent-encoding of the password, including slashes in the path. +func proxyURL(proxyAddr, projectID, environment, secretPath, jwt string) string { + password := fmt.Sprintf("%s/%s:%s", environment, strings.TrimPrefix(secretPath, "/"), jwt) + u := url.URL{ + Scheme: "http", + User: url.UserPassword(projectID, password), + Host: proxyAddr, + } + return u.String() +} + +func writeMitmCa(certificatePem string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + caPath := filepath.Join(home, mitmCaRelativePath) + if err := os.MkdirAll(filepath.Dir(caPath), 0o700); err != nil { + return "", err + } + if err := os.WriteFile(caPath, []byte(certificatePem), 0o600); err != nil { + return "", err + } + return caPath, nil +} + +func fetchProxiedServicePlaceholders(httpClient *resty.Client, projectID, environment, secretPath string) map[string]string { + resp, err := api.CallListProxiedServices(httpClient, api.ListProxiedServicesRequest{ + ProjectID: projectID, + Environment: environment, + SecretPath: secretPath, + }) + if err != nil { + util.HandleError(err, "Failed to list proxied services") + } + + placeholders := map[string]string{} + for _, svc := range resp.Services { + if !svc.CanProxy { + continue + } + for _, cred := range svc.Credentials { + if cred.Role == "credential-substitution" && cred.PlaceholderKey != "" { + placeholders[cred.PlaceholderKey] = cred.PlaceholderValue + } + } + } + return placeholders +} + +func fetchAgentRealSecrets(token *models.TokenDetails, projectID, environment, secretPath string) []models.SingleEnvironmentVariable { + params := models.GetAllSecretsParameters{ + Environment: environment, + WorkspaceId: projectID, + SecretsPath: secretPath, + } + if token.Type == util.SERVICE_TOKEN_IDENTIFIER { + params.InfisicalToken = token.Token + } else if token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER { + params.UniversalAuthAccessToken = token.Token + } + + secrets, err := util.GetAllEnvironmentVariables(params, "") + if err != nil { + // A Proxy-only agent legitimately has no ReadValue permission on secrets in this path. + // This is expected, not fatal — the agent still gets proxy config + placeholders. + log.Warn().Msgf("Could not fetch regular secrets (agent may lack read access): %v", err) + return nil + } + return secrets +} + +func buildAgentEnv(proxy, caPath, jwt string, placeholders map[string]string, realSecrets []models.SingleEnvironmentVariable) []string { + // start from the current environment, stripping any stale proxy keys + stale := map[string]bool{} + for _, k := range proxyEnvKeys { + stale[k] = true + } + env := map[string]string{} + for _, kv := range os.Environ() { + parts := strings.SplitN(kv, "=", 2) + if len(parts) == 2 && !stale[parts[0]] { + env[parts[0]] = parts[1] + } + } + + // proxy routing + env["HTTPS_PROXY"] = proxy + env["HTTP_PROXY"] = proxy + env["NO_PROXY"] = "localhost,127.0.0.1" + env["NODE_USE_ENV_PROXY"] = "1" + env["OPENCLAW_PROXY_URL"] = proxy + + // CA trust + for _, k := range caTrustEnvVars { + env[k] = caPath + } + + // Infisical CLI access from within the agent + env["INFISICAL_TOKEN"] = jwt + + // placeholders (credential-substitution services) + for k, v := range placeholders { + env[k] = v + } + + // real secrets win over placeholder collisions + for _, s := range realSecrets { + if _, collides := placeholders[s.Key]; collides { + log.Warn().Msgf("Secret %q shadows a proxied-service placeholder; using the real secret value", s.Key) + } + env[s.Key] = s.Value + } + + result := make([]string, 0, len(env)) + for k, v := range env { + result = append(result, fmt.Sprintf("%s=%s", k, v)) + } + return result +} + +func runAgentProcess(args, env []string) error { + log.Info().Msg(color.GreenString("Starting agent behind the Infisical agent proxy")) + + // #nosec G204 -- the command is provided directly by the operator running the CLI + proc := exec.Command(args[0], args[1:]...) + proc.Stdin = os.Stdin + proc.Stdout = os.Stdout + proc.Stderr = os.Stderr + proc.Env = env + + sigChannel := make(chan os.Signal, 1) + signal.Notify(sigChannel) + + if err := proc.Start(); err != nil { + return err + } + + go func() { + for sig := range sigChannel { + _ = proc.Process.Signal(sig) + } + }() + + if err := proc.Wait(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if ws, ok := exitErr.Sys().(syscall.WaitStatus); ok { + os.Exit(ws.ExitStatus()) + } + } + return err + } + return nil +} + +func init() { + agentProxyConnectCmd.Flags().String("proxy", "", "address of the agent proxy (host:port)") + agentProxyConnectCmd.Flags().StringP("env", "e", "dev", "environment slug to fetch proxied services and secrets from") + agentProxyConnectCmd.Flags().String("path", "/", "secret path (folder) scope") + agentProxyConnectCmd.Flags().String("projectId", "", "project id (falls back to INFISICAL_PROJECT_ID or .infisical.json)") + agentProxyConnectCmd.Flags().String("client-id", "", "universal auth client id for the agent machine identity") + agentProxyConnectCmd.Flags().String("client-secret", "", "universal auth client secret for the agent machine identity") + + agentProxyStartCmd.Flags().Int("port", 14322, "port for the agent proxy to listen on") + agentProxyStartCmd.Flags().String("unmatched-host", "allow", "policy for hosts with no proxied service: allow | block") + agentProxyStartCmd.Flags().Int("poll-interval", 60, "seconds between permission/credential refreshes for active agents") + agentProxyStartCmd.Flags().String("client-id", "", "universal auth client id for the agent proxy machine identity") + agentProxyStartCmd.Flags().String("client-secret", "", "universal auth client secret for the agent proxy machine identity") + + agentProxyCmd.AddCommand(agentProxyConnectCmd) + agentProxyCmd.AddCommand(agentProxyStartCmd) + secretsCmd.AddCommand(agentProxyCmd) +} diff --git a/packages/cmd/agent_proxy_start.go b/packages/cmd/agent_proxy_start.go new file mode 100644 index 00000000..d67e33bc --- /dev/null +++ b/packages/cmd/agent_proxy_start.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/Infisical/infisical-merge/packages/agentproxy" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/fatih/color" + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +func runAgentProxyStart(cmd *cobra.Command, args []string) { + port, _ := cmd.Flags().GetInt("port") + unmatchedHost, _ := cmd.Flags().GetString("unmatched-host") + if unmatchedHost != agentproxy.UnmatchedAllow && unmatchedHost != agentproxy.UnmatchedBlock { + util.HandleError(fmt.Errorf("--unmatched-host must be 'allow' or 'block', got %q", unmatchedHost)) + } + pollInterval, _ := cmd.Flags().GetInt("poll-interval") + + // Authenticate as the agent proxy machine identity. + clientID, err := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-id", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}, "") + if err != nil || clientID == "" { + util.HandleError(fmt.Errorf("agent proxy credentials required; set INFISICAL_UNIVERSAL_AUTH_CLIENT_ID / _SECRET or pass --client-id / --client-secret")) + } + clientSecret, err := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-secret", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}, "") + if err != nil || clientSecret == "" { + util.HandleError(fmt.Errorf("agent proxy client secret required")) + } + + loginResp, err := util.UniversalAuthLogin(clientID, clientSecret) + if err != nil { + util.HandleError(err, "Failed to authenticate the agent proxy machine identity") + } + + log.Info().Msg(color.GreenString("Agent proxy authenticated; starting MITM proxy")) + + caClient := resty.New().SetAuthToken(loginResp.AccessToken) + + err = agentproxy.Start(agentproxy.Options{ + Port: port, + UnmatchedHost: unmatchedHost, + PollInterval: time.Duration(pollInterval) * time.Second, + ProxyToken: loginResp.AccessToken, + CaHTTPClient: caClient, + }) + if err != nil { + util.HandleError(err, "Agent proxy failed") + } +} From 31e0ea8292c71513fed68d846a272a141cb14ff5 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:50:36 +0530 Subject: [PATCH 02/26] fix: address pre-PR review for agent proxy - refresh the proxy MI access token before it expires (re-login goroutine), so secret fetches and CA signing don't start 401ing after the token TTL - evict an agent's cache on a 401/403 during refresh, so a revoked or expired agent JWT stops receiving brokered credentials (fail closed) - leaf certs use the IP SAN for IP-literal CONNECT targets (not DNS names) - connection deadlines: bound CONNECT read, TLS handshake, and idle tunnel - case-insensitive host matching for wildcards (matching exact behavior) - blocked hosts return 403 (not 502) so policy blocks are distinguishable - pass INFISICAL_DOMAIN to the agent so its infisical commands hit the same instance - require --env (no silent 'dev' default) --- packages/agentproxy/ca.go | 20 +++++++++++++------ packages/agentproxy/cache.go | 28 +++++++++++++++++++++++--- packages/agentproxy/match.go | 10 +++++++--- packages/agentproxy/match_test.go | 11 +++++++++++ packages/agentproxy/proxy.go | 32 ++++++++++++++++++++++++------ packages/cmd/agent_proxy.go | 7 +++++-- packages/cmd/agent_proxy_start.go | 33 +++++++++++++++++++++++++++---- 7 files changed, 117 insertions(+), 24 deletions(-) diff --git a/packages/agentproxy/ca.go b/packages/agentproxy/ca.go index 4077f9ac..962c9cf6 100644 --- a/packages/agentproxy/ca.go +++ b/packages/agentproxy/ca.go @@ -10,6 +10,7 @@ import ( "encoding/pem" "fmt" "math/big" + "net" "sync" "time" @@ -20,7 +21,8 @@ import ( // caManager holds the intermediate CA (signed by the org root CA in Infisical) and mints // short-lived leaf certificates per upstream hostname, presenting the leaf+intermediate chain. type caManager struct { - httpClient *resty.Client + // token returns the proxy MI's current access token, refreshed by the caller. + token func() string mu sync.Mutex intermediateKey *ecdsa.PrivateKey @@ -36,10 +38,10 @@ type leafEntry struct { expiration time.Time } -func newCaManager(httpClient *resty.Client) *caManager { +func newCaManager(token func() string) *caManager { return &caManager{ - httpClient: httpClient, - leafCache: make(map[string]*leafEntry), + token: token, + leafCache: make(map[string]*leafEntry), } } @@ -64,7 +66,8 @@ func (c *caManager) ensureIntermediate() error { } pubPem := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDer}) - resp, err := api.CallSignAgentProxyIntermediateCa(c.httpClient, api.SignAgentProxyIntermediateCaRequest{ + client := resty.New().SetAuthToken(c.token()) + resp, err := api.CallSignAgentProxyIntermediateCa(client, api.SignAgentProxyIntermediateCaRequest{ PublicKey: string(pubPem), }) if err != nil { @@ -128,7 +131,12 @@ func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { NotAfter: notAfter, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - DNSNames: []string{hostname}, + } + // TLS clients validate an IP-literal target against the IP SAN, not DNS names. + if ip := net.ParseIP(hostname); ip != nil { + template.IPAddresses = []net.IP{ip} + } else { + template.DNSNames = []string{hostname} } leafDer, err := x509.CreateCertificate(rand.Reader, template, interCert, &key.PublicKey, interKey) diff --git a/packages/agentproxy/cache.go b/packages/agentproxy/cache.go index 5527ce83..9b3cd3f4 100644 --- a/packages/agentproxy/cache.go +++ b/packages/agentproxy/cache.go @@ -1,6 +1,7 @@ package agentproxy import ( + "errors" "fmt" "strings" "sync" @@ -12,6 +13,16 @@ import ( "github.com/go-resty/resty/v2" ) +// isAuthError reports whether an error from an Infisical call is a hard auth failure (401/403), +// as opposed to a transient/network error. A revoked or expired agent JWT surfaces here. +func isAuthError(err error) bool { + var apiErr *api.APIError + if errors.As(err, &apiErr) { + return apiErr.StatusCode == 401 || apiErr.StatusCode == 403 + } + return false +} + const agentInactiveTTL = 10 * time.Minute type resolvedCredential struct { @@ -54,13 +65,15 @@ func cacheKey(jwt string, scope agentScope) string { // agentCache resolves and caches, per agent JWT, the proxied services the agent may use // along with the real credential values (fetched with the proxy's own token). type agentCache struct { - proxyToken string + // proxyToken returns the proxy MI's current access token; it is refreshed by the caller, + // so it is a getter rather than a fixed string (a fixed token would expire and break fetches). + proxyToken func() string mu sync.Mutex entries map[string]*agentEntry } -func newAgentCache(proxyToken string) *agentCache { +func newAgentCache(proxyToken func() string) *agentCache { return &agentCache{ proxyToken: proxyToken, entries: make(map[string]*agentEntry), @@ -156,7 +169,7 @@ func (a *agentCache) fetchSecretValues(scope agentScope) (map[string]string, err Environment: scope.environment, WorkspaceId: scope.projectID, SecretsPath: scope.secretPath, - UniversalAuthAccessToken: a.proxyToken, + UniversalAuthAccessToken: a.proxyToken(), } secrets, err := util.GetAllEnvironmentVariables(params, "") if err != nil { @@ -191,6 +204,15 @@ func (a *agentCache) refreshActive() { for _, t := range targets { resolved, err := a.resolve(t.jwt, t.scope) if err != nil { + // A hard auth failure means the agent's JWT was revoked or expired: evict so we stop + // serving its cached credentials (fail closed). Transient errors keep the existing cache. + if isAuthError(err) { + util.PrintWarning(fmt.Sprintf("agent authorization no longer valid; dropping cached credentials: %v", err)) + a.mu.Lock() + delete(a.entries, t.key) + a.mu.Unlock() + continue + } util.PrintWarning(fmt.Sprintf("failed to refresh agent cache: %v", err)) continue } diff --git a/packages/agentproxy/match.go b/packages/agentproxy/match.go index 0d100863..d3ffccec 100644 --- a/packages/agentproxy/match.go +++ b/packages/agentproxy/match.go @@ -41,8 +41,12 @@ func parseHostPatterns(raw string) []hostPattern { func (p hostPattern) matchScore(host, port, path string) (bool, int) { score := 0 - if strings.HasPrefix(p.host, "*.") { - suffix := p.host[1:] // ".github.com" + // Hostnames are case-insensitive; fold both sides so wildcard matching agrees with exact matching. + host = strings.ToLower(host) + patternHost := strings.ToLower(p.host) + + if strings.HasPrefix(patternHost, "*.") { + suffix := patternHost[1:] // ".github.com" // wildcard matches exactly one extra label: api.github.com yes, a.b.github.com no if !strings.HasSuffix(host, suffix) { return false, 0 @@ -53,7 +57,7 @@ func (p hostPattern) matchScore(host, port, path string) (bool, int) { } score += 1 } else { - if !strings.EqualFold(p.host, host) { + if patternHost != host { return false, 0 } score += 2 diff --git a/packages/agentproxy/match_test.go b/packages/agentproxy/match_test.go index 5a5cdcee..640668ea 100644 --- a/packages/agentproxy/match_test.go +++ b/packages/agentproxy/match_test.go @@ -58,6 +58,17 @@ func TestLongestPathPrefixWins(t *testing.T) { } } +func TestHostMatchingIsCaseInsensitive(t *testing.T) { + exact := svc("exact", "API.Stripe.com") + if got := bestMatch([]*resolvedService{exact}, "api.stripe.com", "443", "/"); got == nil { + t.Fatal("exact host match should be case-insensitive") + } + wildcard := svc("wildcard", "*.GitHub.com") + if got := bestMatch([]*resolvedService{wildcard}, "API.github.com", "443", "/"); got == nil { + t.Fatal("wildcard host match should be case-insensitive") + } +} + func TestNoMatch(t *testing.T) { s := svc("stripe", "api.stripe.com") if got := bestMatch([]*resolvedService{s}, "api.github.com", "443", "/"); got != nil { diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index 51cf6ede..1d31c757 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/go-resty/resty/v2" "github.com/rs/zerolog/log" ) @@ -21,12 +20,20 @@ const ( UnmatchedBlock = "block" ) +const ( + connectReadTimeout = 30 * time.Second // time allowed to send the CONNECT request + tlsHandshakeTimeout = 10 * time.Second // time allowed to complete the MITM TLS handshake + idleTunnelTimeout = 5 * time.Minute // max idle time waiting for the next request on a tunnel +) + +// errHostBlocked marks a request rejected by the unmatched-host=block policy so it maps to 403. +var errHostBlocked = errors.New("host blocked by policy") + type Options struct { Port int UnmatchedHost string // allow | block PollInterval time.Duration // cache refresh cadence - ProxyToken string // the agent proxy MI's own access token - CaHTTPClient *resty.Client // authenticated as the proxy MI, for CA signing + ProxyToken func() string // returns the agent proxy MI's current access token (refreshed by the caller) } type proxyServer struct { @@ -40,7 +47,7 @@ type proxyServer struct { func Start(opts Options) error { ps := &proxyServer{ opts: opts, - ca: newCaManager(opts.CaHTTPClient), + ca: newCaManager(opts.ProxyToken), cache: newAgentCache(opts.ProxyToken), transport: &http.Transport{ Proxy: nil, @@ -90,11 +97,14 @@ func (ps *proxyServer) pollLoop() { func (ps *proxyServer) handleConn(clientConn net.Conn) { defer clientConn.Close() + // Bound the time a client may take to send its CONNECT so a stalled connection can't pin a goroutine. + _ = clientConn.SetReadDeadline(time.Now().Add(connectReadTimeout)) reader := bufio.NewReader(clientConn) req, err := http.ReadRequest(reader) if err != nil { return } + _ = clientConn.SetReadDeadline(time.Time{}) if req.Method != http.MethodConnect { writeProxyResponse(clientConn, http.StatusMethodNotAllowed, "only CONNECT is supported") @@ -132,9 +142,11 @@ func (ps *proxyServer) handleConn(clientConn net.Conn) { Certificates: []tls.Certificate{leaf}, MinVersion: tls.VersionTLS12, }) + _ = tlsConn.SetDeadline(time.Now().Add(tlsHandshakeTimeout)) if err := tlsConn.Handshake(); err != nil { return } + _ = tlsConn.SetDeadline(time.Time{}) defer tlsConn.Close() ps.serveTunnel(tlsConn, hostname, port, jwt, scope) @@ -145,6 +157,8 @@ func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string tlsReader := bufio.NewReader(tlsConn) for { + // Reap a tunnel that sits idle (or dribbles headers) between requests. + _ = tlsConn.SetReadDeadline(time.Now().Add(idleTunnelTimeout)) req, err := http.ReadRequest(tlsReader) if err != nil { if !errors.Is(err, io.EOF) { @@ -152,10 +166,16 @@ func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string } return } + // Clear the deadline for the forward: the upstream response may legitimately stream for a while. + _ = tlsConn.SetReadDeadline(time.Time{}) resp, err := ps.forward(req, hostname, port, jwt, scope) if err != nil { - writeHTTPError(tlsConn, http.StatusBadGateway, err.Error()) + if errors.Is(err, errHostBlocked) { + writeHTTPError(tlsConn, http.StatusForbidden, err.Error()) + } else { + writeHTTPError(tlsConn, http.StatusBadGateway, err.Error()) + } return } @@ -176,7 +196,7 @@ func (ps *proxyServer) forward(req *http.Request, hostname, port, jwt string, sc svc := bestMatch(services, hostname, port, req.URL.Path) if svc == nil && ps.opts.UnmatchedHost == UnmatchedBlock { - return nil, fmt.Errorf("host %q has no matching proxied service (blocked by policy)", hostname) + return nil, fmt.Errorf("host %q has no matching proxied service: %w", hostname, errHostBlocked) } // rebuild the outbound request targeting the real upstream diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index e5d81fb7..0547db20 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -11,6 +11,7 @@ import ( "syscall" "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" @@ -263,8 +264,10 @@ func buildAgentEnv(proxy, caPath, jwt string, placeholders map[string]string, re env[k] = caPath } - // Infisical CLI access from within the agent + // Infisical CLI access from within the agent. Pass the domain (without the /api suffix the child + // re-appends) so infisical commands run inside the agent target the same instance. env["INFISICAL_TOKEN"] = jwt + env[util.INFISICAL_DOMAIN_ENV_NAME] = strings.TrimSuffix(config.INFISICAL_URL, "/api") // placeholders (credential-substitution services) for k, v := range placeholders { @@ -322,7 +325,7 @@ func runAgentProcess(args, env []string) error { func init() { agentProxyConnectCmd.Flags().String("proxy", "", "address of the agent proxy (host:port)") - agentProxyConnectCmd.Flags().StringP("env", "e", "dev", "environment slug to fetch proxied services and secrets from") + agentProxyConnectCmd.Flags().StringP("env", "e", "", "environment slug to fetch proxied services and secrets from") agentProxyConnectCmd.Flags().String("path", "/", "secret path (folder) scope") agentProxyConnectCmd.Flags().String("projectId", "", "project id (falls back to INFISICAL_PROJECT_ID or .infisical.json)") agentProxyConnectCmd.Flags().String("client-id", "", "universal auth client id for the agent machine identity") diff --git a/packages/cmd/agent_proxy_start.go b/packages/cmd/agent_proxy_start.go index d67e33bc..8a54aad1 100644 --- a/packages/cmd/agent_proxy_start.go +++ b/packages/cmd/agent_proxy_start.go @@ -2,12 +2,12 @@ package cmd import ( "fmt" + "sync/atomic" "time" "github.com/Infisical/infisical-merge/packages/agentproxy" "github.com/Infisical/infisical-merge/packages/util" "github.com/fatih/color" - "github.com/go-resty/resty/v2" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) @@ -37,16 +37,41 @@ func runAgentProxyStart(cmd *cobra.Command, args []string) { log.Info().Msg(color.GreenString("Agent proxy authenticated; starting MITM proxy")) - caClient := resty.New().SetAuthToken(loginResp.AccessToken) + // The proxy MI's access token has a TTL. Re-authenticate before it expires and publish the new + // token atomically, otherwise every secret fetch and CA signing call would start failing with 401. + var proxyToken atomic.Value + proxyToken.Store(loginResp.AccessToken) + go refreshProxyToken(&proxyToken, clientID, clientSecret, loginResp.AccessTokenTTL) err = agentproxy.Start(agentproxy.Options{ Port: port, UnmatchedHost: unmatchedHost, PollInterval: time.Duration(pollInterval) * time.Second, - ProxyToken: loginResp.AccessToken, - CaHTTPClient: caClient, + ProxyToken: func() string { return proxyToken.Load().(string) }, }) if err != nil { util.HandleError(err, "Agent proxy failed") } } + +// refreshProxyToken re-authenticates the agent proxy MI before its token expires, storing the new +// token for the proxy to pick up. Re-login is idempotent and always yields a fresh, valid token. +func refreshProxyToken(token *atomic.Value, clientID, clientSecret string, ttlSeconds int) { + for { + wait := time.Duration(ttlSeconds) * time.Second / 2 + if wait < 30*time.Second { + wait = 30 * time.Second + } + time.Sleep(wait) + + loginResp, err := util.UniversalAuthLogin(clientID, clientSecret) + if err != nil { + log.Warn().Msgf("Failed to refresh agent proxy token, will retry: %v", err) + continue + } + token.Store(loginResp.AccessToken) + if loginResp.AccessTokenTTL > 0 { + ttlSeconds = loginResp.AccessTokenTTL + } + } +} From b114ad4543e7c91ff4e6d7dce92cd8c3d49e2394 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:12:27 +0530 Subject: [PATCH 03/26] docs: note substring-replacement behavior in credential substitution --- packages/agentproxy/rewrite.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/agentproxy/rewrite.go b/packages/agentproxy/rewrite.go index 6d17dc7f..b3670edd 100644 --- a/packages/agentproxy/rewrite.go +++ b/packages/agentproxy/rewrite.go @@ -72,7 +72,10 @@ func hasSurface(surfaces []string, target string) bool { } // applySubstitution replaces the placeholder value with the real credential across the -// configured surfaces (header/path/query/body). +// configured surfaces (header/path/query/body). Replacement is a plain substring ReplaceAll, so +// every occurrence is swapped, including where the placeholder is a prefix/substring of a longer +// token. This is safe in practice because placeholders are distinctive random strings +// (see genPlaceholder), but callers must not use short/common placeholder values. func applySubstitution(req *http.Request, cred resolvedCredential) error { placeholder := cred.placeholder if placeholder == "" { From f87937d44f8d0434edcf461dcf2e5d4105dafafc Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:05:58 +0530 Subject: [PATCH 04/26] refactor: address review feedback for agent proxy - register --token flag so INFISICAL_TOKEN auth works in connect - strip machine identity credentials from the agent environment - retry proxy token refresh on failure instead of waiting a full cycle - standardize on zerolog; name cert lifetime constants - cap leaf cert expiry at the intermediate; close re-sign cache race - exit accept loop on listener close; handle IPv6 CONNECT targets - strip hop-by-hop headers; drain request body on keep-alive tunnels - tiered host-pattern precedence matching the design doc --- packages/agentproxy/ca.go | 32 +++++++++-- packages/agentproxy/cache.go | 10 ++-- packages/agentproxy/match.go | 61 ++++++++++++++------- packages/agentproxy/match_test.go | 18 +++++++ packages/agentproxy/proxy.go | 88 +++++++++++++++++++++++++++---- packages/agentproxy/proxy_test.go | 40 ++++++++++++++ packages/agentproxy/rewrite.go | 3 ++ packages/cmd/agent_proxy.go | 22 ++++++-- packages/cmd/agent_proxy_start.go | 19 +++++-- 9 files changed, 245 insertions(+), 48 deletions(-) create mode 100644 packages/agentproxy/proxy_test.go diff --git a/packages/agentproxy/ca.go b/packages/agentproxy/ca.go index 962c9cf6..a07b664f 100644 --- a/packages/agentproxy/ca.go +++ b/packages/agentproxy/ca.go @@ -12,12 +12,21 @@ import ( "math/big" "net" "sync" + "sync/atomic" "time" "github.com/Infisical/infisical-merge/packages/api" "github.com/go-resty/resty/v2" ) +const ( + // The backend signs the intermediate with a 7-day TTL; renewing below this threshold keeps + // the intermediate (and every leaf capped to it) comfortably inside that window. + intermediateRenewThreshold = 12 * time.Hour + leafTTL = 24 * time.Hour // lifetime of a minted leaf certificate + leafReuseMargin = 1 * time.Hour // minimum remaining lifetime to reuse a cached leaf +) + // caManager holds the intermediate CA (signed by the org root CA in Infisical) and mints // short-lived leaf certificates per upstream hostname, presenting the leaf+intermediate chain. type caManager struct { @@ -28,6 +37,10 @@ type caManager struct { intermediateKey *ecdsa.PrivateKey intermediateCert *x509.Certificate intermediateExp time.Time + // resignGen increments (under mu) every time a new intermediate is installed. Leaf minters + // snapshot it alongside the intermediate and only cache a leaf if it is unchanged, so a leaf + // signed by an outgoing intermediate can never land in leafCache after a re-sign cleared it. + resignGen atomic.Uint64 leafMu sync.Mutex leafCache map[string]*leafEntry @@ -51,7 +64,7 @@ func (c *caManager) ensureIntermediate() error { c.mu.Lock() defer c.mu.Unlock() - if c.intermediateCert != nil && time.Until(c.intermediateExp) > 12*time.Hour { + if c.intermediateCert != nil && time.Until(c.intermediateExp) > intermediateRenewThreshold { return nil } @@ -86,6 +99,7 @@ func (c *caManager) ensureIntermediate() error { c.intermediateKey = key c.intermediateCert = cert c.intermediateExp = cert.NotAfter + c.resignGen.Add(1) // new intermediate invalidates cached leaves (they chain to the old one) c.leafMu.Lock() c.leafCache = make(map[string]*leafEntry) @@ -102,7 +116,7 @@ func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { } c.leafMu.Lock() - if entry, ok := c.leafCache[hostname]; ok && time.Until(entry.expiration) > time.Hour { + if entry, ok := c.leafCache[hostname]; ok && time.Until(entry.expiration) > leafReuseMargin { c.leafMu.Unlock() return entry.cert, nil } @@ -111,6 +125,7 @@ func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { c.mu.Lock() interKey := c.intermediateKey interCert := c.intermediateCert + gen := c.resignGen.Load() c.mu.Unlock() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -123,7 +138,11 @@ func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { return tls.Certificate{}, err } - notAfter := time.Now().Add(24 * time.Hour) + notAfter := time.Now().Add(leafTTL) + // a leaf must never outlive its issuer, or clients would reject the chain near intermediate expiry + if notAfter.After(interCert.NotAfter) { + notAfter = interCert.NotAfter + } template := &x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{CommonName: hostname}, @@ -149,8 +168,13 @@ func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { PrivateKey: key, } + // Only cache if no re-sign happened since the intermediate was snapshotted: a re-sign clears + // leafCache, and caching a leaf chained to the outgoing intermediate would resurrect stale + // state. Serving this leaf directly is still fine; it is valid until the old chain expires. c.leafMu.Lock() - c.leafCache[hostname] = &leafEntry{cert: cert, expiration: notAfter} + if c.resignGen.Load() == gen { + c.leafCache[hostname] = &leafEntry{cert: cert, expiration: notAfter} + } c.leafMu.Unlock() return cert, nil diff --git a/packages/agentproxy/cache.go b/packages/agentproxy/cache.go index 9b3cd3f4..1894d251 100644 --- a/packages/agentproxy/cache.go +++ b/packages/agentproxy/cache.go @@ -11,6 +11,7 @@ import ( "github.com/Infisical/infisical-merge/packages/models" "github.com/Infisical/infisical-merge/packages/util" "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" ) // isAuthError reports whether an error from an Infisical call is a hard auth failure (401/403), @@ -53,7 +54,6 @@ type agentEntry struct { scope agentScope services []*resolvedService lastSeen time.Time - cachedAt time.Time } // cacheKey scopes cached entries by both the agent JWT and the requested scope, so an agent @@ -106,7 +106,6 @@ func (a *agentCache) get(jwt string, scope agentScope) ([]*resolvedService, erro scope: scope, services: resolved, lastSeen: time.Now(), - cachedAt: time.Now(), } a.mu.Lock() a.entries[key] = entry @@ -146,7 +145,7 @@ func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService, value, ok := secretValues[cred.SecretKey] if !ok { // stale reference: secret renamed/deleted after the service was created - util.PrintWarning(fmt.Sprintf("proxied service %q references missing secret %q; skipping", svc.Name, cred.SecretKey)) + log.Warn().Msgf("proxied service %q references missing secret %q; skipping", svc.Name, cred.SecretKey) continue } rs.credentials = append(rs.credentials, resolvedCredential{ @@ -207,19 +206,18 @@ func (a *agentCache) refreshActive() { // A hard auth failure means the agent's JWT was revoked or expired: evict so we stop // serving its cached credentials (fail closed). Transient errors keep the existing cache. if isAuthError(err) { - util.PrintWarning(fmt.Sprintf("agent authorization no longer valid; dropping cached credentials: %v", err)) + log.Warn().Err(err).Msg("agent authorization no longer valid; dropping cached credentials") a.mu.Lock() delete(a.entries, t.key) a.mu.Unlock() continue } - util.PrintWarning(fmt.Sprintf("failed to refresh agent cache: %v", err)) + log.Warn().Err(err).Msg("failed to refresh agent cache") continue } a.mu.Lock() if entry, ok := a.entries[t.key]; ok { entry.services = resolved - entry.cachedAt = time.Now() } a.mu.Unlock() } diff --git a/packages/agentproxy/match.go b/packages/agentproxy/match.go index d3ffccec..7126d5f7 100644 --- a/packages/agentproxy/match.go +++ b/packages/agentproxy/match.go @@ -36,10 +36,29 @@ func parseHostPatterns(raw string) []hostPattern { return patterns } -// matchScore returns (matched, score). Higher score = more specific match. -// Scoring: exact host (2) vs wildcard (1); specific port (+2) vs any (0); + path prefix length. -func (p hostPattern) matchScore(host, port, path string) (bool, int) { - score := 0 +// matchDetail records how specifically a pattern matched, for tiered precedence comparison. +type matchDetail struct { + exactHost bool // exact host match (vs wildcard) + specificPort bool // pattern pinned a port (vs any-port) + pathLen int // length of the matched path prefix +} + +// betterThan reports whether m is strictly more specific than o, per the documented precedence: +// 1. exact host beats wildcard; 2. specific port beats any-port; 3. longest path prefix. +// Each tier is only consulted when all higher tiers are equal. +func (m matchDetail) betterThan(o matchDetail) bool { + if m.exactHost != o.exactHost { + return m.exactHost + } + if m.specificPort != o.specificPort { + return m.specificPort + } + return m.pathLen > o.pathLen +} + +// match reports whether the pattern matches the target and, if so, how specifically. +func (p hostPattern) match(host, port, path string) (bool, matchDetail) { + detail := matchDetail{} // Hostnames are case-insensitive; fold both sides so wildcard matching agrees with exact matching. host = strings.ToLower(host) @@ -49,53 +68,55 @@ func (p hostPattern) matchScore(host, port, path string) (bool, int) { suffix := patternHost[1:] // ".github.com" // wildcard matches exactly one extra label: api.github.com yes, a.b.github.com no if !strings.HasSuffix(host, suffix) { - return false, 0 + return false, detail } prefix := strings.TrimSuffix(host, suffix) if prefix == "" || strings.Contains(prefix, ".") { - return false, 0 + return false, detail } - score += 1 } else { if patternHost != host { - return false, 0 + return false, detail } - score += 2 + detail.exactHost = true } if p.port != "" { if p.port != port { - return false, 0 + return false, detail } - score += 2 + detail.specificPort = true } if p.path != "" { prefix := strings.TrimSuffix(p.path, "*") if !strings.HasPrefix(path, prefix) { - return false, 0 + return false, detail } - score += len(prefix) + detail.pathLen = len(prefix) } - return true, score + return true, detail } -// bestMatch picks the highest-scoring service for the target. On ties, the first service -// (definition order) wins because callers iterate services in order and use strict ">". +// bestMatch picks the most specific matching service per the tiered precedence in betterThan. +// On full ties, the first service (definition order) wins because betterThan is strict. func bestMatch(services []*resolvedService, host, port, path string) *resolvedService { var best *resolvedService - bestScore := -1 + var bestDetail matchDetail for _, svc := range services { if !svc.isEnabled { continue } for _, pat := range svc.hostPatterns { - matched, score := pat.matchScore(host, port, path) - if matched && score > bestScore { - bestScore = score + matched, detail := pat.match(host, port, path) + if !matched { + continue + } + if best == nil || detail.betterThan(bestDetail) { best = svc + bestDetail = detail } } } diff --git a/packages/agentproxy/match_test.go b/packages/agentproxy/match_test.go index 640668ea..52f1dba4 100644 --- a/packages/agentproxy/match_test.go +++ b/packages/agentproxy/match_test.go @@ -39,6 +39,24 @@ func TestWildcardSingleLabelOnly(t *testing.T) { } } +func TestExactHostBeatsWildcardWithMatchingPort(t *testing.T) { + exact := svc("exact", "api.github.com") + wildcardWithPort := svc("wildcardWithPort", "*.github.com:443") + got := bestMatch([]*resolvedService{wildcardWithPort, exact}, "api.github.com", "443", "/") + if got == nil || got.name != "exact" { + t.Fatalf("expected exact host to beat wildcard host with matching port, got %v", got) + } +} + +func TestExactHostBeatsWildcardWithLongerPath(t *testing.T) { + exact := svc("exact", "api.github.com") + wildcardWithPath := svc("wildcardWithPath", "*.github.com/v1/*") + got := bestMatch([]*resolvedService{wildcardWithPath, exact}, "api.github.com", "443", "/v1/repos") + if got == nil || got.name != "exact" { + t.Fatalf("expected exact host to beat wildcard host with longer path, got %v", got) + } +} + func TestPortMatching(t *testing.T) { withPort := svc("withPort", "internal.corp.com:3000") if got := bestMatch([]*resolvedService{withPort}, "internal.corp.com", "3000", "/"); got == nil { diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index 1d31c757..8b94b5a0 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -66,6 +66,13 @@ func Start(opts Options) error { go ps.pollLoop() + // Pre-flight: fail fast if something already listens on this port. A plain net.Listen(":port") + // can bind one address family (e.g. IPv6) while another process holds the other (e.g. a process + // on IPv4 127.0.0.1), which would silently split traffic, so probe both loopback families first. + if addr := portInUse(opts.Port); addr != "" { + return fmt.Errorf("port %d is already in use (%s); another process is listening. Choose a free port with --port", opts.Port, addr) + } + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", opts.Port)) if err != nil { return fmt.Errorf("failed to listen on port %d: %w", opts.Port, err) @@ -75,13 +82,32 @@ func Start(opts Options) error { for { conn, err := listener.Accept() if err != nil { + if errors.Is(err, net.ErrClosed) { + log.Info().Msg("agent proxy listener closed; shutting down") + return nil + } log.Warn().Err(err).Msg("failed to accept connection") + // back off briefly so a persistent accept error doesn't hot-spin the loop + time.Sleep(100 * time.Millisecond) continue } go ps.handleConn(conn) } } +// portInUse reports the first loopback address that already has a listener on the port, or "". +// Probing both families catches the dual-stack split that a bare net.Listen would miss. +func portInUse(port int) string { + for _, addr := range []string{fmt.Sprintf("127.0.0.1:%d", port), fmt.Sprintf("[::1]:%d", port)} { + conn, err := net.DialTimeout("tcp", addr, 300*time.Millisecond) + if err == nil { + _ = conn.Close() + return addr + } + } + return "" +} + func (ps *proxyServer) pollLoop() { interval := ps.opts.PollInterval if interval <= 0 { @@ -117,14 +143,10 @@ func (ps *proxyServer) handleConn(clientConn net.Conn) { return } - targetHost := req.Host - if !strings.Contains(targetHost, ":") { - targetHost += ":443" - } - hostname, port, err := net.SplitHostPort(targetHost) + hostname, port, err := parseConnectTarget(req.Host) if err != nil { - hostname = strings.Split(targetHost, ":")[0] - port = "443" + writeProxyResponse(clientConn, http.StatusBadRequest, fmt.Sprintf("invalid CONNECT target %q", req.Host)) + return } // leaf is minted for the exact CONNECT hostname (design: CONNECT host is source of truth) @@ -152,6 +174,21 @@ func (ps *proxyServer) handleConn(clientConn net.Conn) { ps.serveTunnel(tlsConn, hostname, port, jwt, scope) } +// parseConnectTarget splits a CONNECT authority-form target into hostname and port, defaulting +// the port to 443. Bracketed IPv6 literals ("[::1]", "[::1]:8443") are handled by SplitHostPort; +// anything it cannot parse even with the default port appended is rejected. +func parseConnectTarget(target string) (hostname, port string, err error) { + hostname, port, err = net.SplitHostPort(target) + if err == nil { + return hostname, port, nil + } + var addrErr *net.AddrError + if errors.As(err, &addrErr) && strings.Contains(addrErr.Err, "missing port") { + return net.SplitHostPort(target + ":443") + } + return "", "", err +} + // serveTunnel reads requests off the terminated TLS connection, applies credentials, and forwards them. func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string, scope agentScope) { tlsReader := bufio.NewReader(tlsConn) @@ -184,6 +221,13 @@ func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string return } resp.Body.Close() + + // Drain whatever the transport did not consume of the request body so leftover bytes + // cannot desync the next http.ReadRequest on this keep-alive tunnel. + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + _ = req.Body.Close() + } } } @@ -210,12 +254,38 @@ func (ps *proxyServer) forward(req *http.Request, hostname, port, jwt string, sc } } - req.Header.Del("Proxy-Authorization") - req.Header.Del("Proxy-Connection") + stripHopByHopHeaders(req.Header) return ps.transport.RoundTrip(req) } +// hopByHopHeaders are the standard hop-by-hop headers removed before forwarding, +// mirroring net/http/httputil.ReverseProxy. +var hopByHopHeaders = []string{ + "Connection", + "Proxy-Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "TE", + "Trailer", + "Transfer-Encoding", + "Upgrade", +} + +// stripHopByHopHeaders removes the headers named in the Connection header plus the standard +// hop-by-hop set; they describe this hop's connection and must not be forwarded upstream. +func stripHopByHopHeaders(h http.Header) { + for _, name := range strings.Split(h.Get("Connection"), ",") { + if name = strings.TrimSpace(name); name != "" { + h.Del(name) + } + } + for _, name := range hopByHopHeaders { + h.Del(name) + } +} + // parseProxyAuth decodes the Proxy-Authorization Basic header into scope + agent JWT. // userinfo layout: username = projectId, password = "/:" (jwt last). func parseProxyAuth(header string) (agentScope, string, bool) { diff --git a/packages/agentproxy/proxy_test.go b/packages/agentproxy/proxy_test.go new file mode 100644 index 00000000..41cfa578 --- /dev/null +++ b/packages/agentproxy/proxy_test.go @@ -0,0 +1,40 @@ +package agentproxy + +import "testing" + +func TestParseConnectTarget(t *testing.T) { + tests := []struct { + name string + target string + hostname string + port string + wantErr bool + }{ + {name: "host with port", target: "api.stripe.com:443", hostname: "api.stripe.com", port: "443"}, + {name: "host without port defaults to 443", target: "api.stripe.com", hostname: "api.stripe.com", port: "443"}, + {name: "host with custom port", target: "internal.corp.com:3000", hostname: "internal.corp.com", port: "3000"}, + {name: "bracketed IPv6 with port", target: "[::1]:8443", hostname: "::1", port: "8443"}, + {name: "bracketed IPv6 without port defaults to 443", target: "[::1]", hostname: "::1", port: "443"}, + {name: "IPv4 with port", target: "127.0.0.1:443", hostname: "127.0.0.1", port: "443"}, + {name: "IPv4 without port defaults to 443", target: "127.0.0.1", hostname: "127.0.0.1", port: "443"}, + {name: "unbracketed IPv6 is rejected", target: "::1", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hostname, port, err := parseConnectTarget(tt.target) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for %q, got %q:%q", tt.target, hostname, port) + } + return + } + if err != nil { + t.Fatalf("unexpected error for %q: %v", tt.target, err) + } + if hostname != tt.hostname || port != tt.port { + t.Fatalf("parseConnectTarget(%q) = %q, %q; want %q, %q", tt.target, hostname, port, tt.hostname, tt.port) + } + }) + } +} diff --git a/packages/agentproxy/rewrite.go b/packages/agentproxy/rewrite.go index b3670edd..2bc71579 100644 --- a/packages/agentproxy/rewrite.go +++ b/packages/agentproxy/rewrite.go @@ -85,6 +85,9 @@ func applySubstitution(req *http.Request, cred resolvedCredential) error { if hasSurface(cred.surfaces, surfacePath) { req.URL.Path = strings.ReplaceAll(req.URL.Path, placeholder, real) + // Clearing RawPath makes Go re-encode the path from Path on the wire, which can change + // the byte representation of other escaped segments. Placeholders are distinctive random + // strings, so a placeholder cross-matching an escaped segment is not a concern. req.URL.RawPath = "" } diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index 0547db20..5c483c4f 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -29,7 +29,7 @@ var agentProxyCmd = &cobra.Command{ var agentProxyConnectCmd = &cobra.Command{ Use: "connect [flags] -- [agent start command]", Short: "Set up the environment and launch an agent behind the agent proxy", - Example: "infisical secrets agent-proxy connect --proxy=proxy:14322 --env=prod --path=/myapp -- claude", + Example: "infisical secrets agent-proxy connect --proxy=proxy:17322 --env=prod --path=/myapp -- claude", DisableFlagsInUseLine: true, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { @@ -43,7 +43,7 @@ var agentProxyConnectCmd = &cobra.Command{ var agentProxyStartCmd = &cobra.Command{ Use: "start", Short: "Start the agent proxy (MITM proxy that brokers credentials on the wire)", - Example: "infisical secrets agent-proxy start --port 14322", + Example: "infisical secrets agent-proxy start --port 17322", DisableFlagsInUseLine: true, Run: runAgentProxyStart, } @@ -72,10 +72,18 @@ var proxyEnvKeys = []string{ "OPENCLAW_PROXY_URL", } +// machine identity credential env vars stripped from the agent environment so the agent +// never sees the long-lived credentials, only the scoped short-lived JWT set below. +var credentialEnvKeys = []string{ + util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME, + util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME, + util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, +} + func runAgentProxyConnect(cmd *cobra.Command, args []string) { proxyAddr, err := cmd.Flags().GetString("proxy") if err != nil || proxyAddr == "" { - util.HandleError(fmt.Errorf("the --proxy flag is required (e.g. --proxy=proxy:14322)")) + util.HandleError(fmt.Errorf("the --proxy flag is required (e.g. --proxy=proxy:17322)")) } environment, err := cmd.Flags().GetString("env") @@ -239,11 +247,14 @@ func fetchAgentRealSecrets(token *models.TokenDetails, projectID, environment, s } func buildAgentEnv(proxy, caPath, jwt string, placeholders map[string]string, realSecrets []models.SingleEnvironmentVariable) []string { - // start from the current environment, stripping any stale proxy keys + // start from the current environment, stripping stale proxy keys and machine identity credentials stale := map[string]bool{} for _, k := range proxyEnvKeys { stale[k] = true } + for _, k := range credentialEnvKeys { + stale[k] = true + } env := map[string]string{} for _, kv := range os.Environ() { parts := strings.SplitN(kv, "=", 2) @@ -330,8 +341,9 @@ func init() { agentProxyConnectCmd.Flags().String("projectId", "", "project id (falls back to INFISICAL_PROJECT_ID or .infisical.json)") agentProxyConnectCmd.Flags().String("client-id", "", "universal auth client id for the agent machine identity") agentProxyConnectCmd.Flags().String("client-secret", "", "universal auth client secret for the agent machine identity") + agentProxyConnectCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") - agentProxyStartCmd.Flags().Int("port", 14322, "port for the agent proxy to listen on") + agentProxyStartCmd.Flags().Int("port", 17322, "port for the agent proxy to listen on") agentProxyStartCmd.Flags().String("unmatched-host", "allow", "policy for hosts with no proxied service: allow | block") agentProxyStartCmd.Flags().Int("poll-interval", 60, "seconds between permission/credential refreshes for active agents") agentProxyStartCmd.Flags().String("client-id", "", "universal auth client id for the agent proxy machine identity") diff --git a/packages/cmd/agent_proxy_start.go b/packages/cmd/agent_proxy_start.go index 8a54aad1..0249c34d 100644 --- a/packages/cmd/agent_proxy_start.go +++ b/packages/cmd/agent_proxy_start.go @@ -56,22 +56,33 @@ func runAgentProxyStart(cmd *cobra.Command, args []string) { // refreshProxyToken re-authenticates the agent proxy MI before its token expires, storing the new // token for the proxy to pick up. Re-login is idempotent and always yields a fresh, valid token. +// A failed refresh is retried on a short fixed interval so a transient outage cannot leave the +// proxy holding an expired token for another half TTL. func refreshProxyToken(token *atomic.Value, clientID, clientSecret string, ttlSeconds int) { - for { + const retryInterval = 30 * time.Second + + halfTTL := func() time.Duration { wait := time.Duration(ttlSeconds) * time.Second / 2 - if wait < 30*time.Second { - wait = 30 * time.Second + if wait < retryInterval { + wait = retryInterval } + return wait + } + + wait := halfTTL() + for { time.Sleep(wait) loginResp, err := util.UniversalAuthLogin(clientID, clientSecret) if err != nil { - log.Warn().Msgf("Failed to refresh agent proxy token, will retry: %v", err) + log.Warn().Err(err).Msgf("Failed to refresh agent proxy token, retrying in %s", retryInterval) + wait = retryInterval continue } token.Store(loginResp.AccessToken) if loginResp.AccessTokenTTL > 0 { ttlSeconds = loginResp.AccessTokenTTL } + wait = halfTTL() } } From 491d4ff022db789eb0bc3f65f0cb66f7bca49b04 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:21:27 +0530 Subject: [PATCH 05/26] fix: fall back to still-valid intermediate CA when re-sign fails --- packages/agentproxy/ca.go | 46 +++++++++++++++--- packages/agentproxy/ca_test.go | 89 ++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+), 8 deletions(-) create mode 100644 packages/agentproxy/ca_test.go diff --git a/packages/agentproxy/ca.go b/packages/agentproxy/ca.go index a07b664f..3371285b 100644 --- a/packages/agentproxy/ca.go +++ b/packages/agentproxy/ca.go @@ -17,14 +17,22 @@ import ( "github.com/Infisical/infisical-merge/packages/api" "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" ) const ( // The backend signs the intermediate with a 7-day TTL; renewing below this threshold keeps // the intermediate (and every leaf capped to it) comfortably inside that window. intermediateRenewThreshold = 12 * time.Hour - leafTTL = 24 * time.Hour // lifetime of a minted leaf certificate - leafReuseMargin = 1 * time.Hour // minimum remaining lifetime to reuse a cached leaf + // Below the renew threshold, a failed re-sign falls back to the current intermediate as long + // as it stays valid past this margin, so a transient Infisical outage inside the renewal + // window degrades gracefully instead of failing every mint (including cached-leaf hostnames). + intermediateFallbackMargin = 5 * time.Minute + // Minimum time between re-sign attempts while falling back; mints are serialized on c.mu, + // so retrying on every request would both hammer the sign endpoint and block all minting. + intermediateRetryInterval = 30 * time.Second + leafTTL = 24 * time.Hour // lifetime of a minted leaf certificate + leafReuseMargin = 1 * time.Hour // minimum remaining lifetime to reuse a cached leaf ) // caManager holds the intermediate CA (signed by the org root CA in Infisical) and mints @@ -33,10 +41,11 @@ type caManager struct { // token returns the proxy MI's current access token, refreshed by the caller. token func() string - mu sync.Mutex - intermediateKey *ecdsa.PrivateKey - intermediateCert *x509.Certificate - intermediateExp time.Time + mu sync.Mutex + intermediateKey *ecdsa.PrivateKey + intermediateCert *x509.Certificate + intermediateExp time.Time + lastResignAttempt time.Time // throttles re-sign retries while falling back // resignGen increments (under mu) every time a new intermediate is installed. Leaf minters // snapshot it alongside the intermediate and only cache a leaf if it is unchanged, so a leaf // signed by an outgoing intermediate can never land in leafCache after a re-sign cleared it. @@ -59,15 +68,36 @@ func newCaManager(token func() string) *caManager { } // ensureIntermediate lazily generates an intermediate keypair and has Infisical sign it, -// re-signing when it is near expiry. +// re-signing when it is near expiry. A failed re-sign is not fatal while the current +// intermediate remains usable: minting continues on it and the re-sign is retried later. func (c *caManager) ensureIntermediate() error { c.mu.Lock() defer c.mu.Unlock() - if c.intermediateCert != nil && time.Until(c.intermediateExp) > intermediateRenewThreshold { + remaining := time.Until(c.intermediateExp) + if c.intermediateCert != nil && remaining > intermediateRenewThreshold { return nil } + canFallBack := c.intermediateCert != nil && remaining > intermediateFallbackMargin + if canFallBack && time.Since(c.lastResignAttempt) < intermediateRetryInterval { + return nil + } + c.lastResignAttempt = time.Now() + + if err := c.resignIntermediateLocked(); err != nil { + if canFallBack { + log.Warn().Err(err).Msg("failed to renew the intermediate CA; continuing with the current one until it nears expiry") + return nil + } + return err + } + return nil +} + +// resignIntermediateLocked generates a fresh keypair, has Infisical sign it with the org root +// CA, and installs it. Caller must hold c.mu. +func (c *caManager) resignIntermediateLocked() error { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return fmt.Errorf("failed to generate intermediate CA key: %w", err) diff --git a/packages/agentproxy/ca_test.go b/packages/agentproxy/ca_test.go new file mode 100644 index 00000000..bbb2579a --- /dev/null +++ b/packages/agentproxy/ca_test.go @@ -0,0 +1,89 @@ +package agentproxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Infisical/infisical-merge/packages/config" +) + +// installTestIntermediate puts a self-signed CA cert into the manager as if Infisical had +// signed it, expiring at the given time. +func installTestIntermediate(t *testing.T, c *caManager, notAfter time.Time) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test intermediate"}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: notAfter, + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + c.intermediateKey = key + c.intermediateCert = cert + c.intermediateExp = cert.NotAfter +} + +// A failed re-sign inside the renewal window must fall back to the still-valid intermediate +// instead of failing every mint (a transient Infisical outage must not take the proxy down). +func TestEnsureIntermediateFallsBackWhenResignFails(t *testing.T) { + failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer failing.Close() + origURL := config.INFISICAL_URL + config.INFISICAL_URL = failing.URL + defer func() { config.INFISICAL_URL = origURL }() + + c := newCaManager(func() string { return "test-token" }) + // 1h remaining: below the 12h renew threshold (re-sign attempted), above the fallback margin + installTestIntermediate(t, c, time.Now().Add(1*time.Hour)) + + if err := c.ensureIntermediate(); err != nil { + t.Fatalf("expected fallback to the valid intermediate, got error: %v", err) + } + leaf, err := c.mintLeaf("api.example.com") + if err != nil { + t.Fatalf("expected minting to keep working on the old intermediate, got: %v", err) + } + if len(leaf.Certificate) != 2 { + t.Fatalf("expected leaf + intermediate chain, got %d certs", len(leaf.Certificate)) + } +} + +// With no usable intermediate at all, a failed sign is still a hard error. +func TestEnsureIntermediateFailsWithoutFallback(t *testing.T) { + failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + })) + defer failing.Close() + origURL := config.INFISICAL_URL + config.INFISICAL_URL = failing.URL + defer func() { config.INFISICAL_URL = origURL }() + + c := newCaManager(func() string { return "test-token" }) + if err := c.ensureIntermediate(); err == nil { + t.Fatal("expected an error when there is no intermediate to fall back to") + } +} From bd2006fe9dc268b00810441c298104ff7509f4da Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:21:27 +0530 Subject: [PATCH 06/26] feat: broker plain-HTTP upstreams via absolute-form forward proxying --- packages/agentproxy/forward_test.go | 142 ++++++++++++++++++++++++++++ packages/agentproxy/proxy.go | 112 ++++++++++++++++++---- 2 files changed, 233 insertions(+), 21 deletions(-) create mode 100644 packages/agentproxy/forward_test.go diff --git a/packages/agentproxy/forward_test.go b/packages/agentproxy/forward_test.go new file mode 100644 index 00000000..d332ebc6 --- /dev/null +++ b/packages/agentproxy/forward_test.go @@ -0,0 +1,142 @@ +package agentproxy + +import ( + "bufio" + "encoding/base64" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +func proxyAuthHeader(projectID, environment, secretPath, jwt string) string { + password := fmt.Sprintf("%s/%s:%s", environment, strings.TrimPrefix(secretPath, "/"), jwt) + return "Basic " + base64.StdEncoding.EncodeToString([]byte(projectID+":"+password)) +} + +// newTestProxy returns a proxyServer whose cache is pre-populated for jwt+scope, so no +// Infisical calls happen, plus the client side of a pipe with handleConn running on the other end. +func newTestProxy(t *testing.T, unmatchedHost, jwt string, scope agentScope, services []*resolvedService) net.Conn { + t.Helper() + cache := newAgentCache(func() string { return "" }) + cache.entries[cacheKey(jwt, scope)] = &agentEntry{ + jwt: jwt, + scope: scope, + services: services, + lastSeen: time.Now(), + } + ps := &proxyServer{ + opts: Options{UnmatchedHost: unmatchedHost}, + cache: cache, + transport: &http.Transport{}, + } + client, server := net.Pipe() + go ps.handleConn(server) + t.Cleanup(func() { _ = client.Close() }) + return client +} + +func TestPlainForwardInjectsCredentialsAndKeepsAlive(t *testing.T) { + var gotAuth []string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = append(gotAuth, r.Header.Get("Authorization")) + _, _ = io.WriteString(w, "ok") + })) + defer upstream.Close() + + u, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + hostname := u.Hostname() + + jwt := "test.jwt.token" + scope := agentScope{projectID: "proj", environment: "prod", secretPath: "/"} + services := []*resolvedService{{ + name: "internal", + hostPatterns: parseHostPatterns(hostname), + isEnabled: true, + credentials: []resolvedCredential{ + {role: roleHeaderRewrite, headerName: "Authorization", headerPrefix: "Bearer", value: "real_secret"}, + }, + }} + client := newTestProxy(t, UnmatchedAllow, jwt, scope, services) + reader := bufio.NewReader(client) + + // two sequential requests exercise plain-path keep-alive + for i := 0; i < 2; i++ { + _, err := fmt.Fprintf(client, "GET http://%s/hello HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: %s\r\n\r\n", + u.Host, u.Host, proxyAuthHeader("proj", "prod", "/", jwt)) + if err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(reader, nil) + if err != nil { + t.Fatalf("request %d: %v", i, err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("request %d: unexpected status %d", i, resp.StatusCode) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + } + + if len(gotAuth) != 2 { + t.Fatalf("expected 2 upstream requests, got %d", len(gotAuth)) + } + for i, auth := range gotAuth { + if auth != "Bearer real_secret" { + t.Fatalf("request %d: credential not injected, Authorization = %q", i, auth) + } + } +} + +func TestPlainForwardRejectsHTTPSAbsoluteForm(t *testing.T) { + scope := agentScope{projectID: "proj", environment: "prod", secretPath: "/"} + client := newTestProxy(t, UnmatchedAllow, "jwt", scope, nil) + + fmt.Fprintf(client, "GET https://example.com/ HTTP/1.1\r\nHost: example.com\r\nProxy-Authorization: %s\r\n\r\n", + proxyAuthHeader("proj", "prod", "/", "jwt")) + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("expected 400 for https:// absolute-form (no TLS-strip), got %d", resp.StatusCode) + } +} + +func TestPlainForwardRequiresProxyAuth(t *testing.T) { + scope := agentScope{projectID: "proj", environment: "prod", secretPath: "/"} + client := newTestProxy(t, UnmatchedAllow, "jwt", scope, nil) + + fmt.Fprintf(client, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\n\r\n") + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusProxyAuthRequired { + t.Fatalf("expected 407 without Proxy-Authorization, got %d", resp.StatusCode) + } +} + +func TestPlainForwardBlocksUnmatchedHost(t *testing.T) { + jwt := "test.jwt.token" + scope := agentScope{projectID: "proj", environment: "prod", secretPath: "/"} + client := newTestProxy(t, UnmatchedBlock, jwt, scope, nil) + + fmt.Fprintf(client, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nProxy-Authorization: %s\r\n\r\n", + proxyAuthHeader("proj", "prod", "/", jwt)) + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("expected 403 in block mode for an unmatched host, got %d", resp.StatusCode) + } +} diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index 8b94b5a0..59fe200d 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -123,23 +123,35 @@ func (ps *proxyServer) pollLoop() { func (ps *proxyServer) handleConn(clientConn net.Conn) { defer clientConn.Close() - // Bound the time a client may take to send its CONNECT so a stalled connection can't pin a goroutine. - _ = clientConn.SetReadDeadline(time.Now().Add(connectReadTimeout)) reader := bufio.NewReader(clientConn) - req, err := http.ReadRequest(reader) - if err != nil { - return - } - _ = clientConn.SetReadDeadline(time.Time{}) + // Bound the time a client may take to send its first request so a stalled connection can't + // pin a goroutine; subsequent keep-alive requests on the plain-HTTP path get the idle timeout. + readTimeout := connectReadTimeout + for { + _ = clientConn.SetReadDeadline(time.Now().Add(readTimeout)) + req, err := http.ReadRequest(reader) + if err != nil { + return + } + _ = clientConn.SetReadDeadline(time.Time{}) - if req.Method != http.MethodConnect { - writeProxyResponse(clientConn, http.StatusMethodNotAllowed, "only CONNECT is supported") - return + if req.Method == http.MethodConnect { + ps.handleConnect(clientConn, req) + return + } + if !ps.handlePlainForward(clientConn, req) { + return + } + readTimeout = idleTunnelTimeout } +} +// handleConnect serves a CONNECT request: authenticate, mint a leaf for the target hostname, +// complete the MITM TLS handshake, and serve tunnelled requests until the connection ends. +func (ps *proxyServer) handleConnect(clientConn net.Conn, req *http.Request) { scope, jwt, ok := parseProxyAuth(req.Header.Get("Proxy-Authorization")) if !ok { - clientConn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\n\r\n")) // #nosec G104 + writeProxyAuthRequired(clientConn) return } @@ -174,6 +186,57 @@ func (ps *proxyServer) handleConn(clientConn net.Conn) { ps.serveTunnel(tlsConn, hostname, port, jwt, scope) } +// handlePlainForward serves an absolute-form forward-proxy request (RFC 7230 §5.3.2) for a +// plain-HTTP upstream, applying the same auth, matching, and credential pipeline as the CONNECT +// path — there is just no TLS layer to intercept, and the agent JWT arrives on every request +// rather than once per tunnel. Only http:// is served: https:// absolute-form is rejected so +// the proxy can never be used to silently TLS-strip (HTTPS upstreams must arrive as CONNECT), +// and origin-form is rejected so the proxy ingress cannot be used as an origin server. +// It reports whether the connection may serve another keep-alive request. +func (ps *proxyServer) handlePlainForward(clientConn net.Conn, req *http.Request) bool { + if !strings.EqualFold(req.URL.Scheme, "http") || req.URL.Host == "" { + writeHTTPError(clientConn, http.StatusBadRequest, "non-CONNECT requests must be absolute-form http:// (use CONNECT for https:// upstreams)") + return false + } + + scope, jwt, ok := parseProxyAuth(req.Header.Get("Proxy-Authorization")) + if !ok { + writeProxyAuthRequired(clientConn) + return false + } + + // URL.Hostname/Port handle bracketed IPv6 literals; default the port for the http scheme. + // Per RFC 7230 §5.4 the absolute-form URL is authoritative for routing (the Host header is + // not consulted; Go's ReadRequest already promotes the URL host into req.Host). + hostname := req.URL.Hostname() + port := req.URL.Port() + if port == "" { + port = "80" + } + if req.URL.Path == "" { + req.URL.Path = "/" + } + + resp, err := ps.forward(req, "http", hostname, port, jwt, scope) + if err != nil { + status := http.StatusBadGateway + if errors.Is(err, errHostBlocked) { + status = http.StatusForbidden + } + writeHTTPError(clientConn, status, err.Error()) + return false + } + + if err := resp.Write(clientConn); err != nil { + resp.Body.Close() + return false + } + resp.Body.Close() + drainRequestBody(req) + + return !req.Close && !resp.Close +} + // parseConnectTarget splits a CONNECT authority-form target into hostname and port, defaulting // the port to 443. Bracketed IPv6 literals ("[::1]", "[::1]:8443") are handled by SplitHostPort; // anything it cannot parse even with the default port appended is rejected. @@ -206,7 +269,7 @@ func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string // Clear the deadline for the forward: the upstream response may legitimately stream for a while. _ = tlsConn.SetReadDeadline(time.Time{}) - resp, err := ps.forward(req, hostname, port, jwt, scope) + resp, err := ps.forward(req, "https", hostname, port, jwt, scope) if err != nil { if errors.Is(err, errHostBlocked) { writeHTTPError(tlsConn, http.StatusForbidden, err.Error()) @@ -221,17 +284,11 @@ func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string return } resp.Body.Close() - - // Drain whatever the transport did not consume of the request body so leftover bytes - // cannot desync the next http.ReadRequest on this keep-alive tunnel. - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - _ = req.Body.Close() - } + drainRequestBody(req) } } -func (ps *proxyServer) forward(req *http.Request, hostname, port, jwt string, scope agentScope) (*http.Response, error) { +func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt string, scope agentScope) (*http.Response, error) { services, err := ps.cache.get(jwt, scope) if err != nil { return nil, fmt.Errorf("failed to resolve agent permissions: %w", err) @@ -244,7 +301,7 @@ func (ps *proxyServer) forward(req *http.Request, hostname, port, jwt string, sc } // rebuild the outbound request targeting the real upstream - req.URL.Scheme = "https" + req.URL.Scheme = scheme req.URL.Host = net.JoinHostPort(hostname, port) req.RequestURI = "" @@ -332,6 +389,19 @@ func parseProxyAuth(header string) (agentScope, string, bool) { return agentScope{projectID: projectID, environment: environment, secretPath: secretPath}, jwt, true } +// drainRequestBody consumes whatever the transport did not read of the request body so leftover +// bytes cannot desync the next http.ReadRequest on a keep-alive connection. +func drainRequestBody(req *http.Request) { + if req.Body != nil { + _, _ = io.Copy(io.Discard, req.Body) + _ = req.Body.Close() + } +} + +func writeProxyAuthRequired(conn net.Conn) { + conn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\n\r\n")) // #nosec G104 +} + func writeProxyResponse(conn net.Conn, status int, msg string) { fmt.Fprintf(conn, "HTTP/1.1 %d %s\r\nContent-Length: %d\r\n\r\n%s", status, http.StatusText(status), len(msg), msg) // #nosec G104 } From b968e0744df1128f8707c53b7ff866392b0afd59 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:21:27 +0530 Subject: [PATCH 07/26] fix: skip placeholders of disabled proxied services in connect --- packages/cmd/agent_proxy.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index 5c483c4f..a0839ca0 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -212,7 +212,9 @@ func fetchProxiedServicePlaceholders(httpClient *resty.Client, projectID, enviro placeholders := map[string]string{} for _, svc := range resp.Services { - if !svc.CanProxy { + // the proxy applies nothing for disabled services, so their placeholders would go + // upstream verbatim; don't inject them + if !svc.CanProxy || !svc.IsEnabled { continue } for _, cred := range svc.Credentials { From 3731285500e77fcdd7397d1c25443fedb701074f Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:30:20 +0530 Subject: [PATCH 08/26] fix: break proxied service host-match ties deterministically by name --- packages/agentproxy/match.go | 18 ++++++++++++++++-- packages/agentproxy/match_test.go | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/agentproxy/match.go b/packages/agentproxy/match.go index 7126d5f7..db09f793 100644 --- a/packages/agentproxy/match.go +++ b/packages/agentproxy/match.go @@ -56,6 +56,12 @@ func (m matchDetail) betterThan(o matchDetail) bool { return m.pathLen > o.pathLen } +// equalTo reports whether two matches are equally specific across every tier, meaning neither is +// betterThan the other and the caller must fall back to a stable tiebreaker. +func (m matchDetail) equalTo(o matchDetail) bool { + return m.exactHost == o.exactHost && m.specificPort == o.specificPort && m.pathLen == o.pathLen +} + // match reports whether the pattern matches the target and, if so, how specifically. func (p hostPattern) match(host, port, path string) (bool, matchDetail) { detail := matchDetail{} @@ -100,7 +106,11 @@ func (p hostPattern) match(host, port, path string) (bool, matchDetail) { } // bestMatch picks the most specific matching service per the tiered precedence in betterThan. -// On full ties, the first service (definition order) wins because betterThan is strict. +// A full tie (equal specificity across all tiers) is broken by the lexicographically-smallest +// service name. Names are unique within a folder, so this is a total order that makes the result +// deterministic regardless of the order services were fetched in -- the Infisical list endpoint +// does not guarantee an order, and the proxy re-fetches on every poll, so relying on slice order +// would let the winner differ across HA instances or flip between polls. func bestMatch(services []*resolvedService, host, port, path string) *resolvedService { var best *resolvedService var bestDetail matchDetail @@ -114,7 +124,11 @@ func bestMatch(services []*resolvedService, host, port, path string) *resolvedSe if !matched { continue } - if best == nil || detail.betterThan(bestDetail) { + switch { + case best == nil, detail.betterThan(bestDetail): + best = svc + bestDetail = detail + case detail.equalTo(bestDetail) && svc.name < best.name: best = svc bestDetail = detail } diff --git a/packages/agentproxy/match_test.go b/packages/agentproxy/match_test.go index 52f1dba4..5c9b095c 100644 --- a/packages/agentproxy/match_test.go +++ b/packages/agentproxy/match_test.go @@ -87,6 +87,20 @@ func TestHostMatchingIsCaseInsensitive(t *testing.T) { } } +func TestTieBrokenByServiceNameRegardlessOfInputOrder(t *testing.T) { + // two services claiming the same host tie on every specificity tier; the lexicographically + // smaller name must win in BOTH input orders so the result never depends on fetch/slice order. + alpha := svc("alpha", "api.stripe.com") + bravo := svc("bravo", "api.stripe.com") + + if got := bestMatch([]*resolvedService{alpha, bravo}, "api.stripe.com", "443", "/"); got == nil || got.name != "alpha" { + t.Fatalf("expected 'alpha' to win the tie, got %v", got) + } + if got := bestMatch([]*resolvedService{bravo, alpha}, "api.stripe.com", "443", "/"); got == nil || got.name != "alpha" { + t.Fatalf("tie winner must not depend on input order, got %v", got) + } +} + func TestNoMatch(t *testing.T) { s := svc("stripe", "api.stripe.com") if got := bestMatch([]*resolvedService{s}, "api.github.com", "443", "/"); got != nil { From 9fe18cb205c605dda6514198e059e1d5165868f5 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:41:17 +0530 Subject: [PATCH 09/26] test: cover credential rewrite surfaces, prefixes, and body edge cases --- packages/agentproxy/rewrite_test.go | 154 ++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/packages/agentproxy/rewrite_test.go b/packages/agentproxy/rewrite_test.go index 0913934f..7cc13fd6 100644 --- a/packages/agentproxy/rewrite_test.go +++ b/packages/agentproxy/rewrite_test.go @@ -1,6 +1,7 @@ package agentproxy import ( + "fmt" "io" "net/http" "net/http/httptest" @@ -96,6 +97,159 @@ func TestSubstitutionInBody(t *testing.T) { } } +func TestHeaderRewritePrefixVariations(t *testing.T) { + cases := []struct { + name string + prefix string + value string + want string + }{ + {"with prefix has exactly one space", "Bearer", "sk_live_real", "Bearer sk_live_real"}, + {"empty prefix has no leading space", "", "abc123", "abc123"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := newReq(t, "") + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleHeaderRewrite, headerName: "Authorization", headerPrefix: tc.prefix, value: tc.value}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + if got := req.Header.Get("Authorization"); got != tc.want { + t.Fatalf("want %q, got %q", tc.want, got) + } + }) + } +} + +func TestSubstitutionInHeader(t *testing.T) { + req := newReq(t, "") + req.Header.Set("X-Custom", "prefix-PLACEHOLDER-suffix") + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleCredentialSub, placeholder: "PLACEHOLDER", value: "REALSECRET", surfaces: []string{surfaceHeader}}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + if got := req.Header.Get("X-Custom"); got != "prefix-REALSECRET-suffix" { + t.Fatalf("header not substituted: %q", got) + } +} + +func TestSubstitutionAcrossAllSurfacesInOneCredential(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, "https://api.example.com/PLACEHOLDER/x?q=PLACEHOLDER", strings.NewReader(`{"k":"PLACEHOLDER"}`)) + req.Header.Set("X-Token", "pre-PLACEHOLDER-post") + svc := &resolvedService{credentials: []resolvedCredential{ + { + role: roleCredentialSub, + placeholder: "PLACEHOLDER", + value: "REALSECRET", + surfaces: []string{surfacePath, surfaceQuery, surfaceBody, surfaceHeader}, + }, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + if strings.Contains(req.URL.Path, "PLACEHOLDER") || !strings.Contains(req.URL.Path, "REALSECRET") { + t.Fatalf("path not substituted: %q", req.URL.Path) + } + if strings.Contains(req.URL.RawQuery, "PLACEHOLDER") || !strings.Contains(req.URL.RawQuery, "REALSECRET") { + t.Fatalf("query not substituted: %q", req.URL.RawQuery) + } + if got := req.Header.Get("X-Token"); got != "pre-REALSECRET-post" { + t.Fatalf("header not substituted: %q", got) + } + body, _ := io.ReadAll(req.Body) + if strings.Contains(string(body), "PLACEHOLDER") || !strings.Contains(string(body), "REALSECRET") { + t.Fatalf("body not substituted: %q", string(body)) + } +} + +func TestHeaderRewriteAndSubstitutionCombined(t *testing.T) { + // newReq's URL carries ?token=placeholder_x + req := newReq(t, "") + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleHeaderRewrite, headerName: "Authorization", headerPrefix: "Bearer", value: "sk_live_real"}, + {role: roleCredentialSub, placeholder: "placeholder_x", value: "real_secret", surfaces: []string{surfaceQuery}}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + if got := req.Header.Get("Authorization"); got != "Bearer sk_live_real" { + t.Fatalf("unexpected Authorization header: %q", got) + } + if strings.Contains(req.URL.RawQuery, "placeholder_x") || !strings.Contains(req.URL.RawQuery, "real_secret") { + t.Fatalf("query not substituted: %q", req.URL.RawQuery) + } +} + +func TestMultipleSubstitutionsDistinctPlaceholders(t *testing.T) { + req := newReq(t, `{"a":"PH_ONE","b":"PH_TWO"}`) + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleCredentialSub, placeholder: "PH_ONE", value: "real_one", surfaces: []string{surfaceBody}}, + {role: roleCredentialSub, placeholder: "PH_TWO", value: "real_two", surfaces: []string{surfaceBody}}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(req.Body) + if !strings.Contains(string(body), "real_one") || !strings.Contains(string(body), "real_two") || + strings.Contains(string(body), "PH_ONE") || strings.Contains(string(body), "PH_TWO") { + t.Fatalf("both placeholders should be substituted: %q", string(body)) + } +} + +func TestBodySubstitutionUpdatesContentLength(t *testing.T) { + req := newReq(t, `{"token":"placeholder_x"}`) + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleCredentialSub, placeholder: "placeholder_x", value: "a_substantially_longer_real_secret", surfaces: []string{surfaceBody}}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(req.Body) + if req.ContentLength != int64(len(body)) { + t.Fatalf("ContentLength %d does not match body length %d", req.ContentLength, len(body)) + } + if got := req.Header.Get("Content-Length"); got != fmt.Sprintf("%d", len(body)) { + t.Fatalf("Content-Length header %q does not match body length %d", got, len(body)) + } +} + +func TestBodySubstitutionSkippedWhenContentEncoded(t *testing.T) { + req := newReq(t, `{"token":"placeholder_x"}`) + req.Header.Set("Content-Encoding", "gzip") + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleCredentialSub, placeholder: "placeholder_x", value: "real_secret", surfaces: []string{surfaceBody}}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(req.Body) + if !strings.Contains(string(body), "placeholder_x") { + t.Fatalf("encoded body must not be rewritten: %q", string(body)) + } +} + +func TestBodyOversizedForwardedUnchanged(t *testing.T) { + original := "placeholder_x" + strings.Repeat("a", maxBodyRewriteSize) + req := newReq(t, original) + svc := &resolvedService{credentials: []resolvedCredential{ + {role: roleCredentialSub, placeholder: "placeholder_x", value: "real_secret", surfaces: []string{surfaceBody}}, + }} + if err := applyCredentials(req, svc); err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(req.Body) + if len(body) != len(original) { + t.Fatalf("oversized body must be forwarded whole, not truncated: got %d want %d", len(body), len(original)) + } + if !strings.Contains(string(body), "placeholder_x") { + t.Fatalf("oversized body must be forwarded unchanged (placeholder intact)") + } +} + func TestProxyAuthParsing(t *testing.T) { // base64("proj-123:prod/myapp:jwt.abc.def") header := "Basic cHJvai0xMjM6cHJvZC9teWFwcDpqd3QuYWJjLmRlZg==" From 4025bba7a61842ffb95cb2721aed7f9f44d9ecb6 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:55:42 +0530 Subject: [PATCH 10/26] fix: strip hop-by-hop headers before injecting so credentials always win --- packages/agentproxy/forward_test.go | 45 +++++++++++++++++++++++++++++ packages/agentproxy/proxy.go | 9 ++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/agentproxy/forward_test.go b/packages/agentproxy/forward_test.go index d332ebc6..6a4ff423 100644 --- a/packages/agentproxy/forward_test.go +++ b/packages/agentproxy/forward_test.go @@ -96,6 +96,51 @@ func TestPlainForwardInjectsCredentialsAndKeepsAlive(t *testing.T) { } } +func TestPlainForwardInjectedHeaderSurvivesHostileConnectionHeader(t *testing.T) { + // A client that names the injected header in its Connection field must not be able to strip + // the injected credential: "injected always wins". Hop-by-hop stripping runs before injection. + var gotAuth string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + _, _ = io.WriteString(w, "ok") + })) + defer upstream.Close() + + u, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + + jwt := "test.jwt.token" + scope := agentScope{projectID: "proj", environment: "prod", secretPath: "/"} + services := []*resolvedService{{ + name: "internal", + hostPatterns: parseHostPatterns(u.Hostname()), + isEnabled: true, + credentials: []resolvedCredential{ + {role: roleHeaderRewrite, headerName: "Authorization", headerPrefix: "Bearer", value: "real_secret"}, + }, + }} + client := newTestProxy(t, UnmatchedAllow, jwt, scope, services) + + // hostile request: Connection lists Authorization (would delete it if stripping ran after inject) + fmt.Fprintf(client, "GET http://%s/hello HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: %s\r\nConnection: Authorization\r\nAuthorization: Bearer client_fake\r\n\r\n", + u.Host, u.Host, proxyAuthHeader("proj", "prod", "/", jwt)) + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("unexpected status %d", resp.StatusCode) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + + if gotAuth != "Bearer real_secret" { + t.Fatalf("injected credential must survive a hostile Connection header; upstream saw Authorization = %q", gotAuth) + } +} + func TestPlainForwardRejectsHTTPSAbsoluteForm(t *testing.T) { scope := agentScope{projectID: "proj", environment: "prod", secretPath: "/"} client := newTestProxy(t, UnmatchedAllow, "jwt", scope, nil) diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index 59fe200d..4d871899 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -305,14 +305,19 @@ func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt st req.URL.Host = net.JoinHostPort(hostname, port) req.RequestURI = "" + // Strip hop-by-hop headers BEFORE applying credentials, not after: stripHopByHopHeaders + // removes any header the client named in its Connection field, so applying credentials first + // would let a client (e.g. "Connection: Authorization") delete the header we just injected. + // Injecting last keeps "injected always wins" true regardless of client headers (matches + // Agent Vault's ApplyInjection, which treats this as a security invariant). + stripHopByHopHeaders(req.Header) + if svc != nil { if err := applyCredentials(req, svc); err != nil { return nil, fmt.Errorf("failed to apply credentials: %w", err) } } - stripHopByHopHeaders(req.Header) - return ps.transport.RoundTrip(req) } From 415e65d68d69c3ba824ac009f77daa21bd7f289f Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Sat, 11 Jul 2026 00:12:02 +0530 Subject: [PATCH 11/26] feat: merge operator NO_PROXY entries and add --no-proxy flag to connect --- packages/cmd/agent_proxy.go | 47 +++++++++++++++++++++++++++++--- packages/cmd/agent_proxy_test.go | 40 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 packages/cmd/agent_proxy_test.go diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index a0839ca0..29e27978 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -80,6 +80,33 @@ var credentialEnvKeys = []string{ util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, } +// requiredNoProxy entries always bypass the proxy: loopback is machine-local and must never be +// routed to a (possibly remote) proxy, regardless of operator overrides. +var requiredNoProxy = []string{"localhost", "127.0.0.1"} + +// mergeNoProxy unions the required loopback defaults with operator-supplied entries (the inherited +// NO_PROXY/no_proxy env vars and the --no-proxy flag), de-duplicated, defaults first. Operators can +// add bypass hosts but cannot drop loopback. +func mergeNoProxy(operatorEntries ...string) string { + seen := make(map[string]bool) + var merged []string + add := func(raw string) { + for _, entry := range strings.Split(raw, ",") { + if entry = strings.TrimSpace(entry); entry != "" && !seen[entry] { + seen[entry] = true + merged = append(merged, entry) + } + } + } + for _, d := range requiredNoProxy { + add(d) + } + for _, o := range operatorEntries { + add(o) + } + return strings.Join(merged, ",") +} + func runAgentProxyConnect(cmd *cobra.Command, args []string) { proxyAddr, err := cmd.Flags().GetString("proxy") if err != nil || proxyAddr == "" { @@ -138,7 +165,8 @@ func runAgentProxyConnect(cmd *cobra.Command, args []string) { realSecrets := fetchAgentRealSecrets(token, projectID, environment, secretPath) // 4. Build the environment and launch the agent. - env := buildAgentEnv(proxyURL(proxyAddr, projectID, environment, secretPath, token.Token), caPath, token.Token, placeholderEnvs, realSecrets) + extraNoProxy, _ := cmd.Flags().GetString("no-proxy") + env := buildAgentEnv(proxyURL(proxyAddr, projectID, environment, secretPath, token.Token), caPath, token.Token, extraNoProxy, placeholderEnvs, realSecrets) if err := runAgentProcess(args, env); err != nil { util.HandleError(err, "Agent process failed") @@ -248,7 +276,7 @@ func fetchAgentRealSecrets(token *models.TokenDetails, projectID, environment, s return secrets } -func buildAgentEnv(proxy, caPath, jwt string, placeholders map[string]string, realSecrets []models.SingleEnvironmentVariable) []string { +func buildAgentEnv(proxy, caPath, jwt, extraNoProxy string, placeholders map[string]string, realSecrets []models.SingleEnvironmentVariable) []string { // start from the current environment, stripping stale proxy keys and machine identity credentials stale := map[string]bool{} for _, k := range proxyEnvKeys { @@ -257,10 +285,20 @@ func buildAgentEnv(proxy, caPath, jwt string, placeholders map[string]string, re for _, k := range credentialEnvKeys { stale[k] = true } + // The operator's own NO_PROXY is merged (not discarded) so they can add bypass hosts; capture the + // inherited values here, before the strip loop drops the raw copies, and fold them in below. + var operatorNoProxy []string env := map[string]string{} for _, kv := range os.Environ() { parts := strings.SplitN(kv, "=", 2) - if len(parts) == 2 && !stale[parts[0]] { + if len(parts) != 2 { + continue + } + if parts[0] == "NO_PROXY" || parts[0] == "no_proxy" { + operatorNoProxy = append(operatorNoProxy, parts[1]) + continue + } + if !stale[parts[0]] { env[parts[0]] = parts[1] } } @@ -268,7 +306,7 @@ func buildAgentEnv(proxy, caPath, jwt string, placeholders map[string]string, re // proxy routing env["HTTPS_PROXY"] = proxy env["HTTP_PROXY"] = proxy - env["NO_PROXY"] = "localhost,127.0.0.1" + env["NO_PROXY"] = mergeNoProxy(append(operatorNoProxy, extraNoProxy)...) env["NODE_USE_ENV_PROXY"] = "1" env["OPENCLAW_PROXY_URL"] = proxy @@ -344,6 +382,7 @@ func init() { agentProxyConnectCmd.Flags().String("client-id", "", "universal auth client id for the agent machine identity") agentProxyConnectCmd.Flags().String("client-secret", "", "universal auth client secret for the agent machine identity") agentProxyConnectCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") + agentProxyConnectCmd.Flags().String("no-proxy", "", "additional comma-separated hosts to bypass the proxy (always merged with localhost,127.0.0.1)") agentProxyStartCmd.Flags().Int("port", 17322, "port for the agent proxy to listen on") agentProxyStartCmd.Flags().String("unmatched-host", "allow", "policy for hosts with no proxied service: allow | block") diff --git a/packages/cmd/agent_proxy_test.go b/packages/cmd/agent_proxy_test.go new file mode 100644 index 00000000..34e332f5 --- /dev/null +++ b/packages/cmd/agent_proxy_test.go @@ -0,0 +1,40 @@ +package cmd + +import "testing" + +func TestMergeNoProxy(t *testing.T) { + tests := []struct { + name string + operator []string + want string + }{ + { + name: "defaults only when no operator entries", + operator: nil, + want: "localhost,127.0.0.1", + }, + { + name: "operator entries are appended after the loopback defaults", + operator: []string{"app.infisical.com,internal.corp.com"}, + want: "localhost,127.0.0.1,app.infisical.com,internal.corp.com", + }, + { + name: "duplicates and blanks are removed, loopback is never dropped", + operator: []string{"localhost, ,app.infisical.com", "app.infisical.com,10.0.0.5"}, + want: "localhost,127.0.0.1,app.infisical.com,10.0.0.5", + }, + { + name: "empty operator strings are ignored", + operator: []string{"", ""}, + want: "localhost,127.0.0.1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := mergeNoProxy(tt.operator...); got != tt.want { + t.Fatalf("mergeNoProxy(%v) = %q; want %q", tt.operator, got, tt.want) + } + }) + } +} From c5e2e3c048b419ea33673075ad6f823bbe540efc Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Sat, 11 Jul 2026 01:04:38 +0530 Subject: [PATCH 12/26] fix: force HTTP/1.1 upstream so HTTP/2 responses don't hang the MITM tunnel --- packages/agentproxy/proxy.go | 32 ++++++++++++++++++++----------- packages/agentproxy/proxy_test.go | 13 +++++++++++++ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index 4d871899..855b4b3e 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -43,20 +43,30 @@ type proxyServer struct { transport *http.Transport } +// newUpstreamTransport builds the transport used to forward requests to upstream services. +// It forces HTTP/1.1: every upstream response is re-serialized over the HTTP/1.1 MITM tunnel, so an +// HTTP/2 response has no HTTP/1.1 length framing and would hang the client. A non-nil (empty) +// TLSNextProto disables HTTP/2 -- ForceAttemptHTTP2:false alone is ignored when no custom +// TLSClientConfig/dialer is set, and Go otherwise auto-enables h2. +func newUpstreamTransport() *http.Transport { + return &http.Transport{ + Proxy: nil, + ForceAttemptHTTP2: false, + TLSNextProto: map[string]func(authority string, c *tls.Conn) http.RoundTripper{}, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } +} + // Start runs the agent proxy until the process is terminated. func Start(opts Options) error { ps := &proxyServer{ - opts: opts, - ca: newCaManager(opts.ProxyToken), - cache: newAgentCache(opts.ProxyToken), - transport: &http.Transport{ - Proxy: nil, - ForceAttemptHTTP2: false, - MaxIdleConns: 100, - IdleConnTimeout: 90 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, + opts: opts, + ca: newCaManager(opts.ProxyToken), + cache: newAgentCache(opts.ProxyToken), + transport: newUpstreamTransport(), } // warm up the intermediate CA so the first agent request isn't blocked on signing diff --git a/packages/agentproxy/proxy_test.go b/packages/agentproxy/proxy_test.go index 41cfa578..55c66265 100644 --- a/packages/agentproxy/proxy_test.go +++ b/packages/agentproxy/proxy_test.go @@ -2,6 +2,19 @@ package agentproxy import "testing" +// The proxy re-serializes every upstream response over an HTTP/1.1 MITM tunnel, so it must speak +// HTTP/1.1 upstream. If HTTP/2 sneaks back in (e.g. TLSNextProto reset to nil so Go auto-enables h2), +// h2 responses come back with no HTTP/1.1 length framing and hang the client on every real API. +func TestUpstreamTransportDisablesHTTP2(t *testing.T) { + tr := newUpstreamTransport() + if tr.TLSNextProto == nil { + t.Fatal("TLSNextProto is nil: Go will auto-enable HTTP/2 and h2 upstream responses will hang the HTTP/1.1 tunnel") + } + if len(tr.TLSNextProto) != 0 { + t.Fatalf("TLSNextProto must be empty to disable HTTP/2, got %d entries", len(tr.TLSNextProto)) + } +} + func TestParseConnectTarget(t *testing.T) { tests := []struct { name string From 1a9f1877084f5dbec385aab4573d25b211a38ea2 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:24:02 +0530 Subject: [PATCH 13/26] fix: authenticate before minting proxy leaf certs and bound leaf cache --- packages/agentproxy/ca.go | 31 +++++++++++++++++++++++++++++++ packages/agentproxy/proxy.go | 12 ++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/agentproxy/ca.go b/packages/agentproxy/ca.go index 3371285b..f7be57e3 100644 --- a/packages/agentproxy/ca.go +++ b/packages/agentproxy/ca.go @@ -33,6 +33,10 @@ const ( intermediateRetryInterval = 30 * time.Second leafTTL = 24 * time.Hour // lifetime of a minted leaf certificate leafReuseMargin = 1 * time.Hour // minimum remaining lifetime to reuse a cached leaf + // Upper bound on cached leaves so an agent (even an authenticated but misbehaving one) cannot + // grow the cache without limit by requesting endless unique hostnames. Generous enough for many + // agents talking to many upstreams through one proxy; a miss just re-mints on the next request. + maxLeafCacheEntries = 8192 ) // caManager holds the intermediate CA (signed by the org root CA in Infisical) and mints @@ -203,9 +207,36 @@ func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { // state. Serving this leaf directly is still fine; it is valid until the old chain expires. c.leafMu.Lock() if c.resignGen.Load() == gen { + c.evictLeavesIfFullLocked(hostname) c.leafCache[hostname] = &leafEntry{cert: cert, expiration: notAfter} } c.leafMu.Unlock() return cert, nil } + +// evictLeavesIfFullLocked keeps leafCache bounded before inserting a new hostname. It first drops +// expired entries, then, if still at capacity, evicts entries until there is room for one more. +// Refreshing an already-cached hostname does not grow the map, so it is exempt. Caller holds c.leafMu. +func (c *caManager) evictLeavesIfFullLocked(incoming string) { + if len(c.leafCache) < maxLeafCacheEntries { + return + } + if _, replacing := c.leafCache[incoming]; replacing { + return + } + now := time.Now() + for host, entry := range c.leafCache { + if now.After(entry.expiration) { + delete(c.leafCache, host) + } + } + // Expired sweep may not have freed enough; evict arbitrary entries to make room. Evicting a + // live leaf only costs a re-mint the next time that hostname is requested. + for host := range c.leafCache { + if len(c.leafCache) < maxLeafCacheEntries { + break + } + delete(c.leafCache, host) + } +} diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index 855b4b3e..f3f0b651 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -171,6 +171,18 @@ func (ps *proxyServer) handleConnect(clientConn net.Conn, req *http.Request) { return } + // Authenticate BEFORE minting: resolving the agent's services validates its token against + // Infisical. Minting first would let anyone with a syntactically valid Proxy-Authorization + // header force unbounded key generation (and leaf-cache growth) without a real credential. + if _, err := ps.cache.get(jwt, scope); err != nil { + if isAuthError(err) { + writeProxyResponse(clientConn, http.StatusForbidden, "proxy authorization failed") + } else { + writeProxyResponse(clientConn, http.StatusBadGateway, "failed to resolve agent permissions") + } + return + } + // leaf is minted for the exact CONNECT hostname (design: CONNECT host is source of truth) leaf, err := ps.ca.mintLeaf(hostname) if err != nil { From dd4ba4dcf7a86b6348a364ee413c40875e6b85f1 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Sat, 11 Jul 2026 03:50:34 +0530 Subject: [PATCH 14/26] fix: pin upstream Host header to the matched authority --- packages/agentproxy/proxy.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index f3f0b651..b53d0fe4 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -325,6 +325,11 @@ func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt st // rebuild the outbound request targeting the real upstream req.URL.Scheme = scheme req.URL.Host = net.JoinHostPort(hostname, port) + // Pin the Host header to the matched authority. The Host header on the inner tunnel request is + // agent-controlled, and Go would otherwise send it verbatim (it dials req.URL.Host but writes + // req.Host), letting an agent CONNECT to a matched host yet deliver the injected credential to a + // different vhost on the same upstream. Ignoring it keeps matched host == forwarded host. + req.Host = hostHeaderForScheme(scheme, req.URL.Host) req.RequestURI = "" // Strip hop-by-hop headers BEFORE applying credentials, not after: stripHopByHopHeaders @@ -343,6 +348,32 @@ func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt st return ps.transport.RoundTrip(req) } +// hostHeaderForScheme returns the Host header value for the matched target, stripping the port when +// it is the scheme default (443 for https, 80 for http) so exact vhost matches and signed-Host +// schemes work, while preserving non-default ports for internal vhost routing. +func hostHeaderForScheme(scheme, target string) string { + host, port, err := net.SplitHostPort(target) + if err != nil { + return target + } + var defaultPort string + switch strings.ToLower(scheme) { + case "https": + defaultPort = "443" + case "http": + defaultPort = "80" + default: + return target + } + if port != defaultPort { + return target + } + if strings.ContainsRune(host, ':') { + return "[" + host + "]" + } + return host +} + // hopByHopHeaders are the standard hop-by-hop headers removed before forwarding, // mirroring net/http/httputil.ReverseProxy. var hopByHopHeaders = []string{ From f7f4c1b19f53412a63c01ba4f4e0e50c18a7b735 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:48:04 +0530 Subject: [PATCH 15/26] chore: trim non-essential comments from agent proxy code --- packages/agentproxy/ca.go | 46 +++-------------- packages/agentproxy/ca_test.go | 6 --- packages/agentproxy/cache.go | 20 +------- packages/agentproxy/forward_test.go | 6 --- packages/agentproxy/match.go | 32 +++--------- packages/agentproxy/match_test.go | 2 - packages/agentproxy/proxy.go | 80 +++++------------------------ packages/agentproxy/proxy_test.go | 3 -- packages/agentproxy/rewrite.go | 17 ++---- packages/agentproxy/rewrite_test.go | 1 - packages/api/agent_proxy.go | 4 -- packages/cmd/agent_proxy.go | 35 ++----------- packages/cmd/agent_proxy_start.go | 7 --- 13 files changed, 36 insertions(+), 223 deletions(-) diff --git a/packages/agentproxy/ca.go b/packages/agentproxy/ca.go index f7be57e3..6c56502a 100644 --- a/packages/agentproxy/ca.go +++ b/packages/agentproxy/ca.go @@ -21,39 +21,23 @@ import ( ) const ( - // The backend signs the intermediate with a 7-day TTL; renewing below this threshold keeps - // the intermediate (and every leaf capped to it) comfortably inside that window. intermediateRenewThreshold = 12 * time.Hour - // Below the renew threshold, a failed re-sign falls back to the current intermediate as long - // as it stays valid past this margin, so a transient Infisical outage inside the renewal - // window degrades gracefully instead of failing every mint (including cached-leaf hostnames). intermediateFallbackMargin = 5 * time.Minute - // Minimum time between re-sign attempts while falling back; mints are serialized on c.mu, - // so retrying on every request would both hammer the sign endpoint and block all minting. - intermediateRetryInterval = 30 * time.Second - leafTTL = 24 * time.Hour // lifetime of a minted leaf certificate - leafReuseMargin = 1 * time.Hour // minimum remaining lifetime to reuse a cached leaf - // Upper bound on cached leaves so an agent (even an authenticated but misbehaving one) cannot - // grow the cache without limit by requesting endless unique hostnames. Generous enough for many - // agents talking to many upstreams through one proxy; a miss just re-mints on the next request. - maxLeafCacheEntries = 8192 + intermediateRetryInterval = 30 * time.Second + leafTTL = 24 * time.Hour + leafReuseMargin = 1 * time.Hour + maxLeafCacheEntries = 8192 ) -// caManager holds the intermediate CA (signed by the org root CA in Infisical) and mints -// short-lived leaf certificates per upstream hostname, presenting the leaf+intermediate chain. type caManager struct { - // token returns the proxy MI's current access token, refreshed by the caller. token func() string mu sync.Mutex intermediateKey *ecdsa.PrivateKey intermediateCert *x509.Certificate intermediateExp time.Time - lastResignAttempt time.Time // throttles re-sign retries while falling back - // resignGen increments (under mu) every time a new intermediate is installed. Leaf minters - // snapshot it alongside the intermediate and only cache a leaf if it is unchanged, so a leaf - // signed by an outgoing intermediate can never land in leafCache after a re-sign cleared it. - resignGen atomic.Uint64 + lastResignAttempt time.Time + resignGen atomic.Uint64 leafMu sync.Mutex leafCache map[string]*leafEntry @@ -71,9 +55,6 @@ func newCaManager(token func() string) *caManager { } } -// ensureIntermediate lazily generates an intermediate keypair and has Infisical sign it, -// re-signing when it is near expiry. A failed re-sign is not fatal while the current -// intermediate remains usable: minting continues on it and the re-sign is retried later. func (c *caManager) ensureIntermediate() error { c.mu.Lock() defer c.mu.Unlock() @@ -99,8 +80,6 @@ func (c *caManager) ensureIntermediate() error { return nil } -// resignIntermediateLocked generates a fresh keypair, has Infisical sign it with the org root -// CA, and installs it. Caller must hold c.mu. func (c *caManager) resignIntermediateLocked() error { key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -134,7 +113,6 @@ func (c *caManager) resignIntermediateLocked() error { c.intermediateCert = cert c.intermediateExp = cert.NotAfter c.resignGen.Add(1) - // new intermediate invalidates cached leaves (they chain to the old one) c.leafMu.Lock() c.leafCache = make(map[string]*leafEntry) c.leafMu.Unlock() @@ -142,8 +120,6 @@ func (c *caManager) resignIntermediateLocked() error { return nil } -// mintLeaf returns a TLS certificate for the hostname, signed by the intermediate CA, -// with the intermediate appended so the agent can build the chain to the trusted root. func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { if err := c.ensureIntermediate(); err != nil { return tls.Certificate{}, err @@ -185,7 +161,6 @@ func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, } - // TLS clients validate an IP-literal target against the IP SAN, not DNS names. if ip := net.ParseIP(hostname); ip != nil { template.IPAddresses = []net.IP{ip} } else { @@ -202,9 +177,7 @@ func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { PrivateKey: key, } - // Only cache if no re-sign happened since the intermediate was snapshotted: a re-sign clears - // leafCache, and caching a leaf chained to the outgoing intermediate would resurrect stale - // state. Serving this leaf directly is still fine; it is valid until the old chain expires. + // Skip caching if a re-sign happened since snapshotting: it cleared leafCache and this leaf chains to the outgoing intermediate. c.leafMu.Lock() if c.resignGen.Load() == gen { c.evictLeavesIfFullLocked(hostname) @@ -215,9 +188,6 @@ func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) { return cert, nil } -// evictLeavesIfFullLocked keeps leafCache bounded before inserting a new hostname. It first drops -// expired entries, then, if still at capacity, evicts entries until there is room for one more. -// Refreshing an already-cached hostname does not grow the map, so it is exempt. Caller holds c.leafMu. func (c *caManager) evictLeavesIfFullLocked(incoming string) { if len(c.leafCache) < maxLeafCacheEntries { return @@ -231,8 +201,6 @@ func (c *caManager) evictLeavesIfFullLocked(incoming string) { delete(c.leafCache, host) } } - // Expired sweep may not have freed enough; evict arbitrary entries to make room. Evicting a - // live leaf only costs a re-mint the next time that hostname is requested. for host := range c.leafCache { if len(c.leafCache) < maxLeafCacheEntries { break diff --git a/packages/agentproxy/ca_test.go b/packages/agentproxy/ca_test.go index bbb2579a..759c8888 100644 --- a/packages/agentproxy/ca_test.go +++ b/packages/agentproxy/ca_test.go @@ -15,8 +15,6 @@ import ( "github.com/Infisical/infisical-merge/packages/config" ) -// installTestIntermediate puts a self-signed CA cert into the manager as if Infisical had -// signed it, expiring at the given time. func installTestIntermediate(t *testing.T, c *caManager, notAfter time.Time) { t.Helper() key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) @@ -45,8 +43,6 @@ func installTestIntermediate(t *testing.T, c *caManager, notAfter time.Time) { c.intermediateExp = cert.NotAfter } -// A failed re-sign inside the renewal window must fall back to the still-valid intermediate -// instead of failing every mint (a transient Infisical outage must not take the proxy down). func TestEnsureIntermediateFallsBackWhenResignFails(t *testing.T) { failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) @@ -57,7 +53,6 @@ func TestEnsureIntermediateFallsBackWhenResignFails(t *testing.T) { defer func() { config.INFISICAL_URL = origURL }() c := newCaManager(func() string { return "test-token" }) - // 1h remaining: below the 12h renew threshold (re-sign attempted), above the fallback margin installTestIntermediate(t, c, time.Now().Add(1*time.Hour)) if err := c.ensureIntermediate(); err != nil { @@ -72,7 +67,6 @@ func TestEnsureIntermediateFallsBackWhenResignFails(t *testing.T) { } } -// With no usable intermediate at all, a failed sign is still a hard error. func TestEnsureIntermediateFailsWithoutFallback(t *testing.T) { failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "boom", http.StatusInternalServerError) diff --git a/packages/agentproxy/cache.go b/packages/agentproxy/cache.go index 1894d251..63f39251 100644 --- a/packages/agentproxy/cache.go +++ b/packages/agentproxy/cache.go @@ -14,8 +14,6 @@ import ( "github.com/rs/zerolog/log" ) -// isAuthError reports whether an error from an Infisical call is a hard auth failure (401/403), -// as opposed to a transient/network error. A revoked or expired agent JWT surfaces here. func isAuthError(err error) bool { var apiErr *api.APIError if errors.As(err, &apiErr) { @@ -33,7 +31,7 @@ type resolvedCredential struct { headerPurpose string placeholder string surfaces []string - value string // real secret value resolved via the proxy's own MI + value string } type resolvedService struct { @@ -56,17 +54,11 @@ type agentEntry struct { lastSeen time.Time } -// cacheKey scopes cached entries by both the agent JWT and the requested scope, so an agent -// connecting with a different environment/path does not reuse another scope's resolved credentials. func cacheKey(jwt string, scope agentScope) string { return strings.Join([]string{jwt, scope.projectID, scope.environment, scope.secretPath}, "\x00") } -// agentCache resolves and caches, per agent JWT, the proxied services the agent may use -// along with the real credential values (fetched with the proxy's own token). type agentCache struct { - // proxyToken returns the proxy MI's current access token; it is refreshed by the caller, - // so it is a getter rather than a fixed string (a fixed token would expire and break fetches). proxyToken func() string mu sync.Mutex @@ -80,9 +72,6 @@ func newAgentCache(proxyToken func() string) *agentCache { } } -// get returns a snapshot of the resolved services for the agent JWT + scope, resolving on first use. -// The returned slice is read by the caller without the cache lock, so it is snapshotted here -// under the lock to avoid a data race with refreshActive reassigning entry.services. func (a *agentCache) get(jwt string, scope agentScope) ([]*resolvedService, error) { key := cacheKey(jwt, scope) @@ -113,8 +102,6 @@ func (a *agentCache) get(jwt string, scope agentScope) ([]*resolvedService, erro return resolved, nil } -// resolve discovers the agent's proxied services (using the agent JWT) and fills in the real -// secret values (using the proxy's own token). func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService, error) { agentClient := resty.New().SetAuthToken(jwt) listResp, err := api.CallListProxiedServices(agentClient, api.ListProxiedServicesRequest{ @@ -144,7 +131,6 @@ func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService, for _, cred := range svc.Credentials { value, ok := secretValues[cred.SecretKey] if !ok { - // stale reference: secret renamed/deleted after the service was created log.Warn().Msgf("proxied service %q references missing secret %q; skipping", svc.Name, cred.SecretKey) continue } @@ -181,7 +167,6 @@ func (a *agentCache) fetchSecretValues(scope agentScope) (map[string]string, err return values, nil } -// refreshActive re-resolves cached agents that were seen recently and drops inactive ones. func (a *agentCache) refreshActive() { type refreshTarget struct { key string @@ -203,8 +188,7 @@ func (a *agentCache) refreshActive() { for _, t := range targets { resolved, err := a.resolve(t.jwt, t.scope) if err != nil { - // A hard auth failure means the agent's JWT was revoked or expired: evict so we stop - // serving its cached credentials (fail closed). Transient errors keep the existing cache. + // Hard auth failure means the JWT was revoked/expired: evict to stop serving cached credentials (fail closed). if isAuthError(err) { log.Warn().Err(err).Msg("agent authorization no longer valid; dropping cached credentials") a.mu.Lock() diff --git a/packages/agentproxy/forward_test.go b/packages/agentproxy/forward_test.go index 6a4ff423..b6a23404 100644 --- a/packages/agentproxy/forward_test.go +++ b/packages/agentproxy/forward_test.go @@ -19,8 +19,6 @@ func proxyAuthHeader(projectID, environment, secretPath, jwt string) string { return "Basic " + base64.StdEncoding.EncodeToString([]byte(projectID+":"+password)) } -// newTestProxy returns a proxyServer whose cache is pre-populated for jwt+scope, so no -// Infisical calls happen, plus the client side of a pipe with handleConn running on the other end. func newTestProxy(t *testing.T, unmatchedHost, jwt string, scope agentScope, services []*resolvedService) net.Conn { t.Helper() cache := newAgentCache(func() string { return "" }) @@ -68,7 +66,6 @@ func TestPlainForwardInjectsCredentialsAndKeepsAlive(t *testing.T) { client := newTestProxy(t, UnmatchedAllow, jwt, scope, services) reader := bufio.NewReader(client) - // two sequential requests exercise plain-path keep-alive for i := 0; i < 2; i++ { _, err := fmt.Fprintf(client, "GET http://%s/hello HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: %s\r\n\r\n", u.Host, u.Host, proxyAuthHeader("proj", "prod", "/", jwt)) @@ -97,8 +94,6 @@ func TestPlainForwardInjectsCredentialsAndKeepsAlive(t *testing.T) { } func TestPlainForwardInjectedHeaderSurvivesHostileConnectionHeader(t *testing.T) { - // A client that names the injected header in its Connection field must not be able to strip - // the injected credential: "injected always wins". Hop-by-hop stripping runs before injection. var gotAuth string upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = r.Header.Get("Authorization") @@ -123,7 +118,6 @@ func TestPlainForwardInjectedHeaderSurvivesHostileConnectionHeader(t *testing.T) }} client := newTestProxy(t, UnmatchedAllow, jwt, scope, services) - // hostile request: Connection lists Authorization (would delete it if stripping ran after inject) fmt.Fprintf(client, "GET http://%s/hello HTTP/1.1\r\nHost: %s\r\nProxy-Authorization: %s\r\nConnection: Authorization\r\nAuthorization: Bearer client_fake\r\n\r\n", u.Host, u.Host, proxyAuthHeader("proj", "prod", "/", jwt)) resp, err := http.ReadResponse(bufio.NewReader(client), nil) diff --git a/packages/agentproxy/match.go b/packages/agentproxy/match.go index db09f793..10d6a91a 100644 --- a/packages/agentproxy/match.go +++ b/packages/agentproxy/match.go @@ -4,11 +4,10 @@ import ( "strings" ) -// hostPattern is a single parsed pattern from a proxied service's comma-separated hostPattern. type hostPattern struct { - host string // may start with "*." for a wildcard - port string // "" means any port - path string // "" means any path; may end with "*" + host string + port string + path string } func parseHostPatterns(raw string) []hostPattern { @@ -20,12 +19,10 @@ func parseHostPatterns(raw string) []hostPattern { } p := hostPattern{} - // split off path if idx := strings.Index(part, "/"); idx != -1 { p.path = part[idx:] part = part[:idx] } - // split off port if idx := strings.LastIndex(part, ":"); idx != -1 { p.port = part[idx+1:] part = part[:idx] @@ -36,16 +33,12 @@ func parseHostPatterns(raw string) []hostPattern { return patterns } -// matchDetail records how specifically a pattern matched, for tiered precedence comparison. type matchDetail struct { - exactHost bool // exact host match (vs wildcard) - specificPort bool // pattern pinned a port (vs any-port) - pathLen int // length of the matched path prefix + exactHost bool + specificPort bool + pathLen int } -// betterThan reports whether m is strictly more specific than o, per the documented precedence: -// 1. exact host beats wildcard; 2. specific port beats any-port; 3. longest path prefix. -// Each tier is only consulted when all higher tiers are equal. func (m matchDetail) betterThan(o matchDetail) bool { if m.exactHost != o.exactHost { return m.exactHost @@ -56,22 +49,18 @@ func (m matchDetail) betterThan(o matchDetail) bool { return m.pathLen > o.pathLen } -// equalTo reports whether two matches are equally specific across every tier, meaning neither is -// betterThan the other and the caller must fall back to a stable tiebreaker. func (m matchDetail) equalTo(o matchDetail) bool { return m.exactHost == o.exactHost && m.specificPort == o.specificPort && m.pathLen == o.pathLen } -// match reports whether the pattern matches the target and, if so, how specifically. func (p hostPattern) match(host, port, path string) (bool, matchDetail) { detail := matchDetail{} - // Hostnames are case-insensitive; fold both sides so wildcard matching agrees with exact matching. host = strings.ToLower(host) patternHost := strings.ToLower(p.host) if strings.HasPrefix(patternHost, "*.") { - suffix := patternHost[1:] // ".github.com" + suffix := patternHost[1:] // wildcard matches exactly one extra label: api.github.com yes, a.b.github.com no if !strings.HasSuffix(host, suffix) { return false, detail @@ -105,12 +94,7 @@ func (p hostPattern) match(host, port, path string) (bool, matchDetail) { return true, detail } -// bestMatch picks the most specific matching service per the tiered precedence in betterThan. -// A full tie (equal specificity across all tiers) is broken by the lexicographically-smallest -// service name. Names are unique within a folder, so this is a total order that makes the result -// deterministic regardless of the order services were fetched in -- the Infisical list endpoint -// does not guarantee an order, and the proxy re-fetches on every poll, so relying on slice order -// would let the winner differ across HA instances or flip between polls. +// Full ties are broken by lexicographically-smallest service name so the winner is deterministic regardless of the unordered list-endpoint result. func bestMatch(services []*resolvedService, host, port, path string) *resolvedService { var best *resolvedService var bestDetail matchDetail diff --git a/packages/agentproxy/match_test.go b/packages/agentproxy/match_test.go index 5c9b095c..187a1a51 100644 --- a/packages/agentproxy/match_test.go +++ b/packages/agentproxy/match_test.go @@ -88,8 +88,6 @@ func TestHostMatchingIsCaseInsensitive(t *testing.T) { } func TestTieBrokenByServiceNameRegardlessOfInputOrder(t *testing.T) { - // two services claiming the same host tie on every specificity tier; the lexicographically - // smaller name must win in BOTH input orders so the result never depends on fetch/slice order. alpha := svc("alpha", "api.stripe.com") bravo := svc("bravo", "api.stripe.com") diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index b53d0fe4..0619ed46 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -21,19 +21,18 @@ const ( ) const ( - connectReadTimeout = 30 * time.Second // time allowed to send the CONNECT request - tlsHandshakeTimeout = 10 * time.Second // time allowed to complete the MITM TLS handshake - idleTunnelTimeout = 5 * time.Minute // max idle time waiting for the next request on a tunnel + connectReadTimeout = 30 * time.Second + tlsHandshakeTimeout = 10 * time.Second + idleTunnelTimeout = 5 * time.Minute ) -// errHostBlocked marks a request rejected by the unmatched-host=block policy so it maps to 403. var errHostBlocked = errors.New("host blocked by policy") type Options struct { Port int - UnmatchedHost string // allow | block - PollInterval time.Duration // cache refresh cadence - ProxyToken func() string // returns the agent proxy MI's current access token (refreshed by the caller) + UnmatchedHost string + PollInterval time.Duration + ProxyToken func() string } type proxyServer struct { @@ -43,11 +42,7 @@ type proxyServer struct { transport *http.Transport } -// newUpstreamTransport builds the transport used to forward requests to upstream services. -// It forces HTTP/1.1: every upstream response is re-serialized over the HTTP/1.1 MITM tunnel, so an -// HTTP/2 response has no HTTP/1.1 length framing and would hang the client. A non-nil (empty) -// TLSNextProto disables HTTP/2 -- ForceAttemptHTTP2:false alone is ignored when no custom -// TLSClientConfig/dialer is set, and Go otherwise auto-enables h2. +// Forces HTTP/1.1: h2 responses have no HTTP/1.1 length framing and would hang the re-serialized MITM tunnel; a non-nil empty TLSNextProto is what actually disables h2. func newUpstreamTransport() *http.Transport { return &http.Transport{ Proxy: nil, @@ -60,7 +55,6 @@ func newUpstreamTransport() *http.Transport { } } -// Start runs the agent proxy until the process is terminated. func Start(opts Options) error { ps := &proxyServer{ opts: opts, @@ -69,16 +63,12 @@ func Start(opts Options) error { transport: newUpstreamTransport(), } - // warm up the intermediate CA so the first agent request isn't blocked on signing if err := ps.ca.ensureIntermediate(); err != nil { return fmt.Errorf("failed to initialize agent proxy CA: %w", err) } go ps.pollLoop() - // Pre-flight: fail fast if something already listens on this port. A plain net.Listen(":port") - // can bind one address family (e.g. IPv6) while another process holds the other (e.g. a process - // on IPv4 127.0.0.1), which would silently split traffic, so probe both loopback families first. if addr := portInUse(opts.Port); addr != "" { return fmt.Errorf("port %d is already in use (%s); another process is listening. Choose a free port with --port", opts.Port, addr) } @@ -97,7 +87,6 @@ func Start(opts Options) error { return nil } log.Warn().Err(err).Msg("failed to accept connection") - // back off briefly so a persistent accept error doesn't hot-spin the loop time.Sleep(100 * time.Millisecond) continue } @@ -105,8 +94,6 @@ func Start(opts Options) error { } } -// portInUse reports the first loopback address that already has a listener on the port, or "". -// Probing both families catches the dual-stack split that a bare net.Listen would miss. func portInUse(port int) string { for _, addr := range []string{fmt.Sprintf("127.0.0.1:%d", port), fmt.Sprintf("[::1]:%d", port)} { conn, err := net.DialTimeout("tcp", addr, 300*time.Millisecond) @@ -134,8 +121,6 @@ func (ps *proxyServer) handleConn(clientConn net.Conn) { defer clientConn.Close() reader := bufio.NewReader(clientConn) - // Bound the time a client may take to send its first request so a stalled connection can't - // pin a goroutine; subsequent keep-alive requests on the plain-HTTP path get the idle timeout. readTimeout := connectReadTimeout for { _ = clientConn.SetReadDeadline(time.Now().Add(readTimeout)) @@ -156,8 +141,6 @@ func (ps *proxyServer) handleConn(clientConn net.Conn) { } } -// handleConnect serves a CONNECT request: authenticate, mint a leaf for the target hostname, -// complete the MITM TLS handshake, and serve tunnelled requests until the connection ends. func (ps *proxyServer) handleConnect(clientConn net.Conn, req *http.Request) { scope, jwt, ok := parseProxyAuth(req.Header.Get("Proxy-Authorization")) if !ok { @@ -171,9 +154,7 @@ func (ps *proxyServer) handleConnect(clientConn net.Conn, req *http.Request) { return } - // Authenticate BEFORE minting: resolving the agent's services validates its token against - // Infisical. Minting first would let anyone with a syntactically valid Proxy-Authorization - // header force unbounded key generation (and leaf-cache growth) without a real credential. + // Authenticate before minting: otherwise any syntactically valid Proxy-Authorization header forces unbounded key generation and leaf-cache growth. if _, err := ps.cache.get(jwt, scope); err != nil { if isAuthError(err) { writeProxyResponse(clientConn, http.StatusForbidden, "proxy authorization failed") @@ -183,7 +164,6 @@ func (ps *proxyServer) handleConnect(clientConn net.Conn, req *http.Request) { return } - // leaf is minted for the exact CONNECT hostname (design: CONNECT host is source of truth) leaf, err := ps.ca.mintLeaf(hostname) if err != nil { writeProxyResponse(clientConn, http.StatusInternalServerError, "failed to mint certificate") @@ -208,13 +188,7 @@ func (ps *proxyServer) handleConnect(clientConn net.Conn, req *http.Request) { ps.serveTunnel(tlsConn, hostname, port, jwt, scope) } -// handlePlainForward serves an absolute-form forward-proxy request (RFC 7230 §5.3.2) for a -// plain-HTTP upstream, applying the same auth, matching, and credential pipeline as the CONNECT -// path — there is just no TLS layer to intercept, and the agent JWT arrives on every request -// rather than once per tunnel. Only http:// is served: https:// absolute-form is rejected so -// the proxy can never be used to silently TLS-strip (HTTPS upstreams must arrive as CONNECT), -// and origin-form is rejected so the proxy ingress cannot be used as an origin server. -// It reports whether the connection may serve another keep-alive request. +// Only http:// absolute-form is served; https:// is rejected so the proxy can never be used to silently TLS-strip (HTTPS must arrive as CONNECT). func (ps *proxyServer) handlePlainForward(clientConn net.Conn, req *http.Request) bool { if !strings.EqualFold(req.URL.Scheme, "http") || req.URL.Host == "" { writeHTTPError(clientConn, http.StatusBadRequest, "non-CONNECT requests must be absolute-form http:// (use CONNECT for https:// upstreams)") @@ -227,9 +201,6 @@ func (ps *proxyServer) handlePlainForward(clientConn net.Conn, req *http.Request return false } - // URL.Hostname/Port handle bracketed IPv6 literals; default the port for the http scheme. - // Per RFC 7230 §5.4 the absolute-form URL is authoritative for routing (the Host header is - // not consulted; Go's ReadRequest already promotes the URL host into req.Host). hostname := req.URL.Hostname() port := req.URL.Port() if port == "" { @@ -259,9 +230,6 @@ func (ps *proxyServer) handlePlainForward(clientConn net.Conn, req *http.Request return !req.Close && !resp.Close } -// parseConnectTarget splits a CONNECT authority-form target into hostname and port, defaulting -// the port to 443. Bracketed IPv6 literals ("[::1]", "[::1]:8443") are handled by SplitHostPort; -// anything it cannot parse even with the default port appended is rejected. func parseConnectTarget(target string) (hostname, port string, err error) { hostname, port, err = net.SplitHostPort(target) if err == nil { @@ -274,12 +242,10 @@ func parseConnectTarget(target string) (hostname, port string, err error) { return "", "", err } -// serveTunnel reads requests off the terminated TLS connection, applies credentials, and forwards them. func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string, scope agentScope) { tlsReader := bufio.NewReader(tlsConn) for { - // Reap a tunnel that sits idle (or dribbles headers) between requests. _ = tlsConn.SetReadDeadline(time.Now().Add(idleTunnelTimeout)) req, err := http.ReadRequest(tlsReader) if err != nil { @@ -288,7 +254,6 @@ func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string } return } - // Clear the deadline for the forward: the upstream response may legitimately stream for a while. _ = tlsConn.SetReadDeadline(time.Time{}) resp, err := ps.forward(req, "https", hostname, port, jwt, scope) @@ -322,21 +287,13 @@ func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt st return nil, fmt.Errorf("host %q has no matching proxied service: %w", hostname, errHostBlocked) } - // rebuild the outbound request targeting the real upstream req.URL.Scheme = scheme req.URL.Host = net.JoinHostPort(hostname, port) - // Pin the Host header to the matched authority. The Host header on the inner tunnel request is - // agent-controlled, and Go would otherwise send it verbatim (it dials req.URL.Host but writes - // req.Host), letting an agent CONNECT to a matched host yet deliver the injected credential to a - // different vhost on the same upstream. Ignoring it keeps matched host == forwarded host. + // Pin Host to the matched authority: the inner tunnel Host is agent-controlled and Go forwards it verbatim, which would let a matched CONNECT deliver the credential to a different vhost. req.Host = hostHeaderForScheme(scheme, req.URL.Host) req.RequestURI = "" - // Strip hop-by-hop headers BEFORE applying credentials, not after: stripHopByHopHeaders - // removes any header the client named in its Connection field, so applying credentials first - // would let a client (e.g. "Connection: Authorization") delete the header we just injected. - // Injecting last keeps "injected always wins" true regardless of client headers (matches - // Agent Vault's ApplyInjection, which treats this as a security invariant). + // Strip hop-by-hop before injecting so a client's Connection header cannot delete the injected credential (injected always wins). stripHopByHopHeaders(req.Header) if svc != nil { @@ -348,9 +305,6 @@ func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt st return ps.transport.RoundTrip(req) } -// hostHeaderForScheme returns the Host header value for the matched target, stripping the port when -// it is the scheme default (443 for https, 80 for http) so exact vhost matches and signed-Host -// schemes work, while preserving non-default ports for internal vhost routing. func hostHeaderForScheme(scheme, target string) string { host, port, err := net.SplitHostPort(target) if err != nil { @@ -374,8 +328,6 @@ func hostHeaderForScheme(scheme, target string) string { return host } -// hopByHopHeaders are the standard hop-by-hop headers removed before forwarding, -// mirroring net/http/httputil.ReverseProxy. var hopByHopHeaders = []string{ "Connection", "Proxy-Connection", @@ -388,8 +340,6 @@ var hopByHopHeaders = []string{ "Upgrade", } -// stripHopByHopHeaders removes the headers named in the Connection header plus the standard -// hop-by-hop set; they describe this hop's connection and must not be forwarded upstream. func stripHopByHopHeaders(h http.Header) { for _, name := range strings.Split(h.Get("Connection"), ",") { if name = strings.TrimSpace(name); name != "" { @@ -401,8 +351,6 @@ func stripHopByHopHeaders(h http.Header) { } } -// parseProxyAuth decodes the Proxy-Authorization Basic header into scope + agent JWT. -// userinfo layout: username = projectId, password = "/:" (jwt last). func parseProxyAuth(header string) (agentScope, string, bool) { const prefix = "Basic " if !strings.HasPrefix(header, prefix) { @@ -421,7 +369,6 @@ func parseProxyAuth(header string) (agentScope, string, bool) { projectID := userinfo[:firstColon] password := userinfo[firstColon+1:] - // jwt is after the LAST colon (env/path contain no colons; JWT charset has none) lastColon := strings.LastIndex(password, ":") if lastColon == -1 { return agentScope{}, "", false @@ -429,7 +376,6 @@ func parseProxyAuth(header string) (agentScope, string, bool) { scopeStr := password[:lastColon] jwt := password[lastColon+1:] - // scopeStr = "/" slash := strings.Index(scopeStr, "/") var environment, secretPath string if slash == -1 { @@ -437,7 +383,7 @@ func parseProxyAuth(header string) (agentScope, string, bool) { secretPath = "/" } else { environment = scopeStr[:slash] - secretPath = scopeStr[slash:] // includes leading slash + secretPath = scopeStr[slash:] } if projectID == "" || environment == "" || jwt == "" { @@ -447,8 +393,6 @@ func parseProxyAuth(header string) (agentScope, string, bool) { return agentScope{projectID: projectID, environment: environment, secretPath: secretPath}, jwt, true } -// drainRequestBody consumes whatever the transport did not read of the request body so leftover -// bytes cannot desync the next http.ReadRequest on a keep-alive connection. func drainRequestBody(req *http.Request) { if req.Body != nil { _, _ = io.Copy(io.Discard, req.Body) diff --git a/packages/agentproxy/proxy_test.go b/packages/agentproxy/proxy_test.go index 55c66265..919f932e 100644 --- a/packages/agentproxy/proxy_test.go +++ b/packages/agentproxy/proxy_test.go @@ -2,9 +2,6 @@ package agentproxy import "testing" -// The proxy re-serializes every upstream response over an HTTP/1.1 MITM tunnel, so it must speak -// HTTP/1.1 upstream. If HTTP/2 sneaks back in (e.g. TLSNextProto reset to nil so Go auto-enables h2), -// h2 responses come back with no HTTP/1.1 length framing and hang the client on every real API. func TestUpstreamTransportDisablesHTTP2(t *testing.T) { tr := newUpstreamTransport() if tr.TLSNextProto == nil { diff --git a/packages/agentproxy/rewrite.go b/packages/agentproxy/rewrite.go index 2bc71579..9b6abd3a 100644 --- a/packages/agentproxy/rewrite.go +++ b/packages/agentproxy/rewrite.go @@ -18,10 +18,9 @@ const ( surfacePath = "path" surfaceQuery = "query" surfaceBody = "body" - maxBodyRewriteSize = 10 * 1024 * 1024 // 10 MiB + maxBodyRewriteSize = 10 * 1024 * 1024 ) -// applyCredentials rewrites headers and substitutes placeholder values on the outbound request. func applyCredentials(req *http.Request, svc *resolvedService) error { var basicUser, basicPass string haveBasic := false @@ -71,11 +70,7 @@ func hasSurface(surfaces []string, target string) bool { return false } -// applySubstitution replaces the placeholder value with the real credential across the -// configured surfaces (header/path/query/body). Replacement is a plain substring ReplaceAll, so -// every occurrence is swapped, including where the placeholder is a prefix/substring of a longer -// token. This is safe in practice because placeholders are distinctive random strings -// (see genPlaceholder), but callers must not use short/common placeholder values. +// Plain substring ReplaceAll on distinctive random placeholders; short or common placeholder values would over-match. func applySubstitution(req *http.Request, cred resolvedCredential) error { placeholder := cred.placeholder if placeholder == "" { @@ -85,9 +80,7 @@ func applySubstitution(req *http.Request, cred resolvedCredential) error { if hasSurface(cred.surfaces, surfacePath) { req.URL.Path = strings.ReplaceAll(req.URL.Path, placeholder, real) - // Clearing RawPath makes Go re-encode the path from Path on the wire, which can change - // the byte representation of other escaped segments. Placeholders are distinctive random - // strings, so a placeholder cross-matching an escaped segment is not a concern. + // Clearing RawPath makes Go re-encode the path from Path, which can change the byte form of other escaped segments. req.URL.RawPath = "" } @@ -106,19 +99,15 @@ func applySubstitution(req *http.Request, cred resolvedCredential) error { } if hasSurface(cred.surfaces, surfaceBody) && req.Body != nil { - // don't rewrite encoded bodies; the placeholder wouldn't be present verbatim if req.Header.Get("Content-Encoding") != "" { return nil } - // read one byte past the cap to detect oversized bodies body, err := io.ReadAll(io.LimitReader(req.Body, maxBodyRewriteSize+1)) if err != nil { _ = req.Body.Close() return fmt.Errorf("failed to read request body for substitution: %w", err) } if len(body) > maxBodyRewriteSize { - // too large to safely rewrite: forward the already-buffered body unchanged rather than - // truncating it (truncation would corrupt the request) req.Body = io.NopCloser(io.MultiReader(bytes.NewReader(body), req.Body)) return nil } diff --git a/packages/agentproxy/rewrite_test.go b/packages/agentproxy/rewrite_test.go index 7cc13fd6..1087461b 100644 --- a/packages/agentproxy/rewrite_test.go +++ b/packages/agentproxy/rewrite_test.go @@ -167,7 +167,6 @@ func TestSubstitutionAcrossAllSurfacesInOneCredential(t *testing.T) { } func TestHeaderRewriteAndSubstitutionCombined(t *testing.T) { - // newReq's URL carries ?token=placeholder_x req := newReq(t, "") svc := &resolvedService{credentials: []resolvedCredential{ {role: roleHeaderRewrite, headerName: "Authorization", headerPrefix: "Bearer", value: "sk_live_real"}, diff --git a/packages/api/agent_proxy.go b/packages/api/agent_proxy.go index 47e0c58e..38e60d4f 100644 --- a/packages/api/agent_proxy.go +++ b/packages/api/agent_proxy.go @@ -7,8 +7,6 @@ import ( "github.com/go-resty/resty/v2" ) -// ─── Agent Proxy CA ────────────────────────────────────────────────── - type GetAgentProxyCaResponse struct { Certificate string `json:"certificate"` KeyAlgorithm string `json:"keyAlgorithm"` @@ -63,8 +61,6 @@ func CallSignAgentProxyIntermediateCa(httpClient *resty.Client, request SignAgen return res, nil } -// ─── Proxied Services ──────────────────────────────────────────────── - type ProxiedServiceCredential struct { ID string `json:"id"` SecretKey string `json:"secretKey"` diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index 29e27978..a8b7a4e5 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -50,7 +50,6 @@ var agentProxyStartCmd = &cobra.Command{ const mitmCaRelativePath = ".infisical/agent-proxy/mitm-ca.pem" -// CA-trust env vars set on the agent so it trusts the proxy's forged certs for upstream hosts. var caTrustEnvVars = []string{ "SSL_CERT_FILE", "NODE_EXTRA_CA_CERTS", @@ -60,7 +59,7 @@ var caTrustEnvVars = []string{ "DENO_CERT", } -// proxy env vars (set + stripped) — POSIX getenv returns the first match, so stale copies must be removed. +// Stripped as well as set: getenv returns the first match, so stale copies must be removed. var proxyEnvKeys = []string{ "HTTPS_PROXY", "https_proxy", @@ -72,21 +71,15 @@ var proxyEnvKeys = []string{ "OPENCLAW_PROXY_URL", } -// machine identity credential env vars stripped from the agent environment so the agent -// never sees the long-lived credentials, only the scoped short-lived JWT set below. +// Stripped so the agent never sees the long-lived MI credentials, only the scoped short-lived JWT set below. var credentialEnvKeys = []string{ util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME, util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME, util.INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN_NAME, } -// requiredNoProxy entries always bypass the proxy: loopback is machine-local and must never be -// routed to a (possibly remote) proxy, regardless of operator overrides. var requiredNoProxy = []string{"localhost", "127.0.0.1"} -// mergeNoProxy unions the required loopback defaults with operator-supplied entries (the inherited -// NO_PROXY/no_proxy env vars and the --no-proxy flag), de-duplicated, defaults first. Operators can -// add bypass hosts but cannot drop loopback. func mergeNoProxy(operatorEntries ...string) string { seen := make(map[string]bool) var merged []string @@ -148,7 +141,6 @@ func runAgentProxyConnect(cmd *cobra.Command, args []string) { httpClient := resty.New().SetAuthToken(token.Token) - // 1. Fetch the org root CA and write it to disk for the agent to trust. caResp, err := api.CallGetAgentProxyCa(httpClient) if err != nil { util.HandleError(err, "Failed to fetch the agent proxy root CA") @@ -158,13 +150,10 @@ func runAgentProxyConnect(cmd *cobra.Command, args []string) { util.HandleError(err, "Failed to write the agent proxy CA to disk") } - // 2. Fetch proxied services the agent MI can proxy → placeholder env vars. placeholderEnvs := fetchProxiedServicePlaceholders(httpClient, projectID, environment, secretPath) - // 3. Fetch regular secrets the agent MI can read (real values injected directly). realSecrets := fetchAgentRealSecrets(token, projectID, environment, secretPath) - // 4. Build the environment and launch the agent. extraNoProxy, _ := cmd.Flags().GetString("no-proxy") env := buildAgentEnv(proxyURL(proxyAddr, projectID, environment, secretPath, token.Token), caPath, token.Token, extraNoProxy, placeholderEnvs, realSecrets) @@ -173,8 +162,6 @@ func runAgentProxyConnect(cmd *cobra.Command, args []string) { } } -// resolveAgentToken authenticates the agent machine identity: it prefers universal-auth -// client id/secret (flags or env vars) and falls back to an already-issued token. func resolveAgentToken(cmd *cobra.Command) *models.TokenDetails { clientID, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-id", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}, "") clientSecret, _ := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-secret", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}, "") @@ -200,9 +187,7 @@ func resolveAgentToken(cmd *cobra.Command) *models.TokenDetails { return token } -// proxyURL builds http://:/:@host:port. -// projectId is the username; the password is "/:" (JWT last, per RFC 3986 §3.2.1). -// url.URL handles percent-encoding of the password, including slashes in the path. +// Builds http://:/:@host:port (username=projectId, password="/:", jwt last). func proxyURL(proxyAddr, projectID, environment, secretPath, jwt string) string { password := fmt.Sprintf("%s/%s:%s", environment, strings.TrimPrefix(secretPath, "/"), jwt) u := url.URL{ @@ -240,8 +225,7 @@ func fetchProxiedServicePlaceholders(httpClient *resty.Client, projectID, enviro placeholders := map[string]string{} for _, svc := range resp.Services { - // the proxy applies nothing for disabled services, so their placeholders would go - // upstream verbatim; don't inject them + // Disabled services aren't proxied, so their placeholders would reach upstream verbatim; don't inject them. if !svc.CanProxy || !svc.IsEnabled { continue } @@ -268,8 +252,6 @@ func fetchAgentRealSecrets(token *models.TokenDetails, projectID, environment, s secrets, err := util.GetAllEnvironmentVariables(params, "") if err != nil { - // A Proxy-only agent legitimately has no ReadValue permission on secrets in this path. - // This is expected, not fatal — the agent still gets proxy config + placeholders. log.Warn().Msgf("Could not fetch regular secrets (agent may lack read access): %v", err) return nil } @@ -277,7 +259,6 @@ func fetchAgentRealSecrets(token *models.TokenDetails, projectID, environment, s } func buildAgentEnv(proxy, caPath, jwt, extraNoProxy string, placeholders map[string]string, realSecrets []models.SingleEnvironmentVariable) []string { - // start from the current environment, stripping stale proxy keys and machine identity credentials stale := map[string]bool{} for _, k := range proxyEnvKeys { stale[k] = true @@ -285,8 +266,6 @@ func buildAgentEnv(proxy, caPath, jwt, extraNoProxy string, placeholders map[str for _, k := range credentialEnvKeys { stale[k] = true } - // The operator's own NO_PROXY is merged (not discarded) so they can add bypass hosts; capture the - // inherited values here, before the strip loop drops the raw copies, and fold them in below. var operatorNoProxy []string env := map[string]string{} for _, kv := range os.Environ() { @@ -303,29 +282,23 @@ func buildAgentEnv(proxy, caPath, jwt, extraNoProxy string, placeholders map[str } } - // proxy routing env["HTTPS_PROXY"] = proxy env["HTTP_PROXY"] = proxy env["NO_PROXY"] = mergeNoProxy(append(operatorNoProxy, extraNoProxy)...) env["NODE_USE_ENV_PROXY"] = "1" env["OPENCLAW_PROXY_URL"] = proxy - // CA trust for _, k := range caTrustEnvVars { env[k] = caPath } - // Infisical CLI access from within the agent. Pass the domain (without the /api suffix the child - // re-appends) so infisical commands run inside the agent target the same instance. env["INFISICAL_TOKEN"] = jwt env[util.INFISICAL_DOMAIN_ENV_NAME] = strings.TrimSuffix(config.INFISICAL_URL, "/api") - // placeholders (credential-substitution services) for k, v := range placeholders { env[k] = v } - // real secrets win over placeholder collisions for _, s := range realSecrets { if _, collides := placeholders[s.Key]; collides { log.Warn().Msgf("Secret %q shadows a proxied-service placeholder; using the real secret value", s.Key) diff --git a/packages/cmd/agent_proxy_start.go b/packages/cmd/agent_proxy_start.go index 0249c34d..70886ba7 100644 --- a/packages/cmd/agent_proxy_start.go +++ b/packages/cmd/agent_proxy_start.go @@ -20,7 +20,6 @@ func runAgentProxyStart(cmd *cobra.Command, args []string) { } pollInterval, _ := cmd.Flags().GetInt("poll-interval") - // Authenticate as the agent proxy machine identity. clientID, err := util.GetCmdFlagOrEnvWithDefaultValue(cmd, "client-id", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}, "") if err != nil || clientID == "" { util.HandleError(fmt.Errorf("agent proxy credentials required; set INFISICAL_UNIVERSAL_AUTH_CLIENT_ID / _SECRET or pass --client-id / --client-secret")) @@ -37,8 +36,6 @@ func runAgentProxyStart(cmd *cobra.Command, args []string) { log.Info().Msg(color.GreenString("Agent proxy authenticated; starting MITM proxy")) - // The proxy MI's access token has a TTL. Re-authenticate before it expires and publish the new - // token atomically, otherwise every secret fetch and CA signing call would start failing with 401. var proxyToken atomic.Value proxyToken.Store(loginResp.AccessToken) go refreshProxyToken(&proxyToken, clientID, clientSecret, loginResp.AccessTokenTTL) @@ -54,10 +51,6 @@ func runAgentProxyStart(cmd *cobra.Command, args []string) { } } -// refreshProxyToken re-authenticates the agent proxy MI before its token expires, storing the new -// token for the proxy to pick up. Re-login is idempotent and always yields a fresh, valid token. -// A failed refresh is retried on a short fixed interval so a transient outage cannot leave the -// proxy holding an expired token for another half TTL. func refreshProxyToken(token *atomic.Value, clientID, clientSecret string, ttlSeconds int) { const retryInterval = 30 * time.Second From 392116c81b3ad4bbb3617607c449d062ab28f213 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:25:28 +0530 Subject: [PATCH 16/26] feat: expand references and include imports when fetching proxied secrets The agent proxy's credential fetch and the connect wrapper's secret fetch now request reference expansion and imported secrets, matching infisical run. --- packages/agentproxy/cache.go | 2 ++ packages/cmd/agent_proxy.go | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/agentproxy/cache.go b/packages/agentproxy/cache.go index 63f39251..aa878c45 100644 --- a/packages/agentproxy/cache.go +++ b/packages/agentproxy/cache.go @@ -155,6 +155,8 @@ func (a *agentCache) fetchSecretValues(scope agentScope) (map[string]string, err WorkspaceId: scope.projectID, SecretsPath: scope.secretPath, UniversalAuthAccessToken: a.proxyToken(), + ExpandSecretReferences: true, + IncludeImport: true, } secrets, err := util.GetAllEnvironmentVariables(params, "") if err != nil { diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index a8b7a4e5..66a51bc4 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -240,9 +240,11 @@ func fetchProxiedServicePlaceholders(httpClient *resty.Client, projectID, enviro func fetchAgentRealSecrets(token *models.TokenDetails, projectID, environment, secretPath string) []models.SingleEnvironmentVariable { params := models.GetAllSecretsParameters{ - Environment: environment, - WorkspaceId: projectID, - SecretsPath: secretPath, + Environment: environment, + WorkspaceId: projectID, + SecretsPath: secretPath, + ExpandSecretReferences: true, + IncludeImport: true, } if token.Type == util.SERVICE_TOKEN_IDENTIFIER { params.InfisicalToken = token.Token From 4dbe55a26b57f096677af6a200b89087265872f3 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:27:35 +0530 Subject: [PATCH 17/26] feat: match IPv6 host patterns in the agent proxy --- packages/agentproxy/match.go | 25 ++++++++++++++++++++- packages/agentproxy/match_test.go | 37 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/agentproxy/match.go b/packages/agentproxy/match.go index 10d6a91a..c370bb58 100644 --- a/packages/agentproxy/match.go +++ b/packages/agentproxy/match.go @@ -1,6 +1,7 @@ package agentproxy import ( + "net" "strings" ) @@ -23,6 +24,18 @@ func parseHostPatterns(raw string) []hostPattern { p.path = part[idx:] part = part[:idx] } + // bracketed IPv6 ([::1] or [2001:db8::1]:8443): brackets disambiguate the port colon, and the + // host is stored unbracketed to match the incoming hostname (parseConnectTarget strips brackets) + if strings.HasPrefix(part, "[") { + if end := strings.Index(part, "]"); end != -1 { + p.host = part[1:end] + if rest := part[end+1:]; strings.HasPrefix(rest, ":") { + p.port = rest[1:] + } + patterns = append(patterns, p) + continue + } + } if idx := strings.LastIndex(part, ":"); idx != -1 { p.port = part[idx+1:] part = part[:idx] @@ -70,7 +83,7 @@ func (p hostPattern) match(host, port, path string) (bool, matchDetail) { return false, detail } } else { - if patternHost != host { + if !hostsEqual(patternHost, host) { return false, detail } detail.exactHost = true @@ -94,6 +107,16 @@ func (p hostPattern) match(host, port, path string) (bool, matchDetail) { return true, detail } +// hostsEqual compares hosts by value when both are IP literals, so IPv6 forms like ::1 and +// 0:0:0:0:0:0:0:1 match regardless of how the pattern was written; otherwise it is a plain compare. +func hostsEqual(a, b string) bool { + if a == b { + return true + } + ipA, ipB := net.ParseIP(a), net.ParseIP(b) + return ipA != nil && ipB != nil && ipA.Equal(ipB) +} + // Full ties are broken by lexicographically-smallest service name so the winner is deterministic regardless of the unordered list-endpoint result. func bestMatch(services []*resolvedService, host, port, path string) *resolvedService { var best *resolvedService diff --git a/packages/agentproxy/match_test.go b/packages/agentproxy/match_test.go index 187a1a51..df94f012 100644 --- a/packages/agentproxy/match_test.go +++ b/packages/agentproxy/match_test.go @@ -113,3 +113,40 @@ func TestDisabledServiceNotMatched(t *testing.T) { t.Fatal("disabled service should not match") } } + +func TestParseHostPatternsIPv6(t *testing.T) { + patterns := parseHostPatterns("[2001:db8::1]:8443/api/*, [::1]") + if len(patterns) != 2 { + t.Fatalf("expected 2 patterns, got %d", len(patterns)) + } + // host is stored unbracketed to match the incoming (bracket-stripped) hostname + if patterns[0].host != "2001:db8::1" || patterns[0].port != "8443" || patterns[0].path != "/api/*" { + t.Fatalf("unexpected IPv6 parse: %+v", patterns[0]) + } + if patterns[1].host != "::1" || patterns[1].port != "" { + t.Fatalf("unexpected IPv6 parse: %+v", patterns[1]) + } +} + +func TestIPv6HostMatching(t *testing.T) { + // incoming host is bracket-stripped (as parseConnectTarget returns it) + loopback := svc("loopback", "[::1]") + if got := bestMatch([]*resolvedService{loopback}, "::1", "443", "/"); got == nil { + t.Fatal("expected [::1] pattern to match incoming ::1") + } + // a non-canonical written form must still match by IP value + expanded := svc("expanded", "[0:0:0:0:0:0:0:1]") + if got := bestMatch([]*resolvedService{expanded}, "::1", "443", "/"); got == nil { + t.Fatal("expected expanded IPv6 form to match ::1") + } +} + +func TestIPv6PortMatching(t *testing.T) { + s := svc("withPort", "[2001:db8::1]:8443") + if got := bestMatch([]*resolvedService{s}, "2001:db8::1", "8443", "/"); got == nil { + t.Fatal("expected match on IPv6 host + port") + } + if got := bestMatch([]*resolvedService{s}, "2001:db8::1", "443", "/"); got != nil { + t.Fatal("should not match a different port") + } +} From 486c678e4f3d61b252a3233af526ea4d62af263b Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:50:38 +0530 Subject: [PATCH 18/26] fix(agentproxy): bound request header size to prevent memory exhaustion --- packages/agentproxy/header_limit_test.go | 32 ++++++++++++++ packages/agentproxy/proxy.go | 54 ++++++++++++++++++++++-- 2 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 packages/agentproxy/header_limit_test.go diff --git a/packages/agentproxy/header_limit_test.go b/packages/agentproxy/header_limit_test.go new file mode 100644 index 00000000..68ba7674 --- /dev/null +++ b/packages/agentproxy/header_limit_test.go @@ -0,0 +1,32 @@ +package agentproxy + +import ( + "bufio" + "fmt" + "net/http" + "strings" + "testing" + "time" +) + +// A client sending a header block larger than maxRequestHeaderBytes (before auth) must be rejected +// with 431 rather than allowed to grow proxy memory unbounded. +func TestOversizedRequestHeadersRejected(t *testing.T) { + jwt := "test.jwt.token" + scope := agentScope{projectID: "proj", environment: "prod", secretPath: "/"} + client := newTestProxy(t, UnmatchedAllow, jwt, scope, nil) + + go func() { + huge := strings.Repeat("a", maxRequestHeaderBytes+4096) + _, _ = fmt.Fprintf(client, "GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nX-Big: %s\r\n\r\n", huge) + }() + + _ = client.SetReadDeadline(time.Now().Add(5 * time.Second)) + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatalf("expected a 431 response, got read error: %v", err) + } + if resp.StatusCode != http.StatusRequestHeaderFieldsTooLarge { + t.Fatalf("expected status %d, got %d", http.StatusRequestHeaderFieldsTooLarge, resp.StatusCode) + } +} diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index 0619ed46..16456f0a 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -24,9 +24,44 @@ const ( connectReadTimeout = 30 * time.Second tlsHandshakeTimeout = 10 * time.Second idleTunnelTimeout = 5 * time.Minute + // maxRequestHeaderBytes bounds a single request's header block. http.ReadRequest, unlike + // http.Server, applies no MaxHeaderBytes, so without this an unauthenticated client could send + // unbounded headers and exhaust proxy memory. Matches Go's http.DefaultMaxHeaderBytes (1 MB). + maxRequestHeaderBytes = 1 << 20 ) -var errHostBlocked = errors.New("host blocked by policy") +var ( + errHostBlocked = errors.New("host blocked by policy") + errHeaderTooLarge = errors.New("request header exceeds limit") +) + +// headerLimitedReader caps bytes read while an HTTP request's headers are parsed, then switches to +// unlimited for the body. Reused across keep-alive requests on the same connection by re-arming the +// limit before each http.ReadRequest. +type headerLimitedReader struct { + r io.Reader + remaining int64 + limited bool +} + +func (h *headerLimitedReader) Read(p []byte) (int, error) { + if h.limited { + if h.remaining <= 0 { + return 0, errHeaderTooLarge + } + if int64(len(p)) > h.remaining { + p = p[:h.remaining] + } + } + n, err := h.r.Read(p) + if h.limited { + h.remaining -= int64(n) + } + return n, err +} + +func (h *headerLimitedReader) armHeaderLimit() { h.remaining = maxRequestHeaderBytes; h.limited = true } +func (h *headerLimitedReader) releaseLimit() { h.limited = false } type Options struct { Port int @@ -120,14 +155,20 @@ func (ps *proxyServer) pollLoop() { func (ps *proxyServer) handleConn(clientConn net.Conn) { defer clientConn.Close() - reader := bufio.NewReader(clientConn) + limited := &headerLimitedReader{r: clientConn} + reader := bufio.NewReader(limited) readTimeout := connectReadTimeout for { _ = clientConn.SetReadDeadline(time.Now().Add(readTimeout)) + limited.armHeaderLimit() req, err := http.ReadRequest(reader) if err != nil { + if errors.Is(err, errHeaderTooLarge) { + writeHTTPError(clientConn, http.StatusRequestHeaderFieldsTooLarge, errHeaderTooLarge.Error()) + } return } + limited.releaseLimit() _ = clientConn.SetReadDeadline(time.Time{}) if req.Method == http.MethodConnect { @@ -243,17 +284,22 @@ func parseConnectTarget(target string) (hostname, port string, err error) { } func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string, scope agentScope) { - tlsReader := bufio.NewReader(tlsConn) + limited := &headerLimitedReader{r: tlsConn} + tlsReader := bufio.NewReader(limited) for { _ = tlsConn.SetReadDeadline(time.Now().Add(idleTunnelTimeout)) + limited.armHeaderLimit() req, err := http.ReadRequest(tlsReader) if err != nil { - if !errors.Is(err, io.EOF) { + if errors.Is(err, errHeaderTooLarge) { + writeHTTPError(tlsConn, http.StatusRequestHeaderFieldsTooLarge, errHeaderTooLarge.Error()) + } else if !errors.Is(err, io.EOF) { log.Debug().Err(err).Msg("tunnel read ended") } return } + limited.releaseLimit() _ = tlsConn.SetReadDeadline(time.Time{}) resp, err := ps.forward(req, "https", hostname, port, jwt, scope) From f2ed42753bd84fcb940d3e739f4b5391aaab4892 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:24:13 +0530 Subject: [PATCH 19/26] fix(agentproxy): cap resolution cache with LRU eviction to bound memory --- packages/agentproxy/cache.go | 35 +++++++++++++++++++++ packages/agentproxy/cache_test.go | 51 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 packages/agentproxy/cache_test.go diff --git a/packages/agentproxy/cache.go b/packages/agentproxy/cache.go index aa878c45..d90fd954 100644 --- a/packages/agentproxy/cache.go +++ b/packages/agentproxy/cache.go @@ -24,6 +24,12 @@ func isAuthError(err error) bool { const agentInactiveTTL = 10 * time.Minute +// maxAgentCacheEntries bounds the resolution cache. Its key includes the client-supplied scope +// (project/env/path), so without a cap an authenticated agent could vary the scope indefinitely and +// grow the map until the proxy runs out of memory. When full, the least-recently-seen entry is evicted, +// so actively-used agents keep their cache and only idle scopes are dropped. +const maxAgentCacheEntries = 4096 + type resolvedCredential struct { role string headerName string @@ -97,11 +103,40 @@ func (a *agentCache) get(jwt string, scope agentScope) ([]*resolvedService, erro lastSeen: time.Now(), } a.mu.Lock() + a.evictIfFullLocked(key) a.entries[key] = entry a.mu.Unlock() return resolved, nil } +// evictIfFullLocked makes room for a new entry when the cache is at capacity. Callers must hold a.mu. +// It first drops inactive entries (the same threshold refreshActive uses), then, if still full, evicts +// the least-recently-seen entry so active agents keep their cached credentials. +func (a *agentCache) evictIfFullLocked(incoming string) { + if len(a.entries) < maxAgentCacheEntries { + return + } + if _, replacing := a.entries[incoming]; replacing { + return + } + now := time.Now() + for key, entry := range a.entries { + if now.Sub(entry.lastSeen) > agentInactiveTTL { + delete(a.entries, key) + } + } + for len(a.entries) >= maxAgentCacheEntries { + var oldestKey string + var oldest time.Time + for key, entry := range a.entries { + if oldestKey == "" || entry.lastSeen.Before(oldest) { + oldestKey, oldest = key, entry.lastSeen + } + } + delete(a.entries, oldestKey) + } +} + func (a *agentCache) resolve(jwt string, scope agentScope) ([]*resolvedService, error) { agentClient := resty.New().SetAuthToken(jwt) listResp, err := api.CallListProxiedServices(agentClient, api.ListProxiedServicesRequest{ diff --git a/packages/agentproxy/cache_test.go b/packages/agentproxy/cache_test.go new file mode 100644 index 00000000..7f320eb7 --- /dev/null +++ b/packages/agentproxy/cache_test.go @@ -0,0 +1,51 @@ +package agentproxy + +import ( + "fmt" + "testing" + "time" +) + +// When the cache is full, inserting a new scope must evict the least-recently-seen entry (keeping +// actively-used agents cached) rather than reject the new one or grow the map without bound. +func TestAgentCacheEvictsLeastRecentlySeenWhenFull(t *testing.T) { + a := newAgentCache(func() string { return "" }) + + base := time.Now() + for i := 0; i < maxAgentCacheEntries; i++ { + // k0 is the most recently seen (active); higher indices are progressively staler, all well + // within agentInactiveTTL so the inactive-cleanup pass leaves them alone and LRU decides. + a.entries[fmt.Sprintf("k%d", i)] = &agentEntry{lastSeen: base.Add(-time.Duration(i) * time.Millisecond)} + } + + active := "k0" + oldest := fmt.Sprintf("k%d", maxAgentCacheEntries-1) + + a.evictIfFullLocked("new-scope") + + if len(a.entries) >= maxAgentCacheEntries { + t.Fatalf("expected room for a new entry after eviction, still have %d", len(a.entries)) + } + if _, ok := a.entries[oldest]; ok { + t.Fatalf("expected least-recently-seen entry %q to be evicted", oldest) + } + if _, ok := a.entries[active]; !ok { + t.Fatalf("expected active entry %q to survive eviction", active) + } +} + +// A key already present must not trigger eviction — updating an existing scope isn't growth. +func TestAgentCacheNoEvictionWhenReplacing(t *testing.T) { + a := newAgentCache(func() string { return "" }) + + base := time.Now() + for i := 0; i < maxAgentCacheEntries; i++ { + a.entries[fmt.Sprintf("k%d", i)] = &agentEntry{lastSeen: base.Add(-time.Duration(i) * time.Millisecond)} + } + + a.evictIfFullLocked("k5") + + if len(a.entries) != maxAgentCacheEntries { + t.Fatalf("expected no eviction when replacing an existing key, size changed to %d", len(a.entries)) + } +} From cbf540aed7f9f999bfbd5dc553719d69e3e703f3 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:41:55 +0530 Subject: [PATCH 20/26] refactor(agentproxy): serve via http.Server with connection cap and lifecycle timeouts --- packages/agentproxy/connect_live_test.go | 94 ++++++ packages/agentproxy/connect_test.go | 207 ++++++++++++ packages/agentproxy/forward_test.go | 9 +- packages/agentproxy/limit_listener_test.go | 65 ++++ packages/agentproxy/proxy.go | 363 ++++++++++++--------- 5 files changed, 576 insertions(+), 162 deletions(-) create mode 100644 packages/agentproxy/connect_live_test.go create mode 100644 packages/agentproxy/connect_test.go create mode 100644 packages/agentproxy/limit_listener_test.go diff --git a/packages/agentproxy/connect_live_test.go b/packages/agentproxy/connect_live_test.go new file mode 100644 index 00000000..1eb6bf33 --- /dev/null +++ b/packages/agentproxy/connect_live_test.go @@ -0,0 +1,94 @@ +package agentproxy + +import ( + "crypto/tls" + "crypto/x509" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" +) + +// Exercises the full production serve path over real TCP sockets (not net.Pipe): a real http.Client issues +// an HTTPS request through the proxy, which triggers CONNECT → hijack → MITM TLS → forward → real RoundTrip +// to a real TLS upstream. Asserts the injected credential reaches the upstream on the wire. +func TestConnectTunnelLiveOverTCP(t *testing.T) { + upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.Header.Get("Authorization")) + })) + defer upstream.Close() + + u, err := url.Parse(upstream.URL) + if err != nil { + t.Fatal(err) + } + host := u.Hostname() + + jwt := "test.jwt.token" + scope := agentScope{projectID: "proj", environment: "prod", secretPath: "/"} + services := []*resolvedService{{ + name: "echo", + hostPatterns: parseHostPatterns(host), + isEnabled: true, + credentials: []resolvedCredential{ + {role: roleHeaderRewrite, headerName: "Authorization", headerPrefix: "Bearer", value: "real_secret"}, + }, + }} + + ca, interCert := newTestCA(t) + cache := newAgentCache(func() string { return "" }) + cache.entries[cacheKey(jwt, scope)] = &agentEntry{jwt: jwt, scope: scope, services: services, lastSeen: time.Now()} + + // Upstream transport must trust the httptest upstream's real cert (the proxy does a real TLS leg to it). + upstreamPool := x509.NewCertPool() + upstreamPool.AddCert(upstream.Certificate()) + ps := &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedAllow}, + ca: ca, + cache: cache, + transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: upstreamPool}, + TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}, + }, + } + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + go func() { _ = ps.newFrontServer().Serve(newLimitListener(ln, maxConcurrentConns)) }() + + proxyURL, err := url.Parse("http://" + ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + // Client trusts the MITM CA and routes through the proxy, sending Proxy-Authorization on the CONNECT. + clientPool := x509.NewCertPool() + clientPool.AddCert(interCert) + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + Proxy: http.ProxyURL(proxyURL), + ProxyConnectHeader: http.Header{"Proxy-Authorization": {proxyAuthHeader("proj", "prod", "/", jwt)}}, + TLSClientConfig: &tls.Config{RootCAs: clientPool}, + }, + } + + resp, err := client.Get(upstream.URL + "/v1/charges") + if err != nil { + t.Fatalf("proxied request failed: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + // The client never sent Authorization; the proxy injected it on the wire, so the upstream echoes it back. + if string(body) != "Bearer real_secret" { + t.Fatalf("upstream did not receive injected credential; got %q", body) + } +} diff --git a/packages/agentproxy/connect_test.go b/packages/agentproxy/connect_test.go new file mode 100644 index 00000000..4f96f1db --- /dev/null +++ b/packages/agentproxy/connect_test.go @@ -0,0 +1,207 @@ +package agentproxy + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "io" + "math/big" + "net" + "net/http" + "strings" + "sync" + "testing" + "time" +) + +// newTestCA builds a caManager with a locally self-signed intermediate so mintLeaf works offline +// (ensureIntermediate skips the API when an unexpired intermediate is already set). Returns the +// intermediate cert so the client can trust the minted leaf chain. +func newTestCA(t *testing.T) (*caManager, *x509.Certificate) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test-intermediate"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(48 * time.Hour), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatal(err) + } + return &caManager{ + intermediateKey: key, + intermediateCert: cert, + intermediateExp: cert.NotAfter, + leafCache: make(map[string]*leafEntry), + }, cert +} + +// stubRoundTripper records the Authorization header of each forwarded request and returns a canned 200, +// so the tunnel path can be exercised without a live TLS upstream. +type stubRoundTripper struct { + mu sync.Mutex + gotAuth []string + respBody string +} + +func (s *stubRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { + s.mu.Lock() + s.gotAuth = append(s.gotAuth, r.Header.Get("Authorization")) + s.mu.Unlock() + return &http.Response{ + StatusCode: http.StatusOK, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(s.respBody)), + ContentLength: int64(len(s.respBody)), + Request: r, + }, nil +} + +// Drives a full CONNECT → TLS-terminate → tunneled HTTP request through the new http.Server machinery +// (hijack, one-shot listener, inner server) and asserts credentials are injected and keep-alive works. +func TestConnectTunnelInjectsCredentialsAndKeepsAlive(t *testing.T) { + jwt := "test.jwt.token" + scope := agentScope{projectID: "proj", environment: "prod", secretPath: "/"} + services := []*resolvedService{{ + name: "stripe", + hostPatterns: parseHostPatterns("example.com"), + isEnabled: true, + credentials: []resolvedCredential{ + {role: roleHeaderRewrite, headerName: "Authorization", headerPrefix: "Bearer", value: "real_secret"}, + }, + }} + + ca, interCert := newTestCA(t) + stub := &stubRoundTripper{respBody: "ok"} + cache := newAgentCache(func() string { return "" }) + cache.entries[cacheKey(jwt, scope)] = &agentEntry{jwt: jwt, scope: scope, services: services, lastSeen: time.Now()} + ps := &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedAllow}, + ca: ca, + cache: cache, + transport: stub, + } + + client, server := net.Pipe() + l := newOneShotListener(server) + srv := ps.newFrontServer() + srv.ConnState = func(_ net.Conn, s http.ConnState) { + if s == http.StateClosed || s == http.StateHijacked { + _ = l.Close() + } + } + go func() { _ = srv.Serve(l) }() + t.Cleanup(func() { _ = client.Close() }) + + _ = client.SetDeadline(time.Now().Add(10 * time.Second)) + + // 1. CONNECT and read the raw tunnel-established response (exact bytes, so we don't consume TLS data). + _, err := fmt.Fprintf(client, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\nProxy-Authorization: %s\r\n\r\n", + proxyAuthHeader("proj", "prod", "/", jwt)) + if err != nil { + t.Fatal(err) + } + established := "HTTP/1.1 200 Connection Established\r\n\r\n" + buf := make([]byte, len(established)) + if _, err := io.ReadFull(client, buf); err != nil { + t.Fatalf("reading CONNECT response: %v", err) + } + if string(buf) != established { + t.Fatalf("unexpected CONNECT response: %q", buf) + } + + // 2. TLS handshake against the MITM leaf, trusting our local intermediate. + pool := x509.NewCertPool() + pool.AddCert(interCert) + tlsClient := tls.Client(client, &tls.Config{ServerName: "example.com", RootCAs: pool}) + if err := tlsClient.Handshake(); err != nil { + t.Fatalf("TLS handshake: %v", err) + } + + // 3. Two tunneled requests over the same connection (keep-alive), asserting credential injection. + reader := bufio.NewReader(tlsClient) + for i := 0; i < 2; i++ { + if _, err := io.WriteString(tlsClient, "GET /v1/charges HTTP/1.1\r\nHost: example.com\r\n\r\n"); err != nil { + t.Fatalf("writing tunneled request %d: %v", i, err) + } + resp, err := http.ReadResponse(reader, nil) + if err != nil { + t.Fatalf("reading tunneled response %d: %v", i, err) + } + if resp.StatusCode != http.StatusOK { + t.Fatalf("request %d: expected 200, got %d", i, resp.StatusCode) + } + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + + stub.mu.Lock() + defer stub.mu.Unlock() + if len(stub.gotAuth) != 2 { + t.Fatalf("expected 2 forwarded requests, got %d", len(stub.gotAuth)) + } + for i, auth := range stub.gotAuth { + if auth != "Bearer real_secret" { + t.Fatalf("request %d: expected injected 'Bearer real_secret', got %q", i, auth) + } + } +} + +// A CONNECT with no Proxy-Authorization must be answered with 407 (and the Basic challenge) before any +// hijack — exercising the pre-hijack error path through the ResponseWriter. +func TestConnectRequiresProxyAuth(t *testing.T) { + ca, _ := newTestCA(t) + cache := newAgentCache(func() string { return "" }) + ps := &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedAllow}, + ca: ca, + cache: cache, + transport: &stubRoundTripper{}, + } + + client, server := net.Pipe() + l := newOneShotListener(server) + srv := ps.newFrontServer() + srv.ConnState = func(_ net.Conn, s http.ConnState) { + if s == http.StateClosed || s == http.StateHijacked { + _ = l.Close() + } + } + go func() { _ = srv.Serve(l) }() + t.Cleanup(func() { _ = client.Close() }) + + _ = client.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err := io.WriteString(client, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n"); err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatalf("reading response: %v", err) + } + if resp.StatusCode != http.StatusProxyAuthRequired { + t.Fatalf("expected 407, got %d", resp.StatusCode) + } + if got := resp.Header.Get("Proxy-Authenticate"); got != "Basic" { + t.Fatalf("expected Proxy-Authenticate: Basic, got %q", got) + } +} diff --git a/packages/agentproxy/forward_test.go b/packages/agentproxy/forward_test.go index b6a23404..0b0c6d5c 100644 --- a/packages/agentproxy/forward_test.go +++ b/packages/agentproxy/forward_test.go @@ -34,7 +34,14 @@ func newTestProxy(t *testing.T, unmatchedHost, jwt string, scope agentScope, ser transport: &http.Transport{}, } client, server := net.Pipe() - go ps.handleConn(server) + l := newOneShotListener(server) + srv := ps.newFrontServer() + srv.ConnState = func(_ net.Conn, s http.ConnState) { + if s == http.StateClosed || s == http.StateHijacked { + _ = l.Close() + } + } + go func() { _ = srv.Serve(l) }() t.Cleanup(func() { _ = client.Close() }) return client } diff --git a/packages/agentproxy/limit_listener_test.go b/packages/agentproxy/limit_listener_test.go new file mode 100644 index 00000000..851a9eb5 --- /dev/null +++ b/packages/agentproxy/limit_listener_test.go @@ -0,0 +1,65 @@ +package agentproxy + +import ( + "net" + "testing" + "time" +) + +// limitListener must cap concurrent accepted connections: at capacity, Accept does not yield a new +// connection until a previously accepted one is closed and frees its slot. +func TestLimitListenerCapsConcurrentConns(t *testing.T) { + base, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer base.Close() + + ll := newLimitListener(base, 1) + addr := base.Addr().String() + + accepted := make(chan net.Conn, 4) + go func() { + for { + c, err := ll.Accept() + if err != nil { + return + } + accepted <- c + } + }() + + c1, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer c1.Close() + + var a1 net.Conn + select { + case a1 = <-accepted: + case <-time.After(2 * time.Second): + t.Fatal("first connection was not accepted") + } + + // The TCP connect may complete via the kernel backlog, but Accept must not yield it while at capacity. + c2, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer c2.Close() + select { + case <-accepted: + t.Fatal("second connection accepted while at capacity") + case <-time.After(300 * time.Millisecond): + } + + // Freeing the first slot lets the second through. + _ = a1.Close() + select { + case a2 := <-accepted: + _ = a2.Close() + case <-time.After(2 * time.Second): + t.Fatal("second connection not accepted after slot freed") + } +} diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index 16456f0a..a6a7b3b0 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -1,7 +1,6 @@ package agentproxy import ( - "bufio" "crypto/tls" "encoding/base64" "errors" @@ -10,6 +9,7 @@ import ( "net" "net/http" "strings" + "sync" "time" "github.com/rs/zerolog/log" @@ -21,47 +21,32 @@ const ( ) const ( - connectReadTimeout = 30 * time.Second tlsHandshakeTimeout = 10 * time.Second - idleTunnelTimeout = 5 * time.Minute - // maxRequestHeaderBytes bounds a single request's header block. http.ReadRequest, unlike - // http.Server, applies no MaxHeaderBytes, so without this an unauthenticated client could send - // unbounded headers and exhaust proxy memory. Matches Go's http.DefaultMaxHeaderBytes (1 MB). + + // Outer ingress server. Only header/idle timeouts: no ReadTimeout/WriteTimeout so a CONNECT hijack and + // long streaming responses aren't cut off. Plaintext (http://) relay is therefore not time-bounded here; + // the concurrent-connection cap (maxConcurrentConns) bounds how many connections can pin at once. + frontReadHeaderTimeout = 30 * time.Second + frontIdleTimeout = 5 * time.Minute + + // Inner per-tunnel server. WriteTimeout is roomy for streaming (e.g. large downloads); the read/idle + // timeouts bound slow-loris and pinned connections now that deadlines aren't hand-managed. + tunnelReadHeaderTimeout = 10 * time.Second + tunnelReadTimeout = 60 * time.Second + tunnelWriteTimeout = 30 * time.Minute + tunnelIdleTimeout = 2 * time.Minute + + // maxRequestHeaderBytes bounds a single request's header block via http.Server.MaxHeaderBytes, so an + // unauthenticated client can't send unbounded headers and exhaust proxy memory. Matches Go's + // http.DefaultMaxHeaderBytes (1 MB). maxRequestHeaderBytes = 1 << 20 -) -var ( - errHostBlocked = errors.New("host blocked by policy") - errHeaderTooLarge = errors.New("request header exceeds limit") + // maxConcurrentConns caps simultaneous client connections so a flood of sockets can't exhaust file + // descriptors or goroutines. A live tunnel holds its slot for the tunnel's lifetime. + maxConcurrentConns = 512 ) -// headerLimitedReader caps bytes read while an HTTP request's headers are parsed, then switches to -// unlimited for the body. Reused across keep-alive requests on the same connection by re-arming the -// limit before each http.ReadRequest. -type headerLimitedReader struct { - r io.Reader - remaining int64 - limited bool -} - -func (h *headerLimitedReader) Read(p []byte) (int, error) { - if h.limited { - if h.remaining <= 0 { - return 0, errHeaderTooLarge - } - if int64(len(p)) > h.remaining { - p = p[:h.remaining] - } - } - n, err := h.r.Read(p) - if h.limited { - h.remaining -= int64(n) - } - return n, err -} - -func (h *headerLimitedReader) armHeaderLimit() { h.remaining = maxRequestHeaderBytes; h.limited = true } -func (h *headerLimitedReader) releaseLimit() { h.limited = false } +var errHostBlocked = errors.New("host blocked by policy") type Options struct { Port int @@ -74,7 +59,16 @@ type proxyServer struct { opts Options ca *caManager cache *agentCache - transport *http.Transport + transport http.RoundTripper +} + +func newProxyServer(opts Options) *proxyServer { + return &proxyServer{ + opts: opts, + ca: newCaManager(opts.ProxyToken), + cache: newAgentCache(opts.ProxyToken), + transport: newUpstreamTransport(), + } } // Forces HTTP/1.1: h2 responses have no HTTP/1.1 length framing and would hang the re-serialized MITM tunnel; a non-nil empty TLSNextProto is what actually disables h2. @@ -90,13 +84,19 @@ func newUpstreamTransport() *http.Transport { } } -func Start(opts Options) error { - ps := &proxyServer{ - opts: opts, - ca: newCaManager(opts.ProxyToken), - cache: newAgentCache(opts.ProxyToken), - transport: newUpstreamTransport(), +// newFrontServer builds the ingress http.Server. Shared by Start and the test harness so timeout and +// MaxHeaderBytes settings can't drift between them. +func (ps *proxyServer) newFrontServer() *http.Server { + return &http.Server{ + Handler: http.HandlerFunc(ps.dispatch), + ReadHeaderTimeout: frontReadHeaderTimeout, + IdleTimeout: frontIdleTimeout, + MaxHeaderBytes: maxRequestHeaderBytes, } +} + +func Start(opts Options) error { + ps := newProxyServer(opts) if err := ps.ca.ensureIntermediate(); err != nil { return fmt.Errorf("failed to initialize agent proxy CA: %w", err) @@ -114,19 +114,7 @@ func Start(opts Options) error { } log.Info().Msgf("Infisical agent proxy listening on :%d", opts.Port) - for { - conn, err := listener.Accept() - if err != nil { - if errors.Is(err, net.ErrClosed) { - log.Info().Msg("agent proxy listener closed; shutting down") - return nil - } - log.Warn().Err(err).Msg("failed to accept connection") - time.Sleep(100 * time.Millisecond) - continue - } - go ps.handleConn(conn) - } + return ps.newFrontServer().Serve(newLimitListener(listener, maxConcurrentConns)) } func portInUse(port int) string { @@ -152,64 +140,54 @@ func (ps *proxyServer) pollLoop() { } } -func (ps *proxyServer) handleConn(clientConn net.Conn) { - defer clientConn.Close() - - limited := &headerLimitedReader{r: clientConn} - reader := bufio.NewReader(limited) - readTimeout := connectReadTimeout - for { - _ = clientConn.SetReadDeadline(time.Now().Add(readTimeout)) - limited.armHeaderLimit() - req, err := http.ReadRequest(reader) - if err != nil { - if errors.Is(err, errHeaderTooLarge) { - writeHTTPError(clientConn, http.StatusRequestHeaderFieldsTooLarge, errHeaderTooLarge.Error()) - } - return - } - limited.releaseLimit() - _ = clientConn.SetReadDeadline(time.Time{}) - - if req.Method == http.MethodConnect { - ps.handleConnect(clientConn, req) - return - } - if !ps.handlePlainForward(clientConn, req) { - return - } - readTimeout = idleTunnelTimeout +func (ps *proxyServer) dispatch(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + ps.handleConnect(w, r) + return } + ps.handlePlainForward(w, r) } -func (ps *proxyServer) handleConnect(clientConn net.Conn, req *http.Request) { - scope, jwt, ok := parseProxyAuth(req.Header.Get("Proxy-Authorization")) +func (ps *proxyServer) handleConnect(w http.ResponseWriter, r *http.Request) { + // All authentication and HTTP error responses happen before Hijack: once hijacked, no HTTP status can be sent. + scope, jwt, ok := parseProxyAuth(r.Header.Get("Proxy-Authorization")) if !ok { - writeProxyAuthRequired(clientConn) + writeProxyAuthChallenge(w) return } - hostname, port, err := parseConnectTarget(req.Host) + hostname, port, err := parseConnectTarget(r.Host) if err != nil { - writeProxyResponse(clientConn, http.StatusBadRequest, fmt.Sprintf("invalid CONNECT target %q", req.Host)) + http.Error(w, fmt.Sprintf("invalid CONNECT target %q", r.Host), http.StatusBadRequest) return } // Authenticate before minting: otherwise any syntactically valid Proxy-Authorization header forces unbounded key generation and leaf-cache growth. if _, err := ps.cache.get(jwt, scope); err != nil { if isAuthError(err) { - writeProxyResponse(clientConn, http.StatusForbidden, "proxy authorization failed") + http.Error(w, "proxy authorization failed", http.StatusForbidden) } else { - writeProxyResponse(clientConn, http.StatusBadGateway, "failed to resolve agent permissions") + http.Error(w, "failed to resolve agent permissions", http.StatusBadGateway) } return } leaf, err := ps.ca.mintLeaf(hostname) if err != nil { - writeProxyResponse(clientConn, http.StatusInternalServerError, "failed to mint certificate") + http.Error(w, "failed to mint certificate", http.StatusInternalServerError) + return + } + + hijacker, ok := w.(http.Hijacker) + if !ok { + http.Error(w, "connection hijacking unsupported", http.StatusInternalServerError) + return + } + clientConn, _, err := hijacker.Hijack() + if err != nil { return } + defer clientConn.Close() if _, err := clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil { return @@ -218,57 +196,106 @@ func (ps *proxyServer) handleConnect(clientConn net.Conn, req *http.Request) { tlsConn := tls.Server(clientConn, &tls.Config{ Certificates: []tls.Certificate{leaf}, MinVersion: tls.VersionTLS12, + NextProtos: []string{"http/1.1"}, }) + // The handshake runs on the hijacked conn before the inner server, so no server timeout covers it. _ = tlsConn.SetDeadline(time.Now().Add(tlsHandshakeTimeout)) if err := tlsConn.Handshake(); err != nil { return } _ = tlsConn.SetDeadline(time.Time{}) - defer tlsConn.Close() ps.serveTunnel(tlsConn, hostname, port, jwt, scope) } +// serveTunnel serves HTTP/1.1 requests off the decrypted MITM connection using a fresh http.Server over a +// one-shot listener, so the tunnel gets the same header/timeout enforcement as the ingress. +func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string, scope agentScope) { + listener := newOneShotListener(tlsConn) + srv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ps.forwardHTTP(w, r, "https", hostname, port, jwt, scope) + }), + ReadHeaderTimeout: tunnelReadHeaderTimeout, + ReadTimeout: tunnelReadTimeout, + WriteTimeout: tunnelWriteTimeout, + IdleTimeout: tunnelIdleTimeout, + MaxHeaderBytes: maxRequestHeaderBytes, + // The one-shot listener yields the single conn once, then blocks; closing it on terminal conn state + // makes Serve return. The conn is owned by http.Server (Closed) or the hijack handler, not closed here. + ConnState: func(_ net.Conn, state http.ConnState) { + if state == http.StateHijacked || state == http.StateClosed { + _ = listener.Close() + } + }, + } + _ = srv.Serve(listener) +} + // Only http:// absolute-form is served; https:// is rejected so the proxy can never be used to silently TLS-strip (HTTPS must arrive as CONNECT). -func (ps *proxyServer) handlePlainForward(clientConn net.Conn, req *http.Request) bool { - if !strings.EqualFold(req.URL.Scheme, "http") || req.URL.Host == "" { - writeHTTPError(clientConn, http.StatusBadRequest, "non-CONNECT requests must be absolute-form http:// (use CONNECT for https:// upstreams)") - return false +func (ps *proxyServer) handlePlainForward(w http.ResponseWriter, r *http.Request) { + if !strings.EqualFold(r.URL.Scheme, "http") || r.URL.Host == "" { + http.Error(w, "non-CONNECT requests must be absolute-form http:// (use CONNECT for https:// upstreams)", http.StatusBadRequest) + return } - scope, jwt, ok := parseProxyAuth(req.Header.Get("Proxy-Authorization")) + scope, jwt, ok := parseProxyAuth(r.Header.Get("Proxy-Authorization")) if !ok { - writeProxyAuthRequired(clientConn) - return false + writeProxyAuthChallenge(w) + return } - hostname := req.URL.Hostname() - port := req.URL.Port() + hostname := r.URL.Hostname() + port := r.URL.Port() if port == "" { port = "80" } - if req.URL.Path == "" { - req.URL.Path = "/" + if r.URL.Path == "" { + r.URL.Path = "/" } - resp, err := ps.forward(req, "http", hostname, port, jwt, scope) + ps.forwardHTTP(w, r, "http", hostname, port, jwt, scope) +} + +// forwardHTTP resolves the upstream response and relays it to the client via the ResponseWriter. +func (ps *proxyServer) forwardHTTP(w http.ResponseWriter, r *http.Request, scheme, hostname, port, jwt string, scope agentScope) { + resp, err := ps.forward(r, scheme, hostname, port, jwt, scope) if err != nil { status := http.StatusBadGateway if errors.Is(err, errHostBlocked) { status = http.StatusForbidden } - writeHTTPError(clientConn, status, err.Error()) - return false + http.Error(w, err.Error(), status) + return } + defer resp.Body.Close() - if err := resp.Write(clientConn); err != nil { - resp.Body.Close() - return false + // Strip hop-by-hop response headers (Connection, Transfer-Encoding, etc.) before copying: resp.Write + // used to frame these itself, but the ResponseWriter owns framing now and would double-frame otherwise. + // Set-Cookie and Content-Length are deliberately preserved. + stripHopByHopHeaders(resp.Header) + dst := w.Header() + for name, values := range resp.Header { + for _, v := range values { + dst.Add(name, v) + } } - resp.Body.Close() - drainRequestBody(req) + // Relaying via ResponseWriter (rather than the old byte-transparent resp.Write) means Go adds a Date + // header and, when the upstream omitted Content-Type, sniffs one. Accepted as standard proxy behavior. + w.WriteHeader(resp.StatusCode) + // Flush per chunk so streamed responses (e.g. SSE) reach the client instead of buffering. + _, _ = io.Copy(flushingWriter{w}, resp.Body) +} - return !req.Close && !resp.Close +// flushingWriter flushes the underlying ResponseWriter after every write so streamed bodies aren't buffered. +type flushingWriter struct{ w http.ResponseWriter } + +func (fw flushingWriter) Write(p []byte) (int, error) { + n, err := fw.w.Write(p) + if f, ok := fw.w.(http.Flusher); ok { + f.Flush() + } + return n, err } func parseConnectTarget(target string) (hostname, port string, err error) { @@ -283,44 +310,6 @@ func parseConnectTarget(target string) (hostname, port string, err error) { return "", "", err } -func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string, scope agentScope) { - limited := &headerLimitedReader{r: tlsConn} - tlsReader := bufio.NewReader(limited) - - for { - _ = tlsConn.SetReadDeadline(time.Now().Add(idleTunnelTimeout)) - limited.armHeaderLimit() - req, err := http.ReadRequest(tlsReader) - if err != nil { - if errors.Is(err, errHeaderTooLarge) { - writeHTTPError(tlsConn, http.StatusRequestHeaderFieldsTooLarge, errHeaderTooLarge.Error()) - } else if !errors.Is(err, io.EOF) { - log.Debug().Err(err).Msg("tunnel read ended") - } - return - } - limited.releaseLimit() - _ = tlsConn.SetReadDeadline(time.Time{}) - - resp, err := ps.forward(req, "https", hostname, port, jwt, scope) - if err != nil { - if errors.Is(err, errHostBlocked) { - writeHTTPError(tlsConn, http.StatusForbidden, err.Error()) - } else { - writeHTTPError(tlsConn, http.StatusBadGateway, err.Error()) - } - return - } - - if err := resp.Write(tlsConn); err != nil { - resp.Body.Close() - return - } - resp.Body.Close() - drainRequestBody(req) - } -} - func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt string, scope agentScope) (*http.Response, error) { services, err := ps.cache.get(jwt, scope) if err != nil { @@ -439,21 +428,73 @@ func parseProxyAuth(header string) (agentScope, string, bool) { return agentScope{projectID: projectID, environment: environment, secretPath: secretPath}, jwt, true } -func drainRequestBody(req *http.Request) { - if req.Body != nil { - _, _ = io.Copy(io.Discard, req.Body) - _ = req.Body.Close() +func writeProxyAuthChallenge(w http.ResponseWriter) { + w.Header().Set("Proxy-Authenticate", "Basic") + http.Error(w, "proxy authentication required", http.StatusProxyAuthRequired) +} + +// oneShotListener adapts a single already-accepted connection into a net.Listener so http.Server can serve +// HTTP/1.1 (incl. keep-alive) off it. The first Accept yields the conn; later Accepts block until Close. +type oneShotListener struct { + conn net.Conn + yield chan net.Conn + closed chan struct{} + closeOnce sync.Once +} + +var errListenerClosed = errors.New("agentproxy: one-shot listener closed") + +func newOneShotListener(c net.Conn) *oneShotListener { + l := &oneShotListener{conn: c, yield: make(chan net.Conn, 1), closed: make(chan struct{})} + l.yield <- c + return l +} + +func (l *oneShotListener) Accept() (net.Conn, error) { + select { + case c := <-l.yield: + return c, nil + case <-l.closed: + return nil, errListenerClosed } } -func writeProxyAuthRequired(conn net.Conn) { - conn.Write([]byte("HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\n\r\n")) // #nosec G104 +func (l *oneShotListener) Close() error { + l.closeOnce.Do(func() { close(l.closed) }) + return nil +} + +func (l *oneShotListener) Addr() net.Addr { return l.conn.LocalAddr() } + +// limitListener caps the number of concurrent connections. Accept blocks once the limit is reached and a +// slot frees only when a served connection is closed, so a burst of sockets can't exhaust fds/goroutines. +type limitListener struct { + net.Listener + sem chan struct{} +} + +func newLimitListener(l net.Listener, n int) net.Listener { + return &limitListener{Listener: l, sem: make(chan struct{}, n)} +} + +func (l *limitListener) Accept() (net.Conn, error) { + l.sem <- struct{}{} + conn, err := l.Listener.Accept() + if err != nil { + <-l.sem + return nil, err + } + return &limitConn{Conn: conn, release: func() { <-l.sem }}, nil } -func writeProxyResponse(conn net.Conn, status int, msg string) { - fmt.Fprintf(conn, "HTTP/1.1 %d %s\r\nContent-Length: %d\r\n\r\n%s", status, http.StatusText(status), len(msg), msg) // #nosec G104 +type limitConn struct { + net.Conn + releaseOnce sync.Once + release func() } -func writeHTTPError(conn io.Writer, status int, msg string) { - fmt.Fprintf(conn, "HTTP/1.1 %d %s\r\nContent-Length: %d\r\nConnection: close\r\n\r\n%s", status, http.StatusText(status), len(msg), msg) // #nosec G104 +func (c *limitConn) Close() error { + err := c.Conn.Close() + c.releaseOnce.Do(c.release) + return err } From 194fbc7645615334430277fdb644c2075494f1a2 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:10:02 +0530 Subject: [PATCH 21/26] feat(agent-proxy): block connect when agent can read brokered secrets --- packages/cmd/agent_proxy.go | 47 ++++++++++++++++++++++++++++++-- packages/cmd/agent_proxy_test.go | 36 +++++++++++++++++++++++- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index 66a51bc4..47121d3d 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -7,6 +7,7 @@ import ( "os/exec" "os/signal" "path/filepath" + "sort" "strings" "syscall" @@ -150,10 +151,15 @@ func runAgentProxyConnect(cmd *cobra.Command, args []string) { util.HandleError(err, "Failed to write the agent proxy CA to disk") } - placeholderEnvs := fetchProxiedServicePlaceholders(httpClient, projectID, environment, secretPath) + placeholderEnvs, brokeredKeys := fetchProxiedServiceConfig(httpClient, projectID, environment, secretPath) realSecrets := fetchAgentRealSecrets(token, projectID, environment, secretPath) + allowReadableBrokered, _ := cmd.Flags().GetBool("allow-readable-brokered-secrets") + if !allowReadableBrokered { + assertNoBrokeredSecretsReadable(brokeredKeys, realSecrets) + } + extraNoProxy, _ := cmd.Flags().GetString("no-proxy") env := buildAgentEnv(proxyURL(proxyAddr, projectID, environment, secretPath, token.Token), caPath, token.Token, extraNoProxy, placeholderEnvs, realSecrets) @@ -213,7 +219,10 @@ func writeMitmCa(certificatePem string) (string, error) { return caPath, nil } -func fetchProxiedServicePlaceholders(httpClient *resty.Client, projectID, environment, secretPath string) map[string]string { +// fetchProxiedServiceConfig lists the proxied services the agent can reach and returns both the +// credential-substitution placeholders to inject and the set of secret keys those services broker. The +// brokered keys are what the agent is meant to receive only through the proxy, never as real values. +func fetchProxiedServiceConfig(httpClient *resty.Client, projectID, environment, secretPath string) (map[string]string, map[string]struct{}) { resp, err := api.CallListProxiedServices(httpClient, api.ListProxiedServicesRequest{ ProjectID: projectID, Environment: environment, @@ -224,18 +233,49 @@ func fetchProxiedServicePlaceholders(httpClient *resty.Client, projectID, enviro } placeholders := map[string]string{} + brokeredKeys := map[string]struct{}{} for _, svc := range resp.Services { // Disabled services aren't proxied, so their placeholders would reach upstream verbatim; don't inject them. if !svc.CanProxy || !svc.IsEnabled { continue } for _, cred := range svc.Credentials { + brokeredKeys[cred.SecretKey] = struct{}{} if cred.Role == "credential-substitution" && cred.PlaceholderKey != "" { placeholders[cred.PlaceholderKey] = cred.PlaceholderValue } } } - return placeholders + return placeholders, brokeredKeys +} + +// assertNoBrokeredSecretsReadable fails fast when the agent can read a secret that a proxied service +// brokers to it. Brokering is meant to keep the real value out of the agent's hands, but if the agent +// also holds ReadValue on that secret it gets the value directly (delivered here as a real secret, and +// readable straight from the API), so the protection is silently bypassed. This is a misconfiguration +// guardrail, not a security boundary: the real fix is to not grant the agent ReadValue on brokered secrets. +func readableBrokeredSecrets(brokeredKeys map[string]struct{}, realSecrets []models.SingleEnvironmentVariable) []string { + var overlap []string + for _, s := range realSecrets { + if _, ok := brokeredKeys[s.Key]; ok { + overlap = append(overlap, s.Key) + } + } + sort.Strings(overlap) + return overlap +} + +func assertNoBrokeredSecretsReadable(brokeredKeys map[string]struct{}, realSecrets []models.SingleEnvironmentVariable) { + overlap := readableBrokeredSecrets(brokeredKeys, realSecrets) + if len(overlap) == 0 { + return + } + util.HandleError(fmt.Errorf( + "the agent can read secret(s) that are brokered by a proxied service: %s\n"+ + "brokering hides these values from the agent, but it has ReadValue on them and would receive them directly, bypassing the proxy.\n"+ + "fix: remove the agent's ReadValue permission on these secrets, or stop referencing them from proxied services.\n"+ + "to start anyway, pass --allow-readable-brokered-secrets", + strings.Join(overlap, ", "))) } func fetchAgentRealSecrets(token *models.TokenDetails, projectID, environment, secretPath string) []models.SingleEnvironmentVariable { @@ -358,6 +398,7 @@ func init() { agentProxyConnectCmd.Flags().String("client-secret", "", "universal auth client secret for the agent machine identity") agentProxyConnectCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token") agentProxyConnectCmd.Flags().String("no-proxy", "", "additional comma-separated hosts to bypass the proxy (always merged with localhost,127.0.0.1)") + agentProxyConnectCmd.Flags().Bool("allow-readable-brokered-secrets", false, "start even if the agent can read secrets that proxied services broker to it (bypasses a misconfiguration guardrail)") agentProxyStartCmd.Flags().Int("port", 17322, "port for the agent proxy to listen on") agentProxyStartCmd.Flags().String("unmatched-host", "allow", "policy for hosts with no proxied service: allow | block") diff --git a/packages/cmd/agent_proxy_test.go b/packages/cmd/agent_proxy_test.go index 34e332f5..e5a78bd0 100644 --- a/packages/cmd/agent_proxy_test.go +++ b/packages/cmd/agent_proxy_test.go @@ -1,6 +1,40 @@ package cmd -import "testing" +import ( + "reflect" + "testing" + + "github.com/Infisical/infisical-merge/packages/models" +) + +func TestReadableBrokeredSecrets(t *testing.T) { + brokered := map[string]struct{}{"STRIPE_API_KEY": {}, "GITHUB_TOKEN": {}} + real := func(keys ...string) []models.SingleEnvironmentVariable { + out := make([]models.SingleEnvironmentVariable, len(keys)) + for i, k := range keys { + out[i] = models.SingleEnvironmentVariable{Key: k} + } + return out + } + + tests := []struct { + name string + real []models.SingleEnvironmentVariable + want []string + }{ + {name: "no overlap", real: real("DATABASE_URL", "OTHER"), want: nil}, + {name: "agent has no readable secrets", real: nil, want: nil}, + {name: "single overlap", real: real("DATABASE_URL", "STRIPE_API_KEY"), want: []string{"STRIPE_API_KEY"}}, + {name: "multiple overlap sorted", real: real("GITHUB_TOKEN", "DATABASE_URL", "STRIPE_API_KEY"), want: []string{"GITHUB_TOKEN", "STRIPE_API_KEY"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := readableBrokeredSecrets(brokered, tt.real); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + }) + } +} func TestMergeNoProxy(t *testing.T) { tests := []struct { From 5511cd2ccc0e6a177561178f17c5e85ca441b575 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:26:53 +0530 Subject: [PATCH 22/26] fix(agent-proxy): stay silent when the agent has no readable secrets in scope --- packages/cmd/agent_proxy.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index 47121d3d..4f0a6fe4 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "net/url" "os" @@ -294,7 +295,15 @@ func fetchAgentRealSecrets(token *models.TokenDetails, projectID, environment, s secrets, err := util.GetAllEnvironmentVariables(params, "") if err != nil { - log.Warn().Msgf("Could not fetch regular secrets (agent may lack read access): %v", err) + // A 401/403 just means the agent can't read any secret in this scope (normal when it only holds + // Proxy access): there's nothing to deliver and nothing is wrong, so say nothing. Only a genuine + // failure (network, server error) is worth surfacing. + var apiErr *api.APIError + if errors.As(err, &apiErr) && (apiErr.StatusCode == 401 || apiErr.StatusCode == 403) { + log.Debug().Msg("Agent has no readable secrets in this scope; skipping real-secret delivery") + } else { + log.Warn().Msgf("Could not fetch the agent's readable secrets: %v", err) + } return nil } return secrets From 5af76b08edc9aefba511d85d1e1f3f1e7d072e00 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:48:06 +0530 Subject: [PATCH 23/26] docs(agent-proxy): use placeholder in --proxy examples --- packages/cmd/agent_proxy.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index 4f0a6fe4..6228b9aa 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -31,7 +31,7 @@ var agentProxyCmd = &cobra.Command{ var agentProxyConnectCmd = &cobra.Command{ Use: "connect [flags] -- [agent start command]", Short: "Set up the environment and launch an agent behind the agent proxy", - Example: "infisical secrets agent-proxy connect --proxy=proxy:17322 --env=prod --path=/myapp -- claude", + Example: "infisical secrets agent-proxy connect --proxy=:17322 --env=prod --path=/myapp -- claude", DisableFlagsInUseLine: true, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { @@ -105,7 +105,7 @@ func mergeNoProxy(operatorEntries ...string) string { func runAgentProxyConnect(cmd *cobra.Command, args []string) { proxyAddr, err := cmd.Flags().GetString("proxy") if err != nil || proxyAddr == "" { - util.HandleError(fmt.Errorf("the --proxy flag is required (e.g. --proxy=proxy:17322)")) + util.HandleError(fmt.Errorf("the --proxy flag is required (e.g. --proxy=:17322)")) } environment, err := cmd.Flags().GetString("env") From ab29419c8c49702331d74e91ff55de1518a6036a Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:45:38 +0530 Subject: [PATCH 24/26] fix(agent-proxy): bound substitution expansion and add per-request deadlines for plaintext forwards --- packages/agentproxy/proxy.go | 16 ++++++++++--- packages/agentproxy/rewrite.go | 37 ++++++++++++++++++++++++----- packages/agentproxy/rewrite_test.go | 25 +++++++++++++++++++ 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index a6a7b3b0..1b012a8a 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -23,9 +23,8 @@ const ( const ( tlsHandshakeTimeout = 10 * time.Second - // Outer ingress server. Only header/idle timeouts: no ReadTimeout/WriteTimeout so a CONNECT hijack and - // long streaming responses aren't cut off. Plaintext (http://) relay is therefore not time-bounded here; - // the concurrent-connection cap (maxConcurrentConns) bounds how many connections can pin at once. + // Outer ingress server. No server-level ReadTimeout/WriteTimeout (they'd cut CONNECT hijacks and long + // streaming responses); plaintext forward requests are bounded per-request instead (handlePlainForward). frontReadHeaderTimeout = 30 * time.Second frontIdleTimeout = 5 * time.Minute @@ -36,6 +35,11 @@ const ( tunnelWriteTimeout = 30 * time.Minute tunnelIdleTimeout = 2 * time.Minute + // Plaintext (non-CONNECT) forward requests are bounded per-request via ResponseController deadlines, + // since the front server sets no ReadTimeout/WriteTimeout. + plainReadTimeout = 60 * time.Second + plainWriteTimeout = 30 * time.Minute + // maxRequestHeaderBytes bounds a single request's header block via http.Server.MaxHeaderBytes, so an // unauthenticated client can't send unbounded headers and exhaust proxy memory. Matches Go's // http.DefaultMaxHeaderBytes (1 MB). @@ -234,6 +238,12 @@ func (ps *proxyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, jwt string // Only http:// absolute-form is served; https:// is rejected so the proxy can never be used to silently TLS-strip (HTTPS must arrive as CONNECT). func (ps *proxyServer) handlePlainForward(w http.ResponseWriter, r *http.Request) { + // Bound this request's lifetime: the front server has no ReadTimeout/WriteTimeout (those would cut + // CONNECT tunnels), so without this a slow body or slow-reading client could pin a connection slot. + rc := http.NewResponseController(w) + _ = rc.SetReadDeadline(time.Now().Add(plainReadTimeout)) + _ = rc.SetWriteDeadline(time.Now().Add(plainWriteTimeout)) + if !strings.EqualFold(r.URL.Scheme, "http") || r.URL.Host == "" { http.Error(w, "non-CONNECT requests must be absolute-form http:// (use CONNECT for https:// upstreams)", http.StatusBadRequest) return diff --git a/packages/agentproxy/rewrite.go b/packages/agentproxy/rewrite.go index 9b6abd3a..3e2fefd8 100644 --- a/packages/agentproxy/rewrite.go +++ b/packages/agentproxy/rewrite.go @@ -70,6 +70,21 @@ func hasSurface(surfaces []string, target string) bool { return false } +// replaceWithinLimit substitutes every occurrence of old in s, but only when the expanded result stays +// within limit bytes; otherwise it returns the input unchanged (ok=false). This stops a short placeholder +// mapped to a long secret (or a request stuffed with placeholders) from ballooning proxy memory, since +// ReplaceAll otherwise allocates by the expansion ratio rather than the input size. +func replaceWithinLimit(s, old, replacement string, limit int) (string, bool) { + count := strings.Count(s, old) + if count == 0 { + return s, true + } + if len(s)+count*(len(replacement)-len(old)) > limit { + return s, false + } + return strings.ReplaceAll(s, old, replacement), true +} + // Plain substring ReplaceAll on distinctive random placeholders; short or common placeholder values would over-match. func applySubstitution(req *http.Request, cred resolvedCredential) error { placeholder := cred.placeholder @@ -79,20 +94,24 @@ func applySubstitution(req *http.Request, cred resolvedCredential) error { real := cred.value if hasSurface(cred.surfaces, surfacePath) { - req.URL.Path = strings.ReplaceAll(req.URL.Path, placeholder, real) - // Clearing RawPath makes Go re-encode the path from Path, which can change the byte form of other escaped segments. - req.URL.RawPath = "" + if v, ok := replaceWithinLimit(req.URL.Path, placeholder, real, maxBodyRewriteSize); ok { + req.URL.Path = v + // Clearing RawPath makes Go re-encode the path from Path, which can change the byte form of other escaped segments. + req.URL.RawPath = "" + } } if hasSurface(cred.surfaces, surfaceQuery) { - req.URL.RawQuery = strings.ReplaceAll(req.URL.RawQuery, placeholder, real) + if v, ok := replaceWithinLimit(req.URL.RawQuery, placeholder, real, maxBodyRewriteSize); ok { + req.URL.RawQuery = v + } } if hasSurface(cred.surfaces, surfaceHeader) { for name, values := range req.Header { for i, v := range values { - if strings.Contains(v, placeholder) { - req.Header[name][i] = strings.ReplaceAll(v, placeholder, real) + if replaced, ok := replaceWithinLimit(v, placeholder, real, maxBodyRewriteSize); ok { + req.Header[name][i] = replaced } } } @@ -112,6 +131,12 @@ func applySubstitution(req *http.Request, cred resolvedCredential) error { return nil } _ = req.Body.Close() + // Forward unchanged when expanding the placeholder would push the body past the cap. + count := bytes.Count(body, []byte(placeholder)) + if count > 0 && len(body)+count*(len(real)-len(placeholder)) > maxBodyRewriteSize { + req.Body = io.NopCloser(bytes.NewReader(body)) + return nil + } rewritten := bytes.ReplaceAll(body, []byte(placeholder), []byte(real)) req.Body = io.NopCloser(bytes.NewReader(rewritten)) req.ContentLength = int64(len(rewritten)) diff --git a/packages/agentproxy/rewrite_test.go b/packages/agentproxy/rewrite_test.go index 1087461b..723abc38 100644 --- a/packages/agentproxy/rewrite_test.go +++ b/packages/agentproxy/rewrite_test.go @@ -263,3 +263,28 @@ func TestProxyAuthParsing(t *testing.T) { t.Fatalf("unexpected jwt: %q", jwt) } } + +func TestReplaceWithinLimit(t *testing.T) { + tests := []struct { + name string + s string + old string + replacement string + limit int + want string + wantOK bool + }{ + {name: "no occurrence is unchanged", s: "hello world", old: "X", replacement: "YYYY", limit: 100, want: "hello world", wantOK: true}, + {name: "within limit is replaced", s: "a-X-b", old: "X", replacement: "SECRET", limit: 100, want: "a-SECRET-b", wantOK: true}, + {name: "expansion over limit is skipped", s: "XXXX", old: "X", replacement: "0123456789", limit: 20, want: "XXXX", wantOK: false}, + {name: "shrinking replacement always fits", s: "LONGPLACEHOLDER", old: "LONGPLACEHOLDER", replacement: "s", limit: 5, want: "s", wantOK: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := replaceWithinLimit(tt.s, tt.old, tt.replacement, tt.limit) + if got != tt.want || ok != tt.wantOK { + t.Fatalf("replaceWithinLimit = (%q, %v), want (%q, %v)", got, ok, tt.want, tt.wantOK) + } + }) + } +} From 61b2ab18a98e013e01ea9e2b4652c0ac1a8acbed Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:24:46 +0530 Subject: [PATCH 25/26] docs(agent-proxy): include --projectId in connect --help example --- packages/cmd/agent_proxy.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cmd/agent_proxy.go b/packages/cmd/agent_proxy.go index 6228b9aa..9f5b4834 100644 --- a/packages/cmd/agent_proxy.go +++ b/packages/cmd/agent_proxy.go @@ -31,7 +31,7 @@ var agentProxyCmd = &cobra.Command{ var agentProxyConnectCmd = &cobra.Command{ Use: "connect [flags] -- [agent start command]", Short: "Set up the environment and launch an agent behind the agent proxy", - Example: "infisical secrets agent-proxy connect --proxy=:17322 --env=prod --path=/myapp -- claude", + Example: "infisical secrets agent-proxy connect --proxy=:17322 --projectId= --env=prod --path=/myapp -- claude", DisableFlagsInUseLine: true, Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 { From add2c58513db144fbbbfc4db879dcc110a8d8c45 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:49:40 +0530 Subject: [PATCH 26/26] fix(agent-proxy): reject TRACE/TRACK and redact reflected credentials from response headers --- packages/agentproxy/proxy.go | 20 ++++++- packages/agentproxy/reflect_test.go | 84 +++++++++++++++++++++++++++++ packages/agentproxy/rewrite.go | 19 +++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 packages/agentproxy/reflect_test.go diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go index 1b012a8a..6cdf6ee1 100644 --- a/packages/agentproxy/proxy.go +++ b/packages/agentproxy/proxy.go @@ -269,6 +269,13 @@ func (ps *proxyServer) handlePlainForward(w http.ResponseWriter, r *http.Request // forwardHTTP resolves the upstream response and relays it to the client via the ResponseWriter. func (ps *proxyServer) forwardHTTP(w http.ResponseWriter, r *http.Request, scheme, hostname, port, jwt string, scope agentScope) { + // Reject request-echo methods: TRACE/TRACK make the upstream reflect the request (including the injected + // credential) back in the response body, which would let the agent read a secret it can't fetch directly. + if r.Method == http.MethodTrace || r.Method == "TRACK" { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + resp, err := ps.forward(r, scheme, hostname, port, jwt, scope) if err != nil { status := http.StatusBadGateway @@ -347,7 +354,18 @@ func (ps *proxyServer) forward(req *http.Request, scheme, hostname, port, jwt st } } - return ps.transport.RoundTrip(req) + resp, err := ps.transport.RoundTrip(req) + if err != nil { + return nil, err + } + + // Redact any brokered secret reflected back in a response header (e.g. a substituted value echoed in a + // redirect Location) so the agent can't read a credential it was never allowed to retrieve. Bodies are + // streamed and intentionally not scanned; TRACE/TRACK (the main body-echo vector) is rejected upstream. + if svc != nil { + redactCredentialsFromHeaders(resp.Header, svc) + } + return resp, nil } func hostHeaderForScheme(scheme, target string) string { diff --git a/packages/agentproxy/reflect_test.go b/packages/agentproxy/reflect_test.go new file mode 100644 index 00000000..e5e37039 --- /dev/null +++ b/packages/agentproxy/reflect_test.go @@ -0,0 +1,84 @@ +package agentproxy + +import ( + "bufio" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TRACE (and TRACK) make an upstream echo the request — including the injected credential — back in the +// response, so the proxy must reject them rather than forward. +func TestTraceMethodRejected(t *testing.T) { + jwt := "test.jwt.token" + scope := agentScope{projectID: "proj", environment: "prod", secretPath: "/"} + client := newTestProxy(t, UnmatchedAllow, jwt, scope, nil) + + _ = client.SetDeadline(time.Now().Add(5 * time.Second)) + if _, err := fmt.Fprintf(client, "TRACE http://example.com/ HTTP/1.1\r\nHost: example.com\r\nProxy-Authorization: %s\r\n\r\n", + proxyAuthHeader("proj", "prod", "/", jwt)); err != nil { + t.Fatal(err) + } + resp, err := http.ReadResponse(bufio.NewReader(client), nil) + if err != nil { + t.Fatalf("reading response: %v", err) + } + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("expected 405 for TRACE, got %d", resp.StatusCode) + } +} + +type reflectingTransport struct{ header http.Header } + +func (rt reflectingTransport) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusFound, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: rt.header, + Body: io.NopCloser(strings.NewReader("")), + }, nil +} + +// A brokered secret reflected back in a response header (e.g. a redirect Location echoing a substituted +// value) must be redacted so the agent can't read a credential it was never allowed to retrieve. +func TestForwardRedactsReflectedSecretInResponseHeader(t *testing.T) { + jwt := "test.jwt.token" + scope := agentScope{projectID: "proj", environment: "prod", secretPath: "/"} + services := []*resolvedService{{ + name: "svc", + hostPatterns: parseHostPatterns("example.com"), + isEnabled: true, + credentials: []resolvedCredential{ + {role: roleHeaderRewrite, headerName: "Authorization", headerPrefix: "Bearer", value: "real_secret"}, + }, + }} + cache := newAgentCache(func() string { return "" }) + cache.entries[cacheKey(jwt, scope)] = &agentEntry{jwt: jwt, scope: scope, services: services, lastSeen: time.Now()} + + respHeader := make(http.Header) + respHeader.Set("Location", "https://example.com/next?token=real_secret") + ps := &proxyServer{ + opts: Options{UnmatchedHost: UnmatchedAllow}, + cache: cache, + transport: reflectingTransport{header: respHeader}, + } + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + resp, err := ps.forward(req, "http", "example.com", "80", jwt, scope) + if err != nil { + t.Fatalf("forward: %v", err) + } + got := resp.Header.Get("Location") + if strings.Contains(got, "real_secret") { + t.Fatalf("secret was not redacted from response header: %q", got) + } + if !strings.Contains(got, "[redacted]") { + t.Fatalf("expected redaction marker in header, got %q", got) + } +} diff --git a/packages/agentproxy/rewrite.go b/packages/agentproxy/rewrite.go index 3e2fefd8..39708705 100644 --- a/packages/agentproxy/rewrite.go +++ b/packages/agentproxy/rewrite.go @@ -61,6 +61,25 @@ func applyCredentials(req *http.Request, svc *resolvedService) error { return nil } +// redactCredentialsFromHeaders replaces any brokered secret value that appears in a response header with a +// placeholder. Upstreams occasionally reflect request data (redirect Location, error echoes); without this an +// agent could read a real credential it was never allowed to retrieve. A real high-entropy secret won't +// collide with legitimate header content, so this only fires when a value is genuinely reflected. +func redactCredentialsFromHeaders(h http.Header, svc *resolvedService) { + for _, cred := range svc.credentials { + if cred.value == "" { + continue + } + for name, values := range h { + for i, v := range values { + if strings.Contains(v, cred.value) { + h[name][i] = strings.ReplaceAll(v, cred.value, "[redacted]") + } + } + } + } +} + func hasSurface(surfaces []string, target string) bool { for _, s := range surfaces { if s == target {