Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8a9ea27
feat: secrets agent-proxy start and connect commands
saifsmailbox98 Jul 9, 2026
31e0ea8
fix: address pre-PR review for agent proxy
saifsmailbox98 Jul 9, 2026
b114ad4
docs: note substring-replacement behavior in credential substitution
saifsmailbox98 Jul 10, 2026
f87937d
refactor: address review feedback for agent proxy
saifsmailbox98 Jul 10, 2026
491d4ff
fix: fall back to still-valid intermediate CA when re-sign fails
saifsmailbox98 Jul 10, 2026
bd2006f
feat: broker plain-HTTP upstreams via absolute-form forward proxying
saifsmailbox98 Jul 10, 2026
b968e07
fix: skip placeholders of disabled proxied services in connect
saifsmailbox98 Jul 10, 2026
3731285
fix: break proxied service host-match ties deterministically by name
saifsmailbox98 Jul 10, 2026
9fe18cb
test: cover credential rewrite surfaces, prefixes, and body edge cases
saifsmailbox98 Jul 10, 2026
4025bba
fix: strip hop-by-hop headers before injecting so credentials always win
saifsmailbox98 Jul 10, 2026
415e65d
feat: merge operator NO_PROXY entries and add --no-proxy flag to connect
saifsmailbox98 Jul 10, 2026
c5e2e3c
fix: force HTTP/1.1 upstream so HTTP/2 responses don't hang the MITM …
saifsmailbox98 Jul 10, 2026
1a9f187
fix: authenticate before minting proxy leaf certs and bound leaf cache
saifsmailbox98 Jul 10, 2026
dd4ba4d
fix: pin upstream Host header to the matched authority
saifsmailbox98 Jul 10, 2026
f7f4c1b
chore: trim non-essential comments from agent proxy code
saifsmailbox98 Jul 10, 2026
392116c
feat: expand references and include imports when fetching proxied sec…
saifsmailbox98 Jul 11, 2026
4dbe55a
feat: match IPv6 host patterns in the agent proxy
saifsmailbox98 Jul 13, 2026
486c678
fix(agentproxy): bound request header size to prevent memory exhaustion
saifsmailbox98 Jul 13, 2026
f2ed427
fix(agentproxy): cap resolution cache with LRU eviction to bound memory
saifsmailbox98 Jul 13, 2026
cbf540a
refactor(agentproxy): serve via http.Server with connection cap and l…
saifsmailbox98 Jul 13, 2026
194fbc7
feat(agent-proxy): block connect when agent can read brokered secrets
saifsmailbox98 Jul 13, 2026
5511cd2
fix(agent-proxy): stay silent when the agent has no readable secrets …
saifsmailbox98 Jul 13, 2026
5af76b0
docs(agent-proxy): use <proxy-host> placeholder in --proxy examples
saifsmailbox98 Jul 13, 2026
ab29419
fix(agent-proxy): bound substitution expansion and add per-request de…
saifsmailbox98 Jul 14, 2026
61b2ab1
docs(agent-proxy): include --projectId in connect --help example
saifsmailbox98 Jul 14, 2026
add2c58
fix(agent-proxy): reject TRACE/TRACK and redact reflected credentials…
saifsmailbox98 Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
242 changes: 242 additions & 0 deletions packages/agentproxy/ca.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
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 (
// The backend signs the intermediate with a 7-day TTL; renewing below this threshold keeps
// the intermediate (and every leaf capped to it) comfortably inside that window.
intermediateRenewThreshold = 12 * time.Hour
// Below the renew threshold, a failed re-sign falls back to the current intermediate as long
// as it stays valid past this margin, so a transient Infisical outage inside the renewal
// window degrades gracefully instead of failing every mint (including cached-leaf hostnames).
intermediateFallbackMargin = 5 * time.Minute
// Minimum time between re-sign attempts while falling back; mints are serialized on c.mu,
// so retrying on every request would both hammer the sign endpoint and block all minting.
intermediateRetryInterval = 30 * time.Second
leafTTL = 24 * time.Hour // lifetime of a minted leaf certificate
leafReuseMargin = 1 * time.Hour // minimum remaining lifetime to reuse a cached leaf
// Upper bound on cached leaves so an agent (even an authenticated but misbehaving one) cannot
// grow the cache without limit by requesting endless unique hostnames. Generous enough for many
// agents talking to many upstreams through one proxy; a miss just re-mints on the next request.
maxLeafCacheEntries = 8192
)

// caManager holds the intermediate CA (signed by the org root CA in Infisical) and mints
// short-lived leaf certificates per upstream hostname, presenting the leaf+intermediate chain.
type caManager struct {
// token returns the proxy MI's current access token, refreshed by the caller.
token func() string

mu sync.Mutex
intermediateKey *ecdsa.PrivateKey
intermediateCert *x509.Certificate
intermediateExp time.Time
lastResignAttempt time.Time // throttles re-sign retries while falling back
// resignGen increments (under mu) every time a new intermediate is installed. Leaf minters
// snapshot it alongside the intermediate and only cache a leaf if it is unchanged, so a leaf
// signed by an outgoing intermediate can never land in leafCache after a re-sign cleared it.
resignGen atomic.Uint64

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),
}
}

