-
Notifications
You must be signed in to change notification settings - Fork 65
feat(agent-proxy): add policy-mode agent proxy for agent and user policy intersection #361
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
saifsmailbox98
wants to merge
3
commits into
main
Choose a base branch
from
agent-policies
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
2bc9d2e
feat(agent-proxy): add policy-mode agent proxy for agent and user pol…
saifsmailbox98 4dc9403
improvement(agent-proxy): document the required proxy URL form for se…
saifsmailbox98 4b4d6d6
feat(agent-proxy): report the matched user policy on activity events
saifsmailbox98 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| 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. 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, matchedUser *resolvedUserPolicy) { | ||
| var bestAgent, bestUser 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(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 { | ||
| 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 | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return matched, matchedUser | ||
| } | ||
|
|
||
| // 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 | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Medium: Scheme-less policies permit plaintext credential injection
An authenticated agent can select an
http://URL for any rule whose host pattern omits a scheme, causing the proxy to inject the policy's credential into a plaintext upstream request. Treat omitted schemes as HTTPS for credential-bearing policies, or require an explicit scheme and keep bare-host allowlist matching separate.