From 2bc9d2ebb34c744b21c3c23dc16488741219739f Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:40:46 +0100 Subject: [PATCH 1/3] feat(agent-proxy): add policy-mode agent proxy for agent and user policy intersection --- packages/agentproxy/policy_match.go | 212 +++++++++++ packages/agentproxy/policy_match_test.go | 186 ++++++++++ packages/agentproxy/policy_resolver.go | 230 ++++++++++++ packages/agentproxy/policy_server.go | 430 +++++++++++++++++++++++ packages/api/agent_policies.go | 195 ++++++++++ packages/cmd/agent_proxy_server.go | 145 ++++++++ 6 files changed, 1398 insertions(+) create mode 100644 packages/agentproxy/policy_match.go create mode 100644 packages/agentproxy/policy_match_test.go create mode 100644 packages/agentproxy/policy_resolver.go create mode 100644 packages/agentproxy/policy_server.go create mode 100644 packages/api/agent_policies.go create mode 100644 packages/cmd/agent_proxy_server.go diff --git a/packages/agentproxy/policy_match.go b/packages/agentproxy/policy_match.go new file mode 100644 index 00000000..c1b1f37d --- /dev/null +++ b/packages/agentproxy/policy_match.go @@ -0,0 +1,212 @@ +package agentproxy + +import ( + "strings" +) + +// policyPattern is a rule's host pattern plus the methods it covers. It is deliberately separate from +// hostPattern (used by proxied services), which carries no scheme and no method. +type policyPattern struct { + scheme string + host string + port string + path string + methods []string +} + +// parsePolicyPattern accepts [scheme://]host[:port][/path*]. An unset scheme matches either scheme; a +// set one is enforced, so a rule naming https never lets a plaintext request carry the credential. +func parsePolicyPattern(raw string, methods []string) policyPattern { + p := policyPattern{methods: normalizeMethods(methods)} + + part := strings.TrimSpace(raw) + if idx := strings.Index(part, "://"); idx != -1 { + p.scheme = strings.ToLower(part[:idx]) + part = part[idx+3:] + } + + if idx := strings.Index(part, "/"); idx != -1 { + p.path = part[idx:] + part = part[:idx] + } + + // Bracketed IPv6 ([::1] or [2001:db8::1]:8443): the brackets disambiguate the port colon, and the host + // is stored unbracketed to match the incoming hostname. + 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:] + } + return p + } + } + + if idx := strings.LastIndex(part, ":"); idx != -1 { + p.port = part[idx+1:] + part = part[:idx] + } + p.host = part + return p +} + +func normalizeMethods(methods []string) []string { + if len(methods) == 0 { + return nil + } + out := make([]string, 0, len(methods)) + for _, m := range methods { + if m = strings.ToUpper(strings.TrimSpace(m)); m != "" { + out = append(out, m) + } + } + return out +} + +// coversMethod reports whether the rule covers this HTTP method. No methods means every method, which is +// how the UI's "Any" is stored. +func (p policyPattern) coversMethod(method string) bool { + if len(p.methods) == 0 { + return true + } + method = strings.ToUpper(method) + for _, m := range p.methods { + if m == method { + return true + } + } + return false +} + +func (p policyPattern) match(scheme, host, port, path, method string) (bool, matchDetail) { + detail := matchDetail{} + + if p.scheme != "" && p.scheme != strings.ToLower(scheme) { + return false, detail + } + if !p.coversMethod(method) { + return false, detail + } + + host = strings.ToLower(host) + patternHost := strings.ToLower(p.host) + + if strings.HasPrefix(patternHost, "*.") { + suffix := patternHost[1:] + // The 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 +} + +// hostAllowed reports whether a bare host allowlist entry covers this host. Allowlist entries pass +// through with no credential, so they carry no scheme, port, path or method. +func hostAllowed(entries []string, host string) bool { + for _, entry := range entries { + pattern := parsePolicyPattern(entry, nil) + if ok, _ := pattern.match("", host, "", "/", ""); ok { + return true + } + } + return false +} + +type policyRule struct { + pattern policyPattern +} + +type resolvedAgentPolicy struct { + id string + name string + rules []policyRule + credentials []resolvedCredential +} + +type resolvedUserPolicy struct { + id string + name string + rules []policyRule +} + +// evaluate applies the intersection: a request is allowed when it matches at least one rule on an agent +// policy AND at least one rule on a user policy. Neither side is a subset of the other, so this is a +// per-request check, not set arithmetic. The winning agent policy is the most specific match, which is +// what decides whose credentials get injected. +func evaluate( + agentPolicies []*resolvedAgentPolicy, + userPolicies []*resolvedUserPolicy, + scheme, host, port, path, method string, +) (matched *resolvedAgentPolicy, userAllowed bool) { + var bestDetail matchDetail + + for _, policy := range agentPolicies { + for _, rule := range policy.rules { + ok, detail := rule.pattern.match(scheme, host, port, path, method) + if !ok { + continue + } + switch { + case matched == nil, detail.betterThan(bestDetail): + matched, bestDetail = policy, detail + case detail.equalTo(bestDetail) && policy.name < matched.name: + matched, bestDetail = policy, detail + } + } + } + + for _, policy := range userPolicies { + for _, rule := range policy.rules { + if ok, _ := rule.pattern.match(scheme, host, port, path, method); ok { + userAllowed = true + break + } + } + if userAllowed { + break + } + } + + return matched, userAllowed +} + +// agentCoversHost reports whether any agent rule could match this host at all, ignoring method and path. +// CONNECT carries only host and port, so this is what decides whether to open the tunnel; the real +// decision is made per request inside it, once the method and path are known. +func agentCoversHost(agentPolicies []*resolvedAgentPolicy, host, port string) bool { + for _, policy := range agentPolicies { + for _, rule := range policy.rules { + hostOnly := policyPattern{scheme: "", host: rule.pattern.host, port: rule.pattern.port} + if ok, _ := hostOnly.match("", host, port, "/", ""); ok { + return true + } + } + } + return false +} diff --git a/packages/agentproxy/policy_match_test.go b/packages/agentproxy/policy_match_test.go new file mode 100644 index 00000000..fb198859 --- /dev/null +++ b/packages/agentproxy/policy_match_test.go @@ -0,0 +1,186 @@ +package agentproxy + +import "testing" + +func agentPolicy(name string, rules ...policyPattern) *resolvedAgentPolicy { + policy := &resolvedAgentPolicy{id: name, name: name} + for _, pattern := range rules { + policy.rules = append(policy.rules, policyRule{pattern: pattern}) + } + return policy +} + +func userPolicy(name string, rules ...policyPattern) *resolvedUserPolicy { + policy := &resolvedUserPolicy{id: name, name: name} + for _, pattern := range rules { + policy.rules = append(policy.rules, policyRule{pattern: pattern}) + } + return policy +} + +func TestParsePolicyPattern(t *testing.T) { + cases := []struct { + raw string + scheme string + host string + port string + path string + }{ + {"api.slack.com", "", "api.slack.com", "", ""}, + {"https://api.slack.com/*", "https", "api.slack.com", "", "/*"}, + {"http://localhost:8080/v1/*", "http", "localhost", "8080", "/v1/*"}, + {"*.atlassian.net", "", "*.atlassian.net", "", ""}, + {"[2001:db8::1]:8443/x", "", "2001:db8::1", "8443", "/x"}, + } + + for _, tc := range cases { + got := parsePolicyPattern(tc.raw, nil) + if got.scheme != tc.scheme || got.host != tc.host || got.port != tc.port || got.path != tc.path { + t.Errorf("parsePolicyPattern(%q) = %+v", tc.raw, got) + } + } +} + +func TestPolicyPatternEnforcesScheme(t *testing.T) { + pattern := parsePolicyPattern("https://api.slack.com/*", nil) + + if ok, _ := pattern.match("https", "api.slack.com", "443", "/chat.postMessage", "POST"); !ok { + t.Error("expected https request to match an https rule") + } + // A plaintext request must not satisfy an https rule, or the credential leaves in the clear. + if ok, _ := pattern.match("http", "api.slack.com", "80", "/chat.postMessage", "POST"); ok { + t.Error("expected http request to be rejected by an https rule") + } +} + +func TestPolicyPatternMethods(t *testing.T) { + anyMethod := parsePolicyPattern("api.slack.com", nil) + if ok, _ := anyMethod.match("https", "api.slack.com", "443", "/x", "DELETE"); !ok { + t.Error("expected an empty method list to cover every method") + } + + readOnly := parsePolicyPattern("api.slack.com", []string{"get"}) + if ok, _ := readOnly.match("https", "api.slack.com", "443", "/x", "GET"); !ok { + t.Error("expected GET to match a GET rule, case-insensitively") + } + if ok, _ := readOnly.match("https", "api.slack.com", "443", "/x", "POST"); ok { + t.Error("expected POST to be rejected by a GET-only rule") + } +} + +func TestPolicyPatternWildcardMatchesOneLabel(t *testing.T) { + pattern := parsePolicyPattern("*.atlassian.net", nil) + + if ok, _ := pattern.match("https", "acme.atlassian.net", "443", "/", "GET"); !ok { + t.Error("expected one extra label to match") + } + if ok, _ := pattern.match("https", "a.b.atlassian.net", "443", "/", "GET"); ok { + t.Error("expected two extra labels to be rejected") + } + if ok, _ := pattern.match("https", "atlassian.net", "443", "/", "GET"); ok { + t.Error("expected the bare domain to be rejected by a wildcard rule") + } +} + +// The headline behaviour: the user side narrows the agent side, without either being a subset of the other. +func TestEvaluateUserNarrowsAgent(t *testing.T) { + agents := []*resolvedAgentPolicy{agentPolicy("slack", parsePolicyPattern("api.slack.com", nil))} + users := []*resolvedUserPolicy{userPolicy("read-only", parsePolicyPattern("api.slack.com", []string{"GET"}))} + + matched, allowed := evaluate(agents, users, "https", "api.slack.com", "443", "/conversations.list", "GET") + if matched == nil || !allowed { + t.Fatal("expected GET to be allowed by both sides") + } + + matched, allowed = evaluate(agents, users, "https", "api.slack.com", "443", "/chat.postMessage", "POST") + if matched == nil { + t.Error("expected the agent side to still match a POST") + } + if allowed { + t.Error("expected the user side to reject a POST") + } +} + +func TestEvaluateWithNoUserPolicyDeniesEverything(t *testing.T) { + agents := []*resolvedAgentPolicy{agentPolicy("slack", parsePolicyPattern("api.slack.com", nil))} + + matched, allowed := evaluate(agents, nil, "https", "api.slack.com", "443", "/x", "GET") + if matched == nil { + t.Error("expected the agent side to match") + } + if allowed { + t.Error("expected no user policy to mean nothing is allowed") + } +} + +func TestEvaluateAgentWithoutRuleForHostDenies(t *testing.T) { + agents := []*resolvedAgentPolicy{agentPolicy("slack", parsePolicyPattern("api.slack.com", nil))} + users := []*resolvedUserPolicy{userPolicy("wide", parsePolicyPattern("api.github.com", nil))} + + matched, allowed := evaluate(agents, users, "https", "api.github.com", "443", "/x", "GET") + if matched != nil { + t.Error("expected no agent policy to match a host it has no rule for") + } + if !allowed { + t.Error("expected the user side to match its own host") + } +} + +// Specificity picks the winner, which is what decides whose credential is injected. +func TestEvaluatePrefersMoreSpecificAgentPolicy(t *testing.T) { + wildcard := agentPolicy("wildcard", parsePolicyPattern("*.example.com", nil)) + exact := agentPolicy("exact", parsePolicyPattern("api.example.com", nil)) + users := []*resolvedUserPolicy{userPolicy("all", parsePolicyPattern("*.example.com", nil))} + + matched, allowed := evaluate([]*resolvedAgentPolicy{wildcard, exact}, users, "https", "api.example.com", "443", "/", "GET") + if !allowed || matched == nil { + t.Fatal("expected a match") + } + if matched.name != "exact" { + t.Errorf("expected the exact-host policy to win, got %q", matched.name) + } +} + +func TestAgentCoversHostIgnoresMethodAndPath(t *testing.T) { + // At CONNECT time only host and port are known, so a method-restricted rule must still open the tunnel. + agents := []*resolvedAgentPolicy{ + agentPolicy("slack", parsePolicyPattern("api.slack.com/chat.*", []string{"POST"})), + } + + if !agentCoversHost(agents, "api.slack.com", "443") { + t.Error("expected the host to be covered regardless of method and path") + } + if agentCoversHost(agents, "api.github.com", "443") { + t.Error("expected an unrelated host not to be covered") + } +} + +func TestHostAllowed(t *testing.T) { + entries := []string{"registry.npmjs.org", "*.pypi.org"} + + if !hostAllowed(entries, "registry.npmjs.org") { + t.Error("expected an exact allowlist entry to match") + } + if !hostAllowed(entries, "files.pypi.org") { + t.Error("expected a wildcard allowlist entry to match") + } + if hostAllowed(entries, "api.slack.com") { + t.Error("expected an unlisted host not to match") + } +} + +func TestSessionTokenParsing(t *testing.T) { + // http://@host:port produces Basic with the token as the username and no password. + if token, ok := sessionToken("Basic aXN0X2FiYzo="); !ok || token != "ist_abc" { + t.Errorf("expected the username to be used as the token, got %q ok=%v", token, ok) + } + if token, ok := sessionToken("Bearer ist_abc"); !ok || token != "ist_abc" { + t.Errorf("expected a bearer token, got %q ok=%v", token, ok) + } + if _, ok := sessionToken(""); ok { + t.Error("expected an empty header to be rejected") + } + if _, ok := sessionToken("Basic !!!not-base64"); ok { + t.Error("expected malformed base64 to be rejected") + } +} diff --git a/packages/agentproxy/policy_resolver.go b/packages/agentproxy/policy_resolver.go new file mode 100644 index 00000000..1ff8034b --- /dev/null +++ b/packages/agentproxy/policy_resolver.go @@ -0,0 +1,230 @@ +package agentproxy + +import ( + "sync" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" +) + +const ( + // A session is dropped from the cache after this long without a request, so an agent that has finished + // stops costing a refresh and its credential values leave memory. + sessionInactiveTTL = 10 * time.Minute + + // The cache key is a session token minted by Infisical, so entries cannot be forged, but a busy bot can + // still hold thousands. Bounded so memory stays predictable; the least-recently-seen session is evicted. + maxSessionCacheEntries = 4096 + + // Activity is batched rather than sent per request: one audit call per flush, and a request never waits + // on the audit write. + maxPendingActivityEvents = 500 +) + +type sessionEntry struct { + token string + agentName string + agentPolicies []*resolvedAgentPolicy + userPolicies []*resolvedUserPolicy + allowedHosts []string + lastSeen time.Time +} + +// policyResolver holds the proxy's view of every live session. Everything in it comes from Infisical and +// is re-fetched on a poll, so revocation needs no cache invalidation here: the refresh either returns new +// policies or fails closed and the session is dropped. +type policyResolver struct { + proxyToken func() string + + mu sync.Mutex + sessions map[string]*sessionEntry + + activityMu sync.Mutex + activity map[string][]api.AgentSessionActivityEvent + dropped int +} + +func newPolicyResolver(proxyToken func() string) *policyResolver { + return &policyResolver{ + proxyToken: proxyToken, + sessions: make(map[string]*sessionEntry), + activity: make(map[string][]api.AgentSessionActivityEvent), + } +} + +func (r *policyResolver) client() *resty.Client { + return resty.New().SetAuthToken(r.proxyToken()) +} + +// get returns the cached session for a token, resolving it on first use. A failure is returned rather +// than cached, so a bad token never becomes a cached allow. +func (r *policyResolver) get(token string) (*sessionEntry, error) { + r.mu.Lock() + if entry := r.sessions[token]; entry != nil { + entry.lastSeen = time.Now() + r.mu.Unlock() + return entry, nil + } + r.mu.Unlock() + + resolved, err := api.CallResolveAgentSession(r.client(), token) + if err != nil { + return nil, err + } + + entry := buildSessionEntry(token, resolved) + + r.mu.Lock() + r.evictIfFullLocked(token) + r.sessions[token] = entry + r.mu.Unlock() + return entry, nil +} + +func buildSessionEntry(token string, resolved api.ResolveAgentSessionResponse) *sessionEntry { + entry := &sessionEntry{ + token: token, + agentName: resolved.Session.AgentName, + allowedHosts: resolved.AllowedHosts, + lastSeen: time.Now(), + } + + for _, policy := range resolved.AgentPolicies { + agentPolicy := &resolvedAgentPolicy{id: policy.ID, name: policy.Name} + for _, rule := range policy.Rules { + agentPolicy.rules = append(agentPolicy.rules, policyRule{pattern: parsePolicyPattern(rule.HostPattern, rule.Methods)}) + } + for _, credential := range policy.Credentials { + agentPolicy.credentials = append(agentPolicy.credentials, resolvedCredential{ + role: credential.Role, + headerName: credential.HeaderName, + headerPrefix: credential.HeaderPrefix, + headerPurpose: credential.HeaderPurpose, + placeholder: credential.PlaceholderValue, + surfaces: credential.SubstitutionSurfaces, + value: credential.Value, + }) + } + entry.agentPolicies = append(entry.agentPolicies, agentPolicy) + } + + for _, policy := range resolved.UserPolicies { + userPolicy := &resolvedUserPolicy{id: policy.ID, name: policy.Name} + for _, rule := range policy.Rules { + userPolicy.rules = append(userPolicy.rules, policyRule{pattern: parsePolicyPattern(rule.HostPattern, rule.Methods)}) + } + entry.userPolicies = append(entry.userPolicies, userPolicy) + } + + return entry +} + +func (r *policyResolver) evictIfFullLocked(incoming string) { + if len(r.sessions) < maxSessionCacheEntries { + return + } + if _, replacing := r.sessions[incoming]; replacing { + return + } + now := time.Now() + for token, entry := range r.sessions { + if now.Sub(entry.lastSeen) > sessionInactiveTTL { + delete(r.sessions, token) + } + } + for len(r.sessions) >= maxSessionCacheEntries { + var oldestToken string + var oldest time.Time + for token, entry := range r.sessions { + if oldestToken == "" || entry.lastSeen.Before(oldest) { + oldestToken, oldest = token, entry.lastSeen + } + } + delete(r.sessions, oldestToken) + } +} + +// refreshActive re-resolves every session that has been used recently. A hard auth failure means the +// session was revoked, the agent lost its flag, or the user left the project: drop it and fail closed. +func (r *policyResolver) refreshActive() { + r.mu.Lock() + tokens := make([]string, 0, len(r.sessions)) + for token, entry := range r.sessions { + if time.Since(entry.lastSeen) > sessionInactiveTTL { + delete(r.sessions, token) + continue + } + tokens = append(tokens, token) + } + r.mu.Unlock() + + for _, token := range tokens { + resolved, err := api.CallResolveAgentSession(r.client(), token) + if err != nil { + if isAuthError(err) { + log.Warn().Err(err).Msg("session is no longer valid; dropping its cached policies and credentials") + r.mu.Lock() + delete(r.sessions, token) + r.mu.Unlock() + continue + } + log.Warn().Err(err).Msg("failed to refresh a session's policies; keeping the current ones") + continue + } + + fresh := buildSessionEntry(token, resolved) + r.mu.Lock() + if existing, ok := r.sessions[token]; ok { + fresh.lastSeen = existing.lastSeen + r.sessions[token] = fresh + } + r.mu.Unlock() + } +} + +func (r *policyResolver) close() { + r.mu.Lock() + r.sessions = make(map[string]*sessionEntry) + r.mu.Unlock() +} + +// recordActivity queues one audit event. It never blocks the request: when the queue is full the event is +// counted and dropped, and the count is logged at flush so a silent gap in the trail is visible. +func (r *policyResolver) recordActivity(token string, event api.AgentSessionActivityEvent) { + r.activityMu.Lock() + defer r.activityMu.Unlock() + + total := 0 + for _, events := range r.activity { + total += len(events) + } + if total >= maxPendingActivityEvents { + r.dropped++ + return + } + r.activity[token] = append(r.activity[token], event) +} + +func (r *policyResolver) flushActivity() { + r.activityMu.Lock() + pending := r.activity + dropped := r.dropped + r.activity = make(map[string][]api.AgentSessionActivityEvent) + r.dropped = 0 + r.activityMu.Unlock() + + if dropped > 0 { + log.Warn().Msgf("dropped %d activity event(s) because the audit queue was full", dropped) + } + + for token, events := range pending { + if len(events) == 0 { + continue + } + if err := api.CallRecordAgentSessionActivity(r.client(), token, events); err != nil { + log.Warn().Err(err).Msgf("failed to record %d activity event(s)", len(events)) + } + } +} diff --git a/packages/agentproxy/policy_server.go b/packages/agentproxy/policy_server.go new file mode 100644 index 00000000..70792046 --- /dev/null +++ b/packages/agentproxy/policy_server.go @@ -0,0 +1,430 @@ +package agentproxy + +import ( + "context" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/Infisical/infisical-merge/packages/api" + "github.com/go-resty/resty/v2" + "github.com/rs/zerolog/log" +) + +const ( + policyHeartbeatInterval = 1 * time.Minute + policyShutdownTimeout = 10 * time.Second + + // Returned to the agent for anything the intersection does not allow. Deliberately uniform: it says + // the proxy refused the request without telling the agent which side of the intersection stopped it. + policyDeniedBody = `{"error":"forbidden_by_policy","message":"This request is not allowed by the agent and user policies in effect."}` +) + +// PolicyOptions configures the policy-mode proxy: the long-standing agent proxy registered in Infisical +// under Networking, which brokers on the intersection of an agent's policies and a user's. +type PolicyOptions struct { + Port int + PollInterval time.Duration + ProxyToken func() string +} + +type policyServer struct { + opts PolicyOptions + ca *caManager + resolver *policyResolver + transport http.RoundTripper +} + +// StartPolicyProxy runs the proxy until the process is signalled. +func StartPolicyProxy(opts PolicyOptions) error { + if opts.ProxyToken == nil { + return errors.New("the agent proxy needs an access token") + } + if opts.Port == 0 { + opts.Port = 17323 + } + if opts.PollInterval <= 0 { + opts.PollInterval = 60 * time.Second + } + + ps := &policyServer{ + opts: opts, + ca: newCaManager(opts.ProxyToken), + resolver: newPolicyResolver(opts.ProxyToken), + transport: newUpstreamTransport(), + } + + // Fail at startup rather than on the first request: without a signed intermediate the proxy cannot + // terminate TLS for anything, and an operator wants to know that immediately. + if err := ps.ca.ensureSigningCert(); err != nil { + return fmt.Errorf("failed to get an intermediate CA signed by Infisical: %w", err) + } + + listener, err := net.Listen("tcp", fmt.Sprintf(":%d", opts.Port)) + if err != nil { + if inUse := portInUse(opts.Port); inUse != "" { + return fmt.Errorf("port %d is already in use by %s", opts.Port, inUse) + } + return err + } + + srv := &http.Server{ + Handler: http.HandlerFunc(ps.dispatch), + ReadHeaderTimeout: frontReadHeaderTimeout, + IdleTimeout: frontIdleTimeout, + MaxHeaderBytes: maxRequestHeaderBytes, + } + + stop := make(chan struct{}) + go ps.pollLoop(stop) + go ps.heartbeatLoop(stop) + + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + + errCh := make(chan error, 1) + go func() { + log.Info().Msgf("Infisical agent proxy listening on :%d", opts.Port) + if serveErr := srv.Serve(newLimitListener(listener, maxConcurrentConns)); serveErr != nil && + !errors.Is(serveErr, http.ErrServerClosed) { + errCh <- serveErr + } + }() + + select { + case err := <-errCh: + close(stop) + return err + case <-signals: + log.Info().Msg("Shutting down the agent proxy") + } + + close(stop) + ctx, cancel := context.WithTimeout(context.Background(), policyShutdownTimeout) + defer cancel() + _ = srv.Shutdown(ctx) + ps.resolver.flushActivity() + ps.resolver.close() + return nil +} + +func (ps *policyServer) pollLoop(stop <-chan struct{}) { + ticker := time.NewTicker(ps.opts.PollInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + ps.resolver.refreshActive() + ps.resolver.flushActivity() + case <-stop: + return + } + } +} + +func (ps *policyServer) heartbeatLoop(stop <-chan struct{}) { + ticker := time.NewTicker(policyHeartbeatInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := api.CallAgentProxyHeartbeat(resty.New().SetAuthToken(ps.opts.ProxyToken())); err != nil { + log.Warn().Err(err).Msg("failed to report the agent proxy heartbeat") + } + case <-stop: + return + } + } +} + +// sessionToken pulls the session token out of Proxy-Authorization. Both forms are accepted because an +// agent's HTTP client decides which one it sends: Basic with the token as the username (what an +// http://@host:port proxy URL produces) and Bearer. +func sessionToken(header string) (string, bool) { + if strings.HasPrefix(header, "Bearer ") { + token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) + return token, token != "" + } + if !strings.HasPrefix(header, "Basic ") { + return "", false + } + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(header, "Basic ")) + if err != nil { + return "", false + } + userinfo := string(decoded) + if idx := strings.Index(userinfo, ":"); idx != -1 { + // A token in either position: : from a proxy URL with no password, or :. + if user := userinfo[:idx]; user != "" { + return user, true + } + password := userinfo[idx+1:] + return password, password != "" + } + return userinfo, userinfo != "" +} + +func (ps *policyServer) dispatch(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodConnect { + ps.handleConnect(w, r) + return + } + ps.handlePlainForward(w, r) +} + +func (ps *policyServer) handleConnect(w http.ResponseWriter, r *http.Request) { + // Everything that can produce an HTTP status happens before the hijack: afterwards there is no way to + // send one. + token, ok := sessionToken(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 + } + + // Resolve before minting a leaf: otherwise any syntactically valid header forces unbounded key + // generation and leaf-cache growth. + session, err := ps.resolver.get(token) + if err != nil { + if isAuthError(err) { + http.Error(w, "proxy authorization failed", http.StatusForbidden) + } else { + http.Error(w, "failed to resolve the session's policies", http.StatusBadGateway) + } + return + } + + // CONNECT carries only host and port. A host no rule could ever match is refused here; anything else + // opens the tunnel and is decided per request inside it, where the method and path are known. + allowlisted := hostAllowed(session.allowedHosts, hostname) + if !allowlisted && !agentCoversHost(session.agentPolicies, hostname, port) { + ps.record(session, "blocked", r.Method, hostname, port, "", 0, "", "no policy covers this host") + http.Error(w, "host is not allowed by policy", http.StatusForbidden) + 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"}, + }) + _ = tlsConn.SetDeadline(time.Now().Add(tlsHandshakeTimeout)) + if err := tlsConn.Handshake(); err != nil { + return + } + _ = tlsConn.SetDeadline(time.Time{}) + + ps.serveTunnel(tlsConn, hostname, port, token) +} + +func (ps *policyServer) serveTunnel(tlsConn *tls.Conn, hostname, port, token string) { + listener := newOneShotListener(tlsConn) + srv := &http.Server{ + Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ps.forwardHTTP(w, r, "https", hostname, port, token) + }), + ReadHeaderTimeout: tunnelReadHeaderTimeout, + ReadTimeout: tunnelReadTimeout, + WriteTimeout: tunnelWriteTimeout, + IdleTimeout: tunnelIdleTimeout, + MaxHeaderBytes: maxRequestHeaderBytes, + ConnState: func(_ net.Conn, state http.ConnState) { + if state == http.StateHijacked || state == http.StateClosed { + _ = listener.Close() + } + }, + } + _ = srv.Serve(listener) +} + +func (ps *policyServer) handlePlainForward(w http.ResponseWriter, r *http.Request) { + rc := http.NewResponseController(w) + _ = rc.SetReadDeadline(time.Now().Add(plainReadTimeout)) + _ = rc.SetWriteDeadline(time.Now().Add(plainWriteTimeout)) + + // Only absolute-form http:// is served. Accepting https:// here would let the proxy be used to + // TLS-strip; HTTPS has to arrive as CONNECT. + 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 + } + + token, ok := sessionToken(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, token) +} + +func (ps *policyServer) forwardHTTP(w http.ResponseWriter, r *http.Request, scheme, hostname, port, token string) { + // TRACE/TRACK make the upstream reflect the request, including an injected credential, back in the + // response body, which would hand the agent a secret it cannot fetch directly. + if r.Method == http.MethodTrace || r.Method == "TRACK" { + http.Error(w, "method not allowed through the agent proxy", http.StatusMethodNotAllowed) + return + } + + session, err := ps.resolver.get(token) + if err != nil { + if isAuthError(err) { + http.Error(w, "proxy authorization failed", http.StatusForbidden) + return + } + http.Error(w, "failed to resolve the session's policies", http.StatusBadGateway) + return + } + + matched, userAllowed := evaluate(session.agentPolicies, session.userPolicies, scheme, hostname, port, r.URL.Path, r.Method) + allowlisted := hostAllowed(session.allowedHosts, hostname) + + switch { + case matched != nil && userAllowed: + // Brokered: both sides allow it, so the credential goes on. + case allowlisted: + // Passes through with no credential. Infrastructure hosts an agent needs to function live here. + default: + reason := "no agent policy allows this request" + if matched != nil && !userAllowed { + reason = "no user policy allows this request" + } + ps.record(session, "blocked", r.Method, hostname, port, r.URL.Path, http.StatusForbidden, policyName(matched), reason) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(policyDeniedBody)) + return + } + + r.URL.Scheme = scheme + r.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. + r.Host = hostHeaderForScheme(scheme, r.URL.Host) + r.RequestURI = "" + + // Strip hop-by-hop before injecting, so a client's Connection header cannot delete the credential. + stripHopByHopHeaders(r.Header) + + decision := "passthrough" + if matched != nil && userAllowed { + if _, applyErr := applyCredentials(r, matched.credentials); applyErr != nil { + ps.record(session, "error", r.Method, hostname, port, r.URL.Path, http.StatusBadGateway, matched.name, "failed to apply credentials") + http.Error(w, "failed to apply credentials", http.StatusBadGateway) + return + } + decision = "brokered" + } + + resp, err := ps.transport.RoundTrip(r) + if err != nil { + ps.record(session, "error", r.Method, hostname, port, r.URL.Path, http.StatusBadGateway, policyName(matched), err.Error()) + http.Error(w, "upstream request failed", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + for name, values := range resp.Header { + for _, value := range values { + w.Header().Add(name, value) + } + } + w.WriteHeader(resp.StatusCode) + _, _ = io.Copy(flushingWriter{w: w}, resp.Body) + + ps.record(session, decision, r.Method, hostname, port, r.URL.Path, resp.StatusCode, policyName(matched), "") +} + +func policyName(policy *resolvedAgentPolicy) string { + if policy == nil { + return "" + } + return policy.name +} + +func (ps *policyServer) record( + session *sessionEntry, + decision, method, host, port, path string, + status int, + policy, reason string, +) { + portNum, _ := strconv.Atoi(port) + if portNum == 0 { + portNum = 443 + } + if path == "" { + path = "/" + } + if len(path) > maxLoggedPathLen { + path = path[:maxLoggedPathLen] + } + + log.WithLevel(levelFor(decision)). + Str("event", "agent-proxy.request"). + Str("decision", decision). + Str("agent", session.agentName). + Str("method", method). + Str("host", host). + Int("port", portNum). + Str("path", path). + Int("status", status). + Str("policy", policy). + Str("reason", reason). + Msg("agent request") + + ps.resolver.recordActivity(session.token, api.AgentSessionActivityEvent{ + Decision: decision, + Method: method, + Host: host, + Port: portNum, + Path: path, + StatusCode: status, + PolicyName: policy, + Reason: reason, + }) +} diff --git a/packages/api/agent_policies.go b/packages/api/agent_policies.go new file mode 100644 index 00000000..f7e53877 --- /dev/null +++ b/packages/api/agent_policies.go @@ -0,0 +1,195 @@ +package api + +import ( + "fmt" + + "github.com/Infisical/infisical-merge/packages/config" + "github.com/go-resty/resty/v2" +) + +type AgentProxyLoginRequest struct { + Method string `json:"method"` + Token string `json:"token"` +} + +type AgentProxyLoginResponse struct { + AccessToken string `json:"accessToken"` + AgentProxyID string `json:"agentProxyId"` + TokenType string `json:"tokenType"` +} + +// CallAgentProxyLogin trades a one-time enrollment token for the proxy's long-lived access token. +func CallAgentProxyLogin(httpClient *resty.Client, enrollmentToken string) (AgentProxyLoginResponse, error) { + var res AgentProxyLoginResponse + response, err := httpClient. + R(). + SetResult(&res). + SetHeader("User-Agent", USER_AGENT). + SetBody(AgentProxyLoginRequest{Method: "token", Token: enrollmentToken}). + Post(fmt.Sprintf("%v/v1/agent-proxies/login", config.INFISICAL_URL)) + + if err != nil { + return AgentProxyLoginResponse{}, NewGenericRequestError("CallAgentProxyLogin", err) + } + if response.IsError() { + return AgentProxyLoginResponse{}, NewAPIErrorWithResponse("CallAgentProxyLogin", response, nil) + } + return res, nil +} + +func CallAgentProxyHeartbeat(httpClient *resty.Client) error { + response, err := httpClient. + R(). + SetHeader("User-Agent", USER_AGENT). + Post(fmt.Sprintf("%v/v1/agent-proxies/heartbeat", config.INFISICAL_URL)) + + if err != nil { + return NewGenericRequestError("CallAgentProxyHeartbeat", err) + } + if response.IsError() { + return NewAPIErrorWithResponse("CallAgentProxyHeartbeat", response, nil) + } + return nil +} + +type AgentPolicyRule struct { + HostPattern string `json:"hostPattern"` + Methods []string `json:"methods"` +} + +type AgentPolicyCredential struct { + Role string `json:"role"` + HeaderName string `json:"headerName"` + HeaderPrefix string `json:"headerPrefix"` + HeaderPurpose string `json:"headerPurpose"` + PlaceholderValue string `json:"placeholderValue"` + SubstitutionSurfaces []string `json:"substitutionSurfaces"` + Value string `json:"value"` +} + +type ResolvedAgentPolicy struct { + ID string `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + Rules []AgentPolicyRule `json:"rules"` + Credentials []AgentPolicyCredential `json:"credentials"` +} + +type ResolvedUserPolicy struct { + ID string `json:"id"` + Name string `json:"name"` + Target string `json:"target"` + Rules []AgentPolicyRule `json:"rules"` +} + +type ResolvedAgentSessionInfo struct { + ID string `json:"id"` + IdentityID string `json:"identityId"` + AgentName string `json:"agentName"` + UserID string `json:"userId"` + ProjectID string `json:"projectId"` +} + +type ResolveAgentSessionResponse struct { + Session ResolvedAgentSessionInfo `json:"session"` + AllowedHosts []string `json:"allowedHosts"` + AgentPolicies []ResolvedAgentPolicy `json:"agentPolicies"` + UserPolicies []ResolvedUserPolicy `json:"userPolicies"` +} + +type resolveAgentSessionRequest struct { + Token string `json:"token"` +} + +// CallResolveAgentSession exchanges a session token for the policies on both sides of the intersection, +// plus the credential values for the agent's policies. Called by the proxy, never by the agent. +func CallResolveAgentSession(httpClient *resty.Client, sessionToken string) (ResolveAgentSessionResponse, error) { + var res ResolveAgentSessionResponse + response, err := httpClient. + R(). + SetResult(&res). + SetHeader("User-Agent", USER_AGENT). + SetBody(resolveAgentSessionRequest{Token: sessionToken}). + Post(fmt.Sprintf("%v/v1/agent-sessions/resolve", config.INFISICAL_URL)) + + if err != nil { + return ResolveAgentSessionResponse{}, NewGenericRequestError("CallResolveAgentSession", err) + } + if response.IsError() { + return ResolveAgentSessionResponse{}, NewAPIErrorWithResponse("CallResolveAgentSession", response, nil) + } + return res, nil +} + +type AgentSessionActivityEvent struct { + Decision string `json:"decision"` + Method string `json:"method"` + Host string `json:"host"` + Port int `json:"port"` + Path string `json:"path"` + StatusCode int `json:"statusCode,omitempty"` + PolicyName string `json:"policyName,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type recordAgentSessionActivityRequest struct { + Token string `json:"token"` + Events []AgentSessionActivityEvent `json:"events"` +} + +func CallRecordAgentSessionActivity(httpClient *resty.Client, sessionToken string, events []AgentSessionActivityEvent) error { + response, err := httpClient. + R(). + SetHeader("User-Agent", USER_AGENT). + SetBody(recordAgentSessionActivityRequest{Token: sessionToken, Events: events}). + Post(fmt.Sprintf("%v/v1/agent-sessions/activity", config.INFISICAL_URL)) + + if err != nil { + return NewGenericRequestError("CallRecordAgentSessionActivity", err) + } + if response.IsError() { + return NewAPIErrorWithResponse("CallRecordAgentSessionActivity", response, nil) + } + return nil +} + +type CreateAgentSessionRequest struct { + ProjectID string `json:"projectId"` + UserEmail string `json:"userEmail"` +} + +type AgentSessionPlaceholder struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type CreateAgentSessionResponse struct { + Token string `json:"token"` + User struct { + ID string `json:"id"` + Email string `json:"email"` + Username string `json:"username"` + } `json:"user"` + Placeholders []AgentSessionPlaceholder `json:"placeholders"` + ProxyCaCertificate string `json:"proxyCaCertificate"` +} + +// CallCreateAgentSession is the agent's side of the flow: it authenticates as its own machine identity +// and asks for a session on a user's behalf. +func CallCreateAgentSession(httpClient *resty.Client, request CreateAgentSessionRequest) (CreateAgentSessionResponse, error) { + var res CreateAgentSessionResponse + response, err := httpClient. + R(). + SetResult(&res). + SetHeader("User-Agent", USER_AGENT). + SetBody(request). + Post(fmt.Sprintf("%v/v1/agent-sessions", config.INFISICAL_URL)) + + if err != nil { + return CreateAgentSessionResponse{}, NewGenericRequestError("CallCreateAgentSession", err) + } + if response.IsError() { + return CreateAgentSessionResponse{}, NewAPIErrorWithResponse("CallCreateAgentSession", response, nil) + } + return res, nil +} diff --git a/packages/cmd/agent_proxy_server.go b/packages/cmd/agent_proxy_server.go new file mode 100644 index 00000000..618ce9cc --- /dev/null +++ b/packages/cmd/agent_proxy_server.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/Infisical/infisical-merge/packages/agentproxy" + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/util" + "github.com/go-resty/resty/v2" + "github.com/posthog/posthog-go" + "github.com/rs/zerolog/log" + "github.com/spf13/cobra" +) + +// The access token is written here after enrollment so a restart does not need a fresh enrollment token, +// which is one-time use. +const agentProxyTokenRelativePath = ".infisical/agent-proxy/access-token" + +var agentProxyServerCmd = &cobra.Command{ + Use: "agent-proxy", + Short: "Run an agent proxy that brokers credentials on the intersection of agent and user policies", + Long: `Run an agent proxy. + +An agent proxy is registered in Infisical under Networking. Point an agent's HTTP_PROXY at it with a +session token as the proxy credential, and it applies the policies for that session: a request is allowed +when both the agent's policies and the user's allow it, and only then is a credential attached.`, + Example: `# First run, with a one-time enrollment token from the Infisical UI +infisical agent-proxy start --token= --port=17323 + +# Later runs reuse the saved access token +infisical agent-proxy start`, + DisableFlagsInUseLine: true, +} + +var agentProxyServerStartCmd = &cobra.Command{ + Use: "start", + Short: "Start the agent proxy", + DisableFlagsInUseLine: true, + Run: runAgentProxyServerStart, +} + +func agentProxyTokenPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, agentProxyTokenRelativePath), nil +} + +func saveAgentProxyToken(token string) error { + path, err := agentProxyTokenPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return os.WriteFile(path, []byte(token), 0o600) +} + +func loadAgentProxyToken() string { + path, err := agentProxyTokenPath() + if err != nil { + return "" + } + contents, err := os.ReadFile(path) + if err != nil { + return "" + } + return string(contents) +} + +// resolveAgentProxyAccessToken enrolls with a one-time token when one is supplied, and otherwise reuses +// the saved access token. An enrollment token can only be redeemed once, so enrolling always wins: it is +// what an operator reaches for when the previous token was revoked. +func resolveAgentProxyAccessToken(cmd *cobra.Command) string { + enrollmentToken, _ := cmd.Flags().GetString("token") + if enrollmentToken == "" { + enrollmentToken = os.Getenv("INFISICAL_AGENT_PROXY_ENROLLMENT_TOKEN") + } + + if enrollmentToken != "" { + resp, err := api.CallAgentProxyLogin(resty.New(), enrollmentToken) + if err != nil { + util.HandleError(err, "Failed to enroll the agent proxy") + } + if err := saveAgentProxyToken(resp.AccessToken); err != nil { + log.Warn().Err(err).Msg("enrolled, but could not save the access token; the next start will need a new enrollment token") + } + log.Info().Msgf("Enrolled agent proxy [agentProxyId=%s]", resp.AgentProxyID) + return resp.AccessToken + } + + if saved := loadAgentProxyToken(); saved != "" { + return saved + } + + util.HandleError(fmt.Errorf("no access token found; pass --token with an enrollment token from Infisical (Organization Settings > Networking > Agent Proxies) or set INFISICAL_AGENT_PROXY_ENROLLMENT_TOKEN")) + return "" +} + +func runAgentProxyServerStart(cmd *cobra.Command, args []string) { + port, err := cmd.Flags().GetInt("port") + if err != nil { + util.HandleError(err, "Unable to parse --port") + } + pollInterval, err := cmd.Flags().GetInt("poll-interval") + if err != nil { + util.HandleError(err, "Unable to parse --poll-interval") + } + logFormat, _ := cmd.Flags().GetString("log-format") + if logFormat != "" && logFormat != "console" && logFormat != "json" { + util.HandleError(fmt.Errorf("--log-format must be 'console' or 'json', got %q", logFormat)) + } + logWriter, err := BuildAgentProxyLogWriter(logFormat, "") + if err != nil { + util.HandleError(err) + } + log.Logger = log.Output(logWriter) + + accessToken := resolveAgentProxyAccessToken(cmd) + + Telemetry.CaptureEvent("cli-command:agent-proxy start", posthog.NewProperties().Set("version", util.CLI_VERSION)) + + if err := agentproxy.StartPolicyProxy(agentproxy.PolicyOptions{ + Port: port, + PollInterval: time.Duration(pollInterval) * time.Second, + ProxyToken: func() string { return accessToken }, + }); err != nil { + util.HandleError(err, "Agent proxy failed") + } +} + +func init() { + agentProxyServerStartCmd.Flags().Int("port", 17323, "port for the agent proxy to listen on") + agentProxyServerStartCmd.Flags().Int("poll-interval", 60, "seconds between policy refreshes for active sessions") + agentProxyServerStartCmd.Flags().String("token", "", "one-time enrollment token from Infisical (falls back to INFISICAL_AGENT_PROXY_ENROLLMENT_TOKEN)") + agentProxyServerStartCmd.Flags().String("log-format", "console", "log output format: console | json") + + agentProxyServerCmd.AddCommand(agentProxyServerStartCmd) + RootCmd.AddCommand(agentProxyServerCmd) +} From 4dc9403c88ba264c65db6d47ff8e6b8a8c1a8688 Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:32:52 +0100 Subject: [PATCH 2/3] improvement(agent-proxy): document the required proxy URL form for session tokens --- packages/agentproxy/policy_server.go | 6 +++++- packages/cmd/agent_proxy_server.go | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/agentproxy/policy_server.go b/packages/agentproxy/policy_server.go index 70792046..9b9e352e 100644 --- a/packages/agentproxy/policy_server.go +++ b/packages/agentproxy/policy_server.go @@ -149,7 +149,11 @@ func (ps *policyServer) heartbeatLoop(stop <-chan struct{}) { // sessionToken pulls the session token out of Proxy-Authorization. Both forms are accepted because an // agent's HTTP client decides which one it sends: Basic with the token as the username (what an -// http://@host:port proxy URL produces) and Bearer. +// http://:@host:port proxy URL produces) and Bearer. +// +// The proxy URL needs the trailing colon after the token. urllib3 parses http://@host as having +// no password and sends no Proxy-Authorization at all, so the request arrives here unauthenticated and +// gets a 407; curl and Node send the header either way. func sessionToken(header string) (string, bool) { if strings.HasPrefix(header, "Bearer ") { token := strings.TrimSpace(strings.TrimPrefix(header, "Bearer ")) diff --git a/packages/cmd/agent_proxy_server.go b/packages/cmd/agent_proxy_server.go index 618ce9cc..9c923e79 100644 --- a/packages/cmd/agent_proxy_server.go +++ b/packages/cmd/agent_proxy_server.go @@ -26,7 +26,14 @@ var agentProxyServerCmd = &cobra.Command{ An agent proxy is registered in Infisical under Networking. Point an agent's HTTP_PROXY at it with a session token as the proxy credential, and it applies the policies for that session: a request is allowed -when both the agent's policies and the user's allow it, and only then is a credential attached.`, +when both the agent's policies and the user's allow it, and only then is a credential attached. + +The proxy URL must carry the session token with a trailing colon: + + HTTP_PROXY=http://:@:17323 + +The colon matters. Without it the userinfo has no password, and some clients (urllib3, and so requests) +then send no Proxy-Authorization header at all and get a 407.`, Example: `# First run, with a one-time enrollment token from the Infisical UI infisical agent-proxy start --token= --port=17323 From 4b4d6d6d8b5304ff8a656c41222e5ca6f52ad8ed Mon Sep 17 00:00:00 2001 From: saif <11242541+saifsmailbox98@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:15:38 +0100 Subject: [PATCH 3/3] feat(agent-proxy): report the matched user policy on activity events --- packages/agentproxy/policy_match.go | 32 +++++++++-------- packages/agentproxy/policy_match_test.go | 37 +++++++++++++------ packages/agentproxy/policy_server.go | 45 ++++++++++++++---------- packages/api/agent_policies.go | 17 ++++----- 4 files changed, 81 insertions(+), 50 deletions(-) diff --git a/packages/agentproxy/policy_match.go b/packages/agentproxy/policy_match.go index c1b1f37d..35b3c16b 100644 --- a/packages/agentproxy/policy_match.go +++ b/packages/agentproxy/policy_match.go @@ -158,13 +158,14 @@ type resolvedUserPolicy struct { // evaluate applies the intersection: a request is allowed when it matches at least one rule on an agent // policy AND at least one rule on a user policy. Neither side is a subset of the other, so this is a // per-request check, not set arithmetic. The winning agent policy is the most specific match, which is -// what decides whose credentials get injected. +// what decides whose credentials get injected. The user side picks its winner the same way even though +// only its existence gates the request, so the pair reported to the activity feed is comparable. func evaluate( agentPolicies []*resolvedAgentPolicy, userPolicies []*resolvedUserPolicy, scheme, host, port, path, method string, -) (matched *resolvedAgentPolicy, userAllowed bool) { - var bestDetail matchDetail +) (matched *resolvedAgentPolicy, matchedUser *resolvedUserPolicy) { + var bestAgent, bestUser matchDetail for _, policy := range agentPolicies { for _, rule := range policy.rules { @@ -173,27 +174,30 @@ func evaluate( continue } switch { - case matched == nil, detail.betterThan(bestDetail): - matched, bestDetail = policy, detail - case detail.equalTo(bestDetail) && policy.name < matched.name: - matched, bestDetail = policy, detail + case matched == nil, detail.betterThan(bestAgent): + matched, bestAgent = policy, detail + case detail.equalTo(bestAgent) && policy.name < matched.name: + matched, bestAgent = policy, detail } } } for _, policy := range userPolicies { for _, rule := range policy.rules { - if ok, _ := rule.pattern.match(scheme, host, port, path, method); ok { - userAllowed = true - break + ok, detail := rule.pattern.match(scheme, host, port, path, method) + if !ok { + continue + } + switch { + case matchedUser == nil, detail.betterThan(bestUser): + matchedUser, bestUser = policy, detail + case detail.equalTo(bestUser) && policy.name < matchedUser.name: + matchedUser, bestUser = policy, detail } - } - if userAllowed { - break } } - return matched, userAllowed + return matched, matchedUser } // agentCoversHost reports whether any agent rule could match this host at all, ignoring method and path. diff --git a/packages/agentproxy/policy_match_test.go b/packages/agentproxy/policy_match_test.go index fb198859..5b69eabd 100644 --- a/packages/agentproxy/policy_match_test.go +++ b/packages/agentproxy/policy_match_test.go @@ -87,16 +87,16 @@ func TestEvaluateUserNarrowsAgent(t *testing.T) { agents := []*resolvedAgentPolicy{agentPolicy("slack", parsePolicyPattern("api.slack.com", nil))} users := []*resolvedUserPolicy{userPolicy("read-only", parsePolicyPattern("api.slack.com", []string{"GET"}))} - matched, allowed := evaluate(agents, users, "https", "api.slack.com", "443", "/conversations.list", "GET") - if matched == nil || !allowed { + matched, matchedUser := evaluate(agents, users, "https", "api.slack.com", "443", "/conversations.list", "GET") + if matched == nil || matchedUser == nil { t.Fatal("expected GET to be allowed by both sides") } - matched, allowed = evaluate(agents, users, "https", "api.slack.com", "443", "/chat.postMessage", "POST") + matched, matchedUser = evaluate(agents, users, "https", "api.slack.com", "443", "/chat.postMessage", "POST") if matched == nil { t.Error("expected the agent side to still match a POST") } - if allowed { + if matchedUser != nil { t.Error("expected the user side to reject a POST") } } @@ -104,11 +104,11 @@ func TestEvaluateUserNarrowsAgent(t *testing.T) { func TestEvaluateWithNoUserPolicyDeniesEverything(t *testing.T) { agents := []*resolvedAgentPolicy{agentPolicy("slack", parsePolicyPattern("api.slack.com", nil))} - matched, allowed := evaluate(agents, nil, "https", "api.slack.com", "443", "/x", "GET") + matched, matchedUser := evaluate(agents, nil, "https", "api.slack.com", "443", "/x", "GET") if matched == nil { t.Error("expected the agent side to match") } - if allowed { + if matchedUser != nil { t.Error("expected no user policy to mean nothing is allowed") } } @@ -117,11 +117,11 @@ func TestEvaluateAgentWithoutRuleForHostDenies(t *testing.T) { agents := []*resolvedAgentPolicy{agentPolicy("slack", parsePolicyPattern("api.slack.com", nil))} users := []*resolvedUserPolicy{userPolicy("wide", parsePolicyPattern("api.github.com", nil))} - matched, allowed := evaluate(agents, users, "https", "api.github.com", "443", "/x", "GET") + matched, matchedUser := evaluate(agents, users, "https", "api.github.com", "443", "/x", "GET") if matched != nil { t.Error("expected no agent policy to match a host it has no rule for") } - if !allowed { + if matchedUser == nil { t.Error("expected the user side to match its own host") } } @@ -132,8 +132,8 @@ func TestEvaluatePrefersMoreSpecificAgentPolicy(t *testing.T) { exact := agentPolicy("exact", parsePolicyPattern("api.example.com", nil)) users := []*resolvedUserPolicy{userPolicy("all", parsePolicyPattern("*.example.com", nil))} - matched, allowed := evaluate([]*resolvedAgentPolicy{wildcard, exact}, users, "https", "api.example.com", "443", "/", "GET") - if !allowed || matched == nil { + matched, matchedUser := evaluate([]*resolvedAgentPolicy{wildcard, exact}, users, "https", "api.example.com", "443", "/", "GET") + if matchedUser == nil || matched == nil { t.Fatal("expected a match") } if matched.name != "exact" { @@ -141,6 +141,23 @@ func TestEvaluatePrefersMoreSpecificAgentPolicy(t *testing.T) { } } +// The user policy reported to the activity feed is the most specific one, same as the agent side. +func TestEvaluatePrefersMoreSpecificUserPolicy(t *testing.T) { + agents := []*resolvedAgentPolicy{agentPolicy("wide", parsePolicyPattern("*.example.com", nil))} + users := []*resolvedUserPolicy{ + userPolicy("wildcard", parsePolicyPattern("*.example.com", nil)), + userPolicy("exact", parsePolicyPattern("api.example.com", nil)), + } + + _, matchedUser := evaluate(agents, users, "https", "api.example.com", "443", "/", "GET") + if matchedUser == nil { + t.Fatal("expected a user policy to match") + } + if matchedUser.name != "exact" { + t.Errorf("expected the exact-host user policy to win, got %q", matchedUser.name) + } +} + func TestAgentCoversHostIgnoresMethodAndPath(t *testing.T) { // At CONNECT time only host and port are known, so a method-restricted rule must still open the tunnel. agents := []*resolvedAgentPolicy{ diff --git a/packages/agentproxy/policy_server.go b/packages/agentproxy/policy_server.go index 9b9e352e..0e52cdf1 100644 --- a/packages/agentproxy/policy_server.go +++ b/packages/agentproxy/policy_server.go @@ -217,7 +217,7 @@ func (ps *policyServer) handleConnect(w http.ResponseWriter, r *http.Request) { // opens the tunnel and is decided per request inside it, where the method and path are known. allowlisted := hostAllowed(session.allowedHosts, hostname) if !allowlisted && !agentCoversHost(session.agentPolicies, hostname, port) { - ps.record(session, "blocked", r.Method, hostname, port, "", 0, "", "no policy covers this host") + ps.record(session, "blocked", r.Method, hostname, port, "", 0, "", "", "no policy covers this host") http.Error(w, "host is not allowed by policy", http.StatusForbidden) return } @@ -325,20 +325,20 @@ func (ps *policyServer) forwardHTTP(w http.ResponseWriter, r *http.Request, sche return } - matched, userAllowed := evaluate(session.agentPolicies, session.userPolicies, scheme, hostname, port, r.URL.Path, r.Method) + matched, matchedUser := evaluate(session.agentPolicies, session.userPolicies, scheme, hostname, port, r.URL.Path, r.Method) allowlisted := hostAllowed(session.allowedHosts, hostname) switch { - case matched != nil && userAllowed: + case matched != nil && matchedUser != nil: // Brokered: both sides allow it, so the credential goes on. case allowlisted: // Passes through with no credential. Infrastructure hosts an agent needs to function live here. default: reason := "no agent policy allows this request" - if matched != nil && !userAllowed { + if matched != nil && matchedUser == nil { reason = "no user policy allows this request" } - ps.record(session, "blocked", r.Method, hostname, port, r.URL.Path, http.StatusForbidden, policyName(matched), reason) + ps.record(session, "blocked", r.Method, hostname, port, r.URL.Path, http.StatusForbidden, policyName(matched), userPolicyName(matchedUser), reason) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusForbidden) _, _ = w.Write([]byte(policyDeniedBody)) @@ -356,9 +356,9 @@ func (ps *policyServer) forwardHTTP(w http.ResponseWriter, r *http.Request, sche stripHopByHopHeaders(r.Header) decision := "passthrough" - if matched != nil && userAllowed { + if matched != nil && matchedUser != nil { if _, applyErr := applyCredentials(r, matched.credentials); applyErr != nil { - ps.record(session, "error", r.Method, hostname, port, r.URL.Path, http.StatusBadGateway, matched.name, "failed to apply credentials") + ps.record(session, "error", r.Method, hostname, port, r.URL.Path, http.StatusBadGateway, matched.name, userPolicyName(matchedUser), "failed to apply credentials") http.Error(w, "failed to apply credentials", http.StatusBadGateway) return } @@ -367,7 +367,7 @@ func (ps *policyServer) forwardHTTP(w http.ResponseWriter, r *http.Request, sche resp, err := ps.transport.RoundTrip(r) if err != nil { - ps.record(session, "error", r.Method, hostname, port, r.URL.Path, http.StatusBadGateway, policyName(matched), err.Error()) + ps.record(session, "error", r.Method, hostname, port, r.URL.Path, http.StatusBadGateway, policyName(matched), userPolicyName(matchedUser), err.Error()) http.Error(w, "upstream request failed", http.StatusBadGateway) return } @@ -381,7 +381,7 @@ func (ps *policyServer) forwardHTTP(w http.ResponseWriter, r *http.Request, sche w.WriteHeader(resp.StatusCode) _, _ = io.Copy(flushingWriter{w: w}, resp.Body) - ps.record(session, decision, r.Method, hostname, port, r.URL.Path, resp.StatusCode, policyName(matched), "") + ps.record(session, decision, r.Method, hostname, port, r.URL.Path, resp.StatusCode, policyName(matched), userPolicyName(matchedUser), "") } func policyName(policy *resolvedAgentPolicy) string { @@ -391,11 +391,18 @@ func policyName(policy *resolvedAgentPolicy) string { return policy.name } +func userPolicyName(policy *resolvedUserPolicy) string { + if policy == nil { + return "" + } + return policy.name +} + func (ps *policyServer) record( session *sessionEntry, decision, method, host, port, path string, status int, - policy, reason string, + policy, userPolicy, reason string, ) { portNum, _ := strconv.Atoi(port) if portNum == 0 { @@ -418,17 +425,19 @@ func (ps *policyServer) record( Str("path", path). Int("status", status). Str("policy", policy). + Str("userPolicy", userPolicy). Str("reason", reason). Msg("agent request") ps.resolver.recordActivity(session.token, api.AgentSessionActivityEvent{ - Decision: decision, - Method: method, - Host: host, - Port: portNum, - Path: path, - StatusCode: status, - PolicyName: policy, - Reason: reason, + Decision: decision, + Method: method, + Host: host, + Port: portNum, + Path: path, + StatusCode: status, + PolicyName: policy, + UserPolicyName: userPolicy, + Reason: reason, }) } diff --git a/packages/api/agent_policies.go b/packages/api/agent_policies.go index f7e53877..ad511c3f 100644 --- a/packages/api/agent_policies.go +++ b/packages/api/agent_policies.go @@ -122,14 +122,15 @@ func CallResolveAgentSession(httpClient *resty.Client, sessionToken string) (Res } type AgentSessionActivityEvent struct { - Decision string `json:"decision"` - Method string `json:"method"` - Host string `json:"host"` - Port int `json:"port"` - Path string `json:"path"` - StatusCode int `json:"statusCode,omitempty"` - PolicyName string `json:"policyName,omitempty"` - Reason string `json:"reason,omitempty"` + Decision string `json:"decision"` + Method string `json:"method"` + Host string `json:"host"` + Port int `json:"port"` + Path string `json:"path"` + StatusCode int `json:"statusCode,omitempty"` + PolicyName string `json:"policyName,omitempty"` + UserPolicyName string `json:"userPolicyName,omitempty"` + Reason string `json:"reason,omitempty"` } type recordAgentSessionActivityRequest struct {