diff --git a/packages/agentproxy/ca.go b/packages/agentproxy/ca.go new file mode 100644 index 00000000..6c56502a --- /dev/null +++ b/packages/agentproxy/ca.go @@ -0,0 +1,210 @@ +package agentproxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" +) + +const ( + intermediateRenewThreshold = 12 * time.Hour + intermediateFallbackMargin = 5 * time.Minute + intermediateRetryInterval = 30 * time.Second + leafTTL = 24 * time.Hour + leafReuseMargin = 1 * time.Hour + maxLeafCacheEntries = 8192 +) + +type caManager struct { + token func() string + + mu sync.Mutex + intermediateKey *ecdsa.PrivateKey + intermediateCert *x509.Certificate + intermediateExp time.Time + lastResignAttempt time.Time + resignGen atomic.Uint64 + + leafMu sync.Mutex + leafCache map[string]*leafEntry +} + +type leafEntry struct { + cert tls.Certificate + expiration time.Time +} + +func newCaManager(token func() string) *caManager { + return &caManager{ + token: token, + leafCache: make(map[string]*leafEntry), + } +} + +func (c *caManager) ensureIntermediate() error { + c.mu.Lock() + defer c.mu.Unlock() + + 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 +} + +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) + } + + 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}) + + client := resty.New().SetAuthToken(c.token()) + resp, err := api.CallSignAgentProxyIntermediateCa(client, 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 + c.resignGen.Add(1) + c.leafMu.Lock() + c.leafCache = make(map[string]*leafEntry) + c.leafMu.Unlock() + + return nil +} + +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) > leafReuseMargin { + c.leafMu.Unlock() + return entry.cert, nil + } + c.leafMu.Unlock() + + c.mu.Lock() + interKey := c.intermediateKey + interCert := c.intermediateCert + gen := c.resignGen.Load() + 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(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}, + NotBefore: time.Now().Add(-1 * time.Minute), + NotAfter: notAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + 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) + if err != nil { + return tls.Certificate{}, err + } + + cert := tls.Certificate{ + Certificate: [][]byte{leafDer, interCert.Raw}, + PrivateKey: key, + } + + // 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) + c.leafCache[hostname] = &leafEntry{cert: cert, expiration: notAfter} + } + c.leafMu.Unlock() + + return cert, nil +} + +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) + } + } + for host := range c.leafCache { + if len(c.leafCache) < maxLeafCacheEntries { + break + } + delete(c.leafCache, host) + } +} diff --git a/packages/agentproxy/ca_test.go b/packages/agentproxy/ca_test.go new file mode 100644 index 00000000..759c8888 --- /dev/null +++ b/packages/agentproxy/ca_test.go @@ -0,0 +1,83 @@ +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" +) + +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 +} + +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" }) + 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)) + } +} + +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") + } +} diff --git a/packages/agentproxy/cache.go b/packages/agentproxy/cache.go new file mode 100644 index 00000000..d90fd954 --- /dev/null +++ b/packages/agentproxy/cache.go @@ -0,0 +1,245 @@ +package agentproxy + +import ( + "errors" + "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" + "github.com/rs/zerolog/log" +) + +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 + +// 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 + headerPrefix string + headerPurpose string + placeholder string + surfaces []string + value string +} + +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 +} + +func cacheKey(jwt string, scope agentScope) string { + return strings.Join([]string{jwt, scope.projectID, scope.environment, scope.secretPath}, "\x00") +} + +type agentCache struct { + proxyToken func() string + + mu sync.Mutex + entries map[string]*agentEntry +} + +func newAgentCache(proxyToken func() string) *agentCache { + return &agentCache{ + proxyToken: proxyToken, + entries: make(map[string]*agentEntry), + } +} + +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(), + } + 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{ + 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 { + log.Warn().Msgf("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(), + ExpandSecretReferences: true, + IncludeImport: true, + } + 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 +} + +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 { + // 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() + delete(a.entries, t.key) + a.mu.Unlock() + continue + } + 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 + } + a.mu.Unlock() + } +} 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)) + } +} 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 new file mode 100644 index 00000000..0b0c6d5c --- /dev/null +++ b/packages/agentproxy/forward_test.go @@ -0,0 +1,188 @@ +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)) +} + +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() + 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 +} + +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) + + 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 TestPlainForwardInjectedHeaderSurvivesHostileConnectionHeader(t *testing.T) { + 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) + + 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) + + 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/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/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/match.go b/packages/agentproxy/match.go new file mode 100644 index 00000000..c370bb58 --- /dev/null +++ b/packages/agentproxy/match.go @@ -0,0 +1,145 @@ +package agentproxy + +import ( + "net" + "strings" +) + +type hostPattern struct { + host string + port string + path string +} + +func parseHostPatterns(raw string) []hostPattern { + var patterns []hostPattern + for _, part := range strings.Split(raw, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + p := hostPattern{} + if idx := strings.Index(part, "/"); idx != -1 { + 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] + } + p.host = part + patterns = append(patterns, p) + } + return patterns +} + +type matchDetail struct { + exactHost bool + specificPort bool + pathLen int +} + +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 +} + +func (m matchDetail) equalTo(o matchDetail) bool { + return m.exactHost == o.exactHost && m.specificPort == o.specificPort && m.pathLen == o.pathLen +} + +func (p hostPattern) match(host, port, path string) (bool, matchDetail) { + detail := matchDetail{} + + host = strings.ToLower(host) + patternHost := strings.ToLower(p.host) + + if strings.HasPrefix(patternHost, "*.") { + 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 + } + prefix := strings.TrimSuffix(host, suffix) + if prefix == "" || strings.Contains(prefix, ".") { + return false, detail + } + } else { + if !hostsEqual(patternHost, host) { + return false, detail + } + detail.exactHost = true + } + + if p.port != "" { + if p.port != port { + return false, detail + } + detail.specificPort = true + } + + if p.path != "" { + prefix := strings.TrimSuffix(p.path, "*") + if !strings.HasPrefix(path, prefix) { + return false, detail + } + detail.pathLen = len(prefix) + } + + 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 + var bestDetail matchDetail + + for _, svc := range services { + if !svc.isEnabled { + continue + } + for _, pat := range svc.hostPatterns { + matched, detail := pat.match(host, port, path) + if !matched { + continue + } + switch { + case best == nil, detail.betterThan(bestDetail): + best = svc + bestDetail = detail + case detail.equalTo(bestDetail) && svc.name < best.name: + best = svc + bestDetail = detail + } + } + } + return best +} diff --git a/packages/agentproxy/match_test.go b/packages/agentproxy/match_test.go new file mode 100644 index 00000000..df94f012 --- /dev/null +++ b/packages/agentproxy/match_test.go @@ -0,0 +1,152 @@ +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 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 { + 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 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 TestTieBrokenByServiceNameRegardlessOfInputOrder(t *testing.T) { + 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 { + 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") + } +} + +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") + } +} diff --git a/packages/agentproxy/proxy.go b/packages/agentproxy/proxy.go new file mode 100644 index 00000000..6cdf6ee1 --- /dev/null +++ b/packages/agentproxy/proxy.go @@ -0,0 +1,528 @@ +package agentproxy + +import ( + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +const ( + UnmatchedAllow = "allow" + UnmatchedBlock = "block" +) + +const ( + tlsHandshakeTimeout = 10 * time.Second + + // 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 + + // 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 + + // 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). + maxRequestHeaderBytes = 1 << 20 + + // 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 +) + +var errHostBlocked = errors.New("host blocked by policy") + +type Options struct { + Port int + UnmatchedHost string + PollInterval time.Duration + ProxyToken func() string +} + +type proxyServer struct { + opts Options + ca *caManager + cache *agentCache + 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. +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, + } +} + +// 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) + } + + go ps.pollLoop() + + 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) + } + log.Info().Msgf("Infisical agent proxy listening on :%d", opts.Port) + + return ps.newFrontServer().Serve(newLimitListener(listener, maxConcurrentConns)) +} + +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 { + interval = 60 * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for range ticker.C { + ps.cache.refreshActive() + } +} + +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(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 { + writeProxyAuthChallenge(w) + return + } + + hostname, port, err := parseConnectTarget(r.Host) + if err != nil { + 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) { + http.Error(w, "proxy authorization failed", http.StatusForbidden) + } else { + http.Error(w, "failed to resolve agent permissions", http.StatusBadGateway) + } + return + } + + leaf, err := ps.ca.mintLeaf(hostname) + if err != nil { + 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 + } + + 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{}) + + 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(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 + } + + scope, jwt, ok := parseProxyAuth(r.Header.Get("Proxy-Authorization")) + if !ok { + writeProxyAuthChallenge(w) + return + } + + hostname := r.URL.Hostname() + port := r.URL.Port() + if port == "" { + port = "80" + } + if r.URL.Path == "" { + r.URL.Path = "/" + } + + 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) { + // 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 + if errors.Is(err, errHostBlocked) { + status = http.StatusForbidden + } + http.Error(w, err.Error(), status) + return + } + defer resp.Body.Close() + + // 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) + } + } + // 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) +} + +// 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) { + 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 +} + +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) + } + + 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: %w", hostname, errHostBlocked) + } + + req.URL.Scheme = scheme + req.URL.Host = net.JoinHostPort(hostname, port) + // 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 before injecting so a client's Connection header cannot delete the injected credential (injected always wins). + stripHopByHopHeaders(req.Header) + + if svc != nil { + if err := applyCredentials(req, svc); err != nil { + return nil, fmt.Errorf("failed to apply credentials: %w", err) + } + } + + 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 { + 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 +} + +var hopByHopHeaders = []string{ + "Connection", + "Proxy-Connection", + "Keep-Alive", + "Proxy-Authenticate", + "Proxy-Authorization", + "TE", + "Trailer", + "Transfer-Encoding", + "Upgrade", +} + +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) + } +} + +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:] + + lastColon := strings.LastIndex(password, ":") + if lastColon == -1 { + return agentScope{}, "", false + } + scopeStr := password[:lastColon] + jwt := password[lastColon+1:] + + slash := strings.Index(scopeStr, "/") + var environment, secretPath string + if slash == -1 { + environment = scopeStr + secretPath = "/" + } else { + environment = scopeStr[:slash] + secretPath = scopeStr[slash:] + } + + if projectID == "" || environment == "" || jwt == "" { + return agentScope{}, "", false + } + + return agentScope{projectID: projectID, environment: environment, secretPath: secretPath}, jwt, true +} + +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 (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 +} + +type limitConn struct { + net.Conn + releaseOnce sync.Once + release func() +} + +func (c *limitConn) Close() error { + err := c.Conn.Close() + c.releaseOnce.Do(c.release) + return err +} diff --git a/packages/agentproxy/proxy_test.go b/packages/agentproxy/proxy_test.go new file mode 100644 index 00000000..919f932e --- /dev/null +++ b/packages/agentproxy/proxy_test.go @@ -0,0 +1,50 @@ +package agentproxy + +import "testing" + +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 + 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/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 new file mode 100644 index 00000000..39708705 --- /dev/null +++ b/packages/agentproxy/rewrite.go @@ -0,0 +1,166 @@ +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 +) + +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 +} + +// 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 { + return true + } + } + 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 + if placeholder == "" { + return nil + } + real := cred.value + + if hasSurface(cred.surfaces, surfacePath) { + 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) { + 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 replaced, ok := replaceWithinLimit(v, placeholder, real, maxBodyRewriteSize); ok { + req.Header[name][i] = replaced + } + } + } + } + + if hasSurface(cred.surfaces, surfaceBody) && req.Body != nil { + if req.Header.Get("Content-Encoding") != "" { + return nil + } + 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 { + req.Body = io.NopCloser(io.MultiReader(bytes.NewReader(body), req.Body)) + 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)) + 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..723abc38 --- /dev/null +++ b/packages/agentproxy/rewrite_test.go @@ -0,0 +1,290 @@ +package agentproxy + +import ( + "fmt" + "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 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) { + 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==" + 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) + } +} + +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) + } + }) + } +} diff --git a/packages/api/agent_proxy.go b/packages/api/agent_proxy.go new file mode 100644 index 00000000..38e60d4f --- /dev/null +++ b/packages/api/agent_proxy.go @@ -0,0 +1,113 @@ +package api + +import ( + "fmt" + + "github.com/Infisical/infisical-merge/packages/config" + "github.com/go-resty/resty/v2" +) + +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 +} + +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..9f5b4834 --- /dev/null +++ b/packages/cmd/agent_proxy.go @@ -0,0 +1,421 @@ +package cmd + +import ( + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "os/signal" + "path/filepath" + "sort" + "strings" + "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" + "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=:17322 --projectId= --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 17322", + DisableFlagsInUseLine: true, + Run: runAgentProxyStart, +} + +const mitmCaRelativePath = ".infisical/agent-proxy/mitm-ca.pem" + +var caTrustEnvVars = []string{ + "SSL_CERT_FILE", + "NODE_EXTRA_CA_CERTS", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "DENO_CERT", +} + +// Stripped as well as set: 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", +} + +// 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, +} + +var requiredNoProxy = []string{"localhost", "127.0.0.1"} + +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 == "" { + util.HandleError(fmt.Errorf("the --proxy flag is required (e.g. --proxy=:17322)")) + } + + 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) + + 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") + } + + 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) + + if err := runAgentProcess(args, env); err != nil { + util.HandleError(err, "Agent process failed") + } +} + +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 +} + +// 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{ + 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 +} + +// 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, + SecretPath: secretPath, + }) + if err != nil { + util.HandleError(err, "Failed to list proxied services") + } + + 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, 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 { + params := models.GetAllSecretsParameters{ + Environment: environment, + WorkspaceId: projectID, + SecretsPath: secretPath, + ExpandSecretReferences: true, + IncludeImport: true, + } + 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 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 +} + +func buildAgentEnv(proxy, caPath, jwt, extraNoProxy string, placeholders map[string]string, realSecrets []models.SingleEnvironmentVariable) []string { + stale := map[string]bool{} + for _, k := range proxyEnvKeys { + stale[k] = true + } + for _, k := range credentialEnvKeys { + stale[k] = true + } + var operatorNoProxy []string + env := map[string]string{} + for _, kv := range os.Environ() { + parts := strings.SplitN(kv, "=", 2) + 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] + } + } + + 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 + + for _, k := range caTrustEnvVars { + env[k] = caPath + } + + env["INFISICAL_TOKEN"] = jwt + env[util.INFISICAL_DOMAIN_ENV_NAME] = strings.TrimSuffix(config.INFISICAL_URL, "/api") + + for k, v := range placeholders { + env[k] = v + } + + 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", "", "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") + 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") + 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..70886ba7 --- /dev/null +++ b/packages/cmd/agent_proxy_start.go @@ -0,0 +1,81 @@ +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/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") + + 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")) + + 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: func() string { return proxyToken.Load().(string) }, + }) + if err != nil { + util.HandleError(err, "Agent proxy failed") + } +} + +func refreshProxyToken(token *atomic.Value, clientID, clientSecret string, ttlSeconds int) { + const retryInterval = 30 * time.Second + + halfTTL := func() time.Duration { + wait := time.Duration(ttlSeconds) * time.Second / 2 + if wait < retryInterval { + wait = retryInterval + } + return wait + } + + wait := halfTTL() + for { + time.Sleep(wait) + + loginResp, err := util.UniversalAuthLogin(clientID, clientSecret) + if err != nil { + 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() + } +} diff --git a/packages/cmd/agent_proxy_test.go b/packages/cmd/agent_proxy_test.go new file mode 100644 index 00000000..e5a78bd0 --- /dev/null +++ b/packages/cmd/agent_proxy_test.go @@ -0,0 +1,74 @@ +package cmd + +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 { + 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) + } + }) + } +}