// ensureIntermediate lazily generates an intermediate keypair and has Infisical sign it,
// re-signing when it is near expiry. A failed re-sign is not fatal while the current
// intermediate remains usable: minting continues on it and the re-sign is retried later.
func (c *caManager) ensureIntermediate() error {
c.mu.Lock()
defer c.mu.Unlock()

remaining := time.Until(c.intermediateExp)
if c.intermediateCert != nil && remaining > intermediateRenewThreshold {
return nil
}

canFallBack := c.intermediateCert != nil && remaining > intermediateFallbackMargin
if canFallBack && time.Since(c.lastResignAttempt) < intermediateRetryInterval {
return nil
}
c.lastResignAttempt = time.Now()

if err := c.resignIntermediateLocked(); err != nil {
if canFallBack {
log.Warn().Err(err).Msg("failed to renew the intermediate CA; continuing with the current one until it nears expiry")
return nil
}
return err
}
return nil
}

// resignIntermediateLocked generates a fresh keypair, has Infisical sign it with the org root
// CA, and installs it. Caller must hold c.mu.
func (c *caManager) resignIntermediateLocked() error {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("failed to generate intermediate CA key: %w", err)
}

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)
// new intermediate invalidates cached leaves (they chain to the old one)
c.leafMu.Lock()
c.leafCache = make(map[string]*leafEntry)
c.leafMu.Unlock()

return nil
}

// mintLeaf returns a TLS certificate for the hostname, signed by the intermediate CA,
// with the intermediate appended so the agent can build the chain to the trusted root.
func (c *caManager) mintLeaf(hostname string) (tls.Certificate, error) {
if err := c.ensureIntermediate(); err != nil {
return tls.Certificate{}, err
}

c.leafMu.Lock()
if entry, ok := c.leafCache[hostname]; ok && time.Until(entry.expiration) > 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},
}
// TLS clients validate an IP-literal target against the IP SAN, not DNS names.
if ip := net.ParseIP(hostname); ip != nil {
template.IPAddresses = []net.IP{ip}
} else {
template.DNSNames = []string{hostname}
}

leafDer, err := x509.CreateCertificate(rand.Reader, template, interCert, &key.PublicKey, interKey)
if err != nil {
return tls.Certificate{}, err
}

cert := tls.Certificate{
Certificate: [][]byte{leafDer, interCert.Raw},
PrivateKey: key,
}

// Only cache if no re-sign happened since the intermediate was snapshotted: a re-sign clears
// leafCache, and caching a leaf chained to the outgoing intermediate would resurrect stale
// state. Serving this leaf directly is still fine; it is valid until the old chain expires.
c.leafMu.Lock()
if c.resignGen.Load() == gen {
c.evictLeavesIfFullLocked(hostname)
c.leafCache[hostname] = &leafEntry{cert: cert, expiration: notAfter}
}
c.leafMu.Unlock()

return cert, nil
}

// evictLeavesIfFullLocked keeps leafCache bounded before inserting a new hostname. It first drops
// expired entries, then, if still at capacity, evicts entries until there is room for one more.
// Refreshing an already-cached hostname does not grow the map, so it is exempt. Caller holds c.leafMu.
func (c *caManager) evictLeavesIfFullLocked(incoming string) {
if len(c.leafCache) < maxLeafCacheEntries {
return
}
if _, replacing := c.leafCache[incoming]; replacing {
return
}
now := time.Now()
for host, entry := range c.leafCache {
if now.After(entry.expiration) {
delete(c.leafCache, host)
}
}
// Expired sweep may not have freed enough; evict arbitrary entries to make room. Evicting a
// live leaf only costs a re-mint the next time that hostname is requested.
for host := range c.leafCache {
if len(c.leafCache) < maxLeafCacheEntries {
break
}
delete(c.leafCache, host)
}
}
89 changes: 89 additions & 0 deletions packages/agentproxy/ca_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package agentproxy

import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/Infisical/infisical-merge/packages/config"
)

// installTestIntermediate puts a self-signed CA cert into the manager as if Infisical had
// signed it, expiring at the given time.
func installTestIntermediate(t *testing.T, c *caManager, notAfter time.Time) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test intermediate"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: notAfter,
IsCA: true,
BasicConstraintsValid: true,
KeyUsage: x509.KeyUsageCertSign,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
t.Fatal(err)
}
c.intermediateKey = key
c.intermediateCert = cert
c.intermediateExp = cert.NotAfter
}

// A failed re-sign inside the renewal window must fall back to the still-valid intermediate
// instead of failing every mint (a transient Infisical outage must not take the proxy down).
func TestEnsureIntermediateFallsBackWhenResignFails(t *testing.T) {
failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer failing.Close()
origURL := config.INFISICAL_URL
config.INFISICAL_URL = failing.URL
defer func() { config.INFISICAL_URL = origURL }()

c := newCaManager(func() string { return "test-token" })
// 1h remaining: below the 12h renew threshold (re-sign attempted), above the fallback margin
installTestIntermediate(t, c, time.Now().Add(1*time.Hour))

if err := c.ensureIntermediate(); err != nil {
t.Fatalf("expected fallback to the valid intermediate, got error: %v", err)
}
leaf, err := c.mintLeaf("api.example.com")
if err != nil {
t.Fatalf("expected minting to keep working on the old intermediate, got: %v", err)
}
if len(leaf.Certificate) != 2 {
t.Fatalf("expected leaf + intermediate chain, got %d certs", len(leaf.Certificate))
}
}

// With no usable intermediate at all, a failed sign is still a hard error.
func TestEnsureIntermediateFailsWithoutFallback(t *testing.T) {
failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer failing.Close()
origURL := config.INFISICAL_URL
config.INFISICAL_URL = failing.URL
defer func() { config.INFISICAL_URL = origURL }()

c := newCaManager(func() string { return "test-token" })
if err := c.ensureIntermediate(); err == nil {
t.Fatal("expected an error when there is no intermediate to fall back to")
}
}
Loading
Loading