diff --git a/internal/plugins/proxy/errors.go b/internal/plugins/proxy/errors.go new file mode 100644 index 000000000..d3b73dab9 --- /dev/null +++ b/internal/plugins/proxy/errors.go @@ -0,0 +1,140 @@ +package proxy + +import ( + "encoding/json" + "maps" + "net/http" + "strconv" + "strings" + "unicode/utf8" +) + +// proxyError is an error that is a constant, so that the sentinel errors below +// cannot be changed. Their messages are sentences that are shown to users as +// they are. +type proxyError string + +// Error returns the message of the error. +func (e proxyError) Error() string { + return string(e) +} + +// The errors that building a policy and starting the proxy can return. Use +// errors.Is to tell them apart. +const ( + // ErrReloginRequired is what a TokenSource returns from Refresh when the + // session cannot be renewed without the user, which is the case for a user + // session. The plugin then gets a session_expired error that tells the user to + // log in again. + ErrReloginRequired = proxyError("Your Auth0 session cannot be renewed silently. Run `auth0 login` and try again.") + + // ErrNoScopeTable means the registry index has no scope table, so there is no + // way to tell which Management API calls a scope allows. No plugin runs then. + ErrNoScopeTable = proxyError("The registry index has no scope table, so plugins cannot call the Management API.") + + // ErrNoSecretFields means the registry index lists no secret fields. The + // proxy would forward responses without removing secrets, so no plugin runs. + ErrNoSecretFields = proxyError("The registry index lists no secret fields, so plugins cannot call the Management API.") + + // ErrInvalidDomain means the tenant domain is not a bare host name. + ErrInvalidDomain = proxyError("The tenant domain must be a host name such as example.us.auth0.com, without a scheme, port or path.") +) + +// errRefreshLimit is why the proxy stops asking the token source for a new +// token. The plugin never sees it, because it gets session_expired instead. +const errRefreshLimit = proxyError("The proxy refreshed the token too often in this run.") + +// errorCode names why the proxy answered with an error. SDKs of the Management +// API read it from the errorCode field of the error body. +type errorCode string + +// The error codes that the proxy answers with. +const ( + codeInvalidProxyToken errorCode = "invalid_proxy_token" + codeBadRequest errorCode = "bad_request" + codeMethodNotAllowed errorCode = "method_not_allowed" + codeURITooLong errorCode = "uri_too_long" + codeForbiddenPath errorCode = "forbidden_path" + codeNoScopeRule errorCode = "no_scope_rule" + codeInsufficientScope errorCode = "insufficient_scope" + codePayloadTooLarge errorCode = "payload_too_large" + codeSessionExpired errorCode = "session_expired" + codeUnsupportedResponse errorCode = "unsupported_response" + codeUpstreamRedirect errorCode = "upstream_redirect" + codeUpstreamFailed errorCode = "upstream_failed" +) + +// rejection is a request that the proxy answers itself instead of forwarding. +type rejection struct { + status int + code errorCode + message string + + // The scope is the first one that would have allowed the call, when a missing + // scope is why it was denied. The summary names it. + scope string + + // The header holds headers that go with the answer, such as Allow. + header http.Header +} + +// reject returns a rejection with the given status and message. +func reject(status int, code errorCode, message string) *rejection { + return &rejection{status: status, code: code, message: message} +} + +// write sends the rejection as the error body of the Management API. +func (r *rejection) write(w http.ResponseWriter) { + // Strings and integers always encode, so there is no error to handle. + body, _ := json.Marshal(struct { + StatusCode int `json:"statusCode"` + Error string `json:"error"` + Message string `json:"message"` + ErrorCode errorCode `json:"errorCode"` + }{r.status, http.StatusText(r.status), r.message, r.code}) + + maps.Copy(w.Header(), r.header) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.WriteHeader(r.status) + _, _ = w.Write(body) +} + +// maxEchoBytes is the longest piece of a request that an error message repeats. +const maxEchoBytes = 80 + +// safeText returns s in a form that is safe to print to a terminal and to put in +// a message: printable ASCII only, with anything else replaced by a question +// mark, and cut to maxEchoBytes. +func safeText(s string) string { + var out strings.Builder + + i := 0 + for i < len(s) && out.Len() < maxEchoBytes { + r, size := utf8.DecodeRuneInString(s[i:]) + i += size + + if r >= ' ' && r <= '~' { + out.WriteRune(r) + } else { + out.WriteByte('?') + } + } + + if i < len(s) { + out.WriteString("...") + } + + return out.String() +} + +// quoted returns s as a quoted, escaped and cut piece of text, for the errors +// that repeat a request that was not valid. +func quoted(s string) string { + if len(s) > maxEchoBytes { + s = strings.ToValidUTF8(s[:maxEchoBytes], "") + "..." + } + + return strconv.QuoteToASCII(s) +} diff --git a/internal/plugins/proxy/errors_test.go b/internal/plugins/proxy/errors_test.go new file mode 100644 index 000000000..8cb5a8e7c --- /dev/null +++ b/internal/plugins/proxy/errors_test.go @@ -0,0 +1,62 @@ +package proxy + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRejection_write(t *testing.T) { + rej := reject(http.StatusForbidden, codeInsufficientScope, `Plugin a/b did not declare scope "read:users".`) + rej.header = http.Header{} + rej.header.Set("Allow", "GET") + + recorder := httptest.NewRecorder() + rej.write(recorder) + + assert.Equal(t, http.StatusForbidden, recorder.Code) + assert.Equal(t, "application/json", recorder.Header().Get("Content-Type")) + assert.Equal(t, "GET", recorder.Header().Get("Allow")) + + var body map[string]any + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body)) + assert.Equal(t, map[string]any{ + "statusCode": float64(403), + "error": "Forbidden", + "message": `Plugin a/b did not declare scope "read:users".`, + "errorCode": "insufficient_scope", + }, body) +} + +func TestSafeText(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {name: "plain text", in: "GET /api/v2/users", want: "GET /api/v2/users"}, + {name: "a control character", in: "a\x1b[31mb", want: "a?[31mb"}, + {name: "a newline", in: "a\nb", want: "a?b"}, + {name: "a bidi override", in: "a\u202eb", want: "a?b"}, + {name: "invalid UTF-8", in: "a\xffb", want: "a?b"}, + {name: "long text", in: strings.Repeat("a", 100), want: strings.Repeat("a", 80) + "..."}, + {name: "text of exactly the limit", in: strings.Repeat("a", 80), want: strings.Repeat("a", 80)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, safeText(test.in)) + }) + } +} + +func TestQuoted(t *testing.T) { + assert.Equal(t, `"a\x1b[31m\u00e9"`, quoted("a\x1b[31mé")) + assert.Equal(t, `"`+strings.Repeat("a", 80)+`..."`, quoted(strings.Repeat("a", 100))) + assert.NotContains(t, quoted("\xff\xfe"), "\xff") +} diff --git a/internal/plugins/proxy/path.go b/internal/plugins/proxy/path.go new file mode 100644 index 000000000..ff68ea61b --- /dev/null +++ b/internal/plugins/proxy/path.go @@ -0,0 +1,147 @@ +package proxy + +import ( + "net/http" + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +// The limits on what a plugin can send in the request line. +const ( + maxPathBytes = 2048 + maxQuery = 8 << 10 + maxSegments = 18 +) + +// target is the part of a request that the proxy checked and forwards. +type target struct { + // The path is decoded. It is both what the scope check looks at and what is + // forwarded, so the two cannot differ. + path string + + // The segments are those of the path after the Management API prefix. + segments []string + + // The query is the raw query string, forwarded as it came. + query string +} + +// parseTarget checks the request target of a request, which is the raw text of +// the request line, and returns what the proxy forwards. A path that is not +// already canonical is rejected, never repaired: the proxy does not clean up +// dot segments, repeated slashes or escapes, because a repair is a second +// interpretation of the path that could differ from the one the server behind +// the proxy makes. +func parseTarget(requestURI string) (target, *rejection) { + if !strings.HasPrefix(requestURI, "/") { + return target{}, reject(http.StatusBadRequest, codeBadRequest, + "The request target "+quoted(requestURI)+" must be a path that starts with /.") + } + + rawPath, query, _ := strings.Cut(requestURI, "?") + + if len(rawPath) > maxPathBytes || len(query) > maxQuery { + return target{}, reject(http.StatusRequestURITooLong, codeURITooLong, "The request target is too long.") + } + + if !validQuery(query) { + return target{}, reject(http.StatusBadRequest, codeBadRequest, "The query string "+quoted(query)+" has characters that are not allowed.") + } + + path, problem := decodePath(rawPath) + if problem != "" { + return target{}, reject(http.StatusBadRequest, codeBadRequest, "The path "+quoted(rawPath)+" is not allowed: "+problem+".") + } + + segments := strings.Split(path[1:], "/") + if len(segments) > maxSegments { + return target{}, reject(http.StatusBadRequest, codeBadRequest, "The path "+quoted(rawPath)+" has too many segments.") + } + + for _, segment := range segments { + if segment == "" || segment == "." || segment == ".." { + return target{}, reject(http.StatusBadRequest, codeBadRequest, + "The path "+quoted(rawPath)+" is not allowed: it has an empty or dot segment, or a trailing slash.") + } + } + + if !strings.HasPrefix(path, managementPrefix) { + return target{}, reject(http.StatusForbidden, codeForbiddenPath, + "Plugins can only call the Management API, under "+managementPrefix+". The path "+quoted(safeText(path))+" is not allowed.") + } + + // The prefix is the first two segments, and no segment is empty, so the rest + // of the segments are the ones after it. + return target{path: path, segments: segments[2:], query: query}, nil +} + +// decodePath decodes the escapes of a raw path and returns the decoded path. It +// returns a short reason instead when the path is not canonical: an escape that +// hides a slash, a dot, a backslash, a null byte or a percent sign, an escape +// of a character that needs none, or a character that has no business in a +// path. +func decodePath(raw string) (string, string) { + var decoded strings.Builder + + decoded.Grow(len(raw)) + + for i := 0; i < len(raw); i++ { + switch c := raw[i]; { + case c == '#': + return "", "it has a fragment" + case c != '%': + decoded.WriteByte(c) + case i+2 >= len(raw): + return "", "it has an escape that is cut short" + default: + value, err := strconv.ParseUint(raw[i+1:i+3], 16, 8) + if err != nil { + return "", "it has an escape that is not hexadecimal" + } + + c = byte(value) + i += 2 + + switch { + case c == '/' || c == '.' || c == '\\' || c == 0 || c == '%': + return "", "it has an escaped slash, dot, backslash, null byte or percent sign" + case isUnreserved(c): + return "", "it escapes a character that needs no escape" + } + + decoded.WriteByte(c) + } + } + + path := decoded.String() + + if !utf8.ValidString(path) { + return "", "it is not valid UTF-8" + } + + if strings.ContainsAny(path, ";\\") || strings.IndexFunc(path, unicode.IsControl) >= 0 { + return "", "it has a semicolon, a backslash or a control character" + } + + return path, "" +} + +// isUnreserved reports whether c is a character that a URL never needs to +// escape: a letter, a digit, or one of - _ . ~. +func isUnreserved(c byte) bool { + return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' || c == '~' +} + +// validQuery reports whether a raw query string only has printable ASCII +// characters and no fragment marker, so that it can be forwarded as it is. +func validQuery(query string) bool { + for i := 0; i < len(query); i++ { + if c := query[i]; c <= ' ' || c >= 0x7f || c == '#' { + return false + } + } + + return true +} diff --git a/internal/plugins/proxy/path_test.go b/internal/plugins/proxy/path_test.go new file mode 100644 index 000000000..5fbf0e737 --- /dev/null +++ b/internal/plugins/proxy/path_test.go @@ -0,0 +1,117 @@ +package proxy + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseTarget(t *testing.T) { + longSegments := "/api/v2" + strings.Repeat("/a", 16) + + tests := []struct { + name string + target string + wantPath string + wantSegments []string + wantQuery string + wantStatus int + wantCode errorCode + + // The reason is part of the message, for the rejections that other checks + // would also catch, so that a test fails when one of them is removed. + wantReason string + }{ + {name: "a plain call", target: "/api/v2/clients", wantPath: "/api/v2/clients", wantSegments: []string{"clients"}}, + {name: "an id with a pipe, escaped the way clients do", target: "/api/v2/users/auth0%7C123", wantPath: "/api/v2/users/auth0|123", wantSegments: []string{"users", "auth0|123"}}, + {name: "an id with a colon escaped", target: "/api/v2/users/a%3Ab", wantPath: "/api/v2/users/a:b", wantSegments: []string{"users", "a:b"}}, + {name: "escapes in lowercase", target: "/api/v2/users/a%7cb", wantPath: "/api/v2/users/a|b", wantSegments: []string{"users", "a|b"}}, + {name: "the query as it came", target: "/api/v2/clients?per_page=1&fields=a%2Cb", wantPath: "/api/v2/clients", wantSegments: []string{"clients"}, wantQuery: "per_page=1&fields=a%2Cb"}, + {name: "an empty query", target: "/api/v2/clients?", wantPath: "/api/v2/clients", wantSegments: []string{"clients"}}, + {name: "16 segments after the prefix", target: longSegments, wantPath: longSegments, wantSegments: strings.Split(longSegments[8:], "/")}, + + {name: "dot dot", target: "/api/v2/../users", wantStatus: 400}, + {name: "dot dot after a resource", target: "/api/v2/clients/../users", wantStatus: 400}, + {name: "a dot", target: "/api/v2/./clients", wantStatus: 400}, + {name: "escaped dot dot", target: "/api/v2/%2e%2e/users", wantStatus: 400, wantReason: "escaped slash, dot, backslash, null byte or percent sign"}, + {name: "one escaped dot", target: "/api/v2/.%2e/users", wantStatus: 400, wantReason: "escaped slash, dot, backslash, null byte or percent sign"}, + {name: "an escaped slash", target: "/api/v2/clients%2fusers", wantStatus: 400, wantReason: "escaped slash, dot, backslash, null byte or percent sign"}, + {name: "an escaped backslash", target: "/api/v2/clients%5cusers", wantStatus: 400, wantReason: "escaped slash, dot, backslash, null byte or percent sign"}, + {name: "a backslash", target: `/api/v2/clients\users`, wantStatus: 400}, + {name: "an escaped null byte", target: "/api/v2/clients%00", wantStatus: 400, wantReason: "escaped slash, dot, backslash, null byte or percent sign"}, + {name: "an escaped percent sign", target: "/api/v2/%252e%252e/users", wantStatus: 400, wantReason: "escaped slash, dot, backslash, null byte or percent sign"}, + {name: "an escaped newline", target: "/api/v2/clients%0a", wantStatus: 400}, + {name: "an escaped C1 control", target: "/api/v2/clients%c2%85", wantStatus: 400}, + {name: "an escaped semicolon", target: "/api/v2/clients%3bx", wantStatus: 400}, + {name: "a semicolon", target: "/api/v2/clients;x=1", wantStatus: 400}, + {name: "an escape of a letter", target: "/api/v2/%63lients", wantStatus: 400, wantReason: "needs no escape"}, + {name: "an escape of a digit", target: "/api/v2/users/%31", wantStatus: 400, wantReason: "needs no escape"}, + {name: "an escape cut short", target: "/api/v2/clients%2", wantStatus: 400}, + {name: "an escape that is not hexadecimal", target: "/api/v2/clients%zz", wantStatus: 400}, + {name: "an escape of invalid UTF-8", target: "/api/v2/clients%ff", wantStatus: 400}, + {name: "invalid UTF-8", target: "/api/v2/clients\xff", wantStatus: 400}, + {name: "a fragment", target: "/api/v2/clients#x", wantStatus: 400}, + {name: "a double slash", target: "/api/v2//clients", wantStatus: 400}, + {name: "a double slash at the start", target: "//api/v2/clients", wantStatus: 400}, + {name: "a trailing slash", target: "/api/v2/clients/", wantStatus: 400}, + {name: "the prefix with a trailing slash", target: "/api/v2/", wantStatus: 400}, + {name: "the root", target: "/", wantStatus: 400}, + {name: "17 segments after the prefix", target: longSegments + "/a", wantStatus: 400}, + {name: "an absolute URL", target: "http://example.com/api/v2/clients", wantStatus: 400}, + {name: "an authority", target: "example.com:443", wantStatus: 400}, + {name: "the asterisk", target: "*", wantStatus: 400}, + {name: "a query with a space", target: "/api/v2/clients?a=b c", wantStatus: 400}, + {name: "a query with a control character", target: "/api/v2/clients?a=\x01", wantStatus: 400}, + {name: "a query with a fragment", target: "/api/v2/clients?a=b#c", wantStatus: 400}, + {name: "a query with a byte above ASCII", target: "/api/v2/clients?a=\xc3\xa9", wantStatus: 400}, + + {name: "a path of 2049 bytes", target: "/api/v2/" + strings.Repeat("a", 2041), wantStatus: 414}, + {name: "a query of 8193 bytes", target: "/api/v2/clients?" + strings.Repeat("a", 8193), wantStatus: 414}, + + {name: "the token endpoint", target: "/oauth/token", wantStatus: 403, wantCode: codeForbiddenPath}, + {name: "the userinfo endpoint", target: "/userinfo", wantStatus: 403, wantCode: codeForbiddenPath}, + {name: "the prefix in capitals", target: "/API/V2/clients", wantStatus: 403, wantCode: codeForbiddenPath}, + {name: "the prefix with a capital", target: "/api/V2/clients", wantStatus: 403, wantCode: codeForbiddenPath}, + {name: "a look-alike prefix", target: "/api/v2x/clients", wantStatus: 403, wantCode: codeForbiddenPath}, + {name: "the prefix alone", target: "/api/v2", wantStatus: 403, wantCode: codeForbiddenPath}, + {name: "the prefix of another version", target: "/api/v3/clients", wantStatus: 403, wantCode: codeForbiddenPath}, + {name: "the prefix under another path", target: "/x/api/v2/clients", wantStatus: 403, wantCode: codeForbiddenPath}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, rej := parseTarget(test.target) + + if test.wantStatus != 0 { + require.NotNil(t, rej) + assert.Equal(t, test.wantStatus, rej.status) + assert.Empty(t, got) + + if test.wantCode != "" { + assert.Equal(t, test.wantCode, rej.code) + } + + assert.Contains(t, rej.message, test.wantReason) + + return + } + + require.Nil(t, rej) + assert.Equal(t, test.wantPath, got.path) + assert.Equal(t, test.wantSegments, got.segments) + assert.Equal(t, test.wantQuery, got.query) + }) + } +} + +func TestParseTarget_ErrorsEchoOnlyASafeForm(t *testing.T) { + _, rej := parseTarget("/api/v2/clients\x1b[31m%00" + strings.Repeat("a", 500)) + + require.NotNil(t, rej) + assert.Equal(t, http.StatusBadRequest, rej.status) + assert.NotContains(t, rej.message, "\x1b") + assert.Less(t, len(rej.message), 300) +} diff --git a/internal/plugins/proxy/policy.go b/internal/plugins/proxy/policy.go new file mode 100644 index 000000000..e682ae454 --- /dev/null +++ b/internal/plugins/proxy/policy.go @@ -0,0 +1,220 @@ +package proxy + +import ( + "net/http" + "slices" + "strings" + "unicode" + + "github.com/auth0/auth0-cli/internal/plugins/registry" +) + +// managementPrefix is the path every call to the Management API starts with. +const managementPrefix = "/api/v2/" + +// allowedMethods are the methods that the proxy forwards. +var allowedMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} + +// segment is one segment of a path template. +type segment struct { + // The literal is the text a path segment must equal, empty for a parameter. + literal string + + // A parameter matches any one non-empty segment. + parameter bool +} + +// rule is a compiled scope rule. +type rule struct { + method string + path string + segments []segment + anyOf []string +} + +// Policy holds the scope rules and the secret field names of a registry index, +// compiled for the proxy. It never changes after NewPolicy returns it, so it can +// be shared. +type Policy struct { + // The rules are sorted so that the first rule that matches a call is the most + // specific one. + rules []rule + + // The secrets are the folded names of the fields removed from responses. + secrets map[string]struct{} +} + +// NewPolicy compiles the scope table and the secret fields of an index. It +// returns ErrNoScopeTable when the index has no rule and ErrNoSecretFields when +// it lists no name, so that a registry that does not publish them cannot make the +// proxy forward calls without limits or without removing secrets. It checks both +// with the validators of the registry package, because the index may not have +// come from ParseIndex. +func NewPolicy(index *registry.Index) (*Policy, error) { + if index == nil || index.ScopeTable.FormatVersion != 1 || len(index.ScopeTable.Rules) == 0 { + return nil, ErrNoScopeTable + } + + if index.SecretFields.FormatVersion != 1 || len(index.SecretFields.Names) == 0 { + return nil, ErrNoSecretFields + } + + if err := index.ScopeTable.Validate(); err != nil { + return nil, err + } + + if err := index.SecretFields.Validate(); err != nil { + return nil, err + } + + policy := &Policy{ + rules: make([]rule, 0, len(index.ScopeTable.Rules)), + secrets: make(map[string]struct{}, len(index.SecretFields.Names)), + } + + for _, source := range index.ScopeTable.Rules { + policy.rules = append(policy.rules, compileRule(source)) + } + + slices.SortStableFunc(policy.rules, compareRules) + + for _, name := range index.SecretFields.Names { + policy.secrets[foldName(name)] = struct{}{} + } + + return policy, nil +} + +// compileRule compiles the path of a rule that the registry package validated: +// it starts with the Management API prefix and every segment is a literal or a +// parameter in braces. +func compileRule(source registry.ScopeRule) rule { + var segments []segment + + for part := range strings.SplitSeq(strings.TrimPrefix(source.Path, managementPrefix), "/") { + if strings.HasPrefix(part, "{") { + segments = append(segments, segment{parameter: true}) + } else { + segments = append(segments, segment{literal: part}) + } + } + + return rule{method: source.Method, path: source.Path, segments: segments, anyOf: slices.Clone(source.AnyOf)} +} + +// compareRules orders rules so that, of two rules that match the same call, the +// more specific one comes first: at the first segment where they differ, a +// literal comes before a parameter. Rules for paths of different lengths never +// match the same call, so their order does not matter. +func compareRules(a, b rule) int { + if len(a.segments) != len(b.segments) { + return len(a.segments) - len(b.segments) + } + + for n := range a.segments { + x, y := a.segments[n], b.segments[n] + + switch { + case x.parameter && !y.parameter: + return 1 + case !x.parameter && y.parameter: + return -1 + case x.literal != y.literal: + return strings.Compare(x.literal, y.literal) + } + } + + return strings.Compare(a.method, b.method) +} + +// match returns the most specific rule for the method and the segments of a +// path that come after the Management API prefix. +func (p *Policy) match(method string, segments []string) (rule, bool) { + return p.find(method, segments, func(a, b string) bool { return a == b }) +} + +// ambiguous reports whether the call could be routed to another rule than the +// one that matched it. A server that ignores the case of a path would route the +// call by the rule that matches when the case of the literals is ignored, and +// the scope check must not have looked at a different rule. The matched rule is +// the one that match returned for the same method and segments. +func (p *Policy) ambiguous(method string, segments []string, matched rule) bool { + folded, ok := p.find(method, segments, strings.EqualFold) + + return !ok || folded.path != matched.path +} + +// find returns the first rule, which is the most specific, whose literals equal +// the segments of the call according to equal. +func (p *Policy) find(method string, segments []string, equal func(a, b string) bool) (rule, bool) { + for n := range p.rules { + if candidate := &p.rules[n]; candidate.method == method && candidate.matches(segments, equal) { + return *candidate, true + } + } + + return rule{}, false +} + +// matches reports whether the segments are a call that the rule describes. +func (r *rule) matches(segments []string, equal func(a, b string) bool) bool { + if len(segments) != len(r.segments) { + return false + } + + for n, want := range r.segments { + switch { + case segments[n] == "": + return false + case !want.parameter && !equal(segments[n], want.literal): + return false + } + } + + return true +} + +// allowedBy reports whether the command declared at least one of the scopes that +// allow the call. +func (r *rule) allowedBy(declared map[string]struct{}) bool { + return slices.ContainsFunc(r.anyOf, func(scope string) bool { + _, ok := declared[scope] + + return ok + }) +} + +// isSecret reports whether a field name is one that responses must not carry. +func (p *Policy) isSecret(name string) bool { + _, ok := p.secrets[foldName(name)] + + return ok +} + +// foldName returns the name with every letter replaced by the smallest letter +// that is equal to it when case is ignored. Two names get the same result when +// strings.EqualFold says they are equal, which covers letters that ToLower keeps +// apart, such as the long s and the Kelvin sign. +func foldName(name string) string { + return strings.Map(func(r rune) rune { + smallest := r + for next := unicode.SimpleFold(r); next != r; next = unicode.SimpleFold(next) { + smallest = min(smallest, next) + } + + return smallest + }, name) +} + +// usable reports why a policy cannot be used to start a proxy, which is the case +// for the zero value and for nil. +func (p *Policy) usable() error { + switch { + case p == nil || len(p.rules) == 0: + return ErrNoScopeTable + case len(p.secrets) == 0: + return ErrNoSecretFields + } + + return nil +} diff --git a/internal/plugins/proxy/policy_test.go b/internal/plugins/proxy/policy_test.go new file mode 100644 index 000000000..acb5ca86d --- /dev/null +++ b/internal/plugins/proxy/policy_test.go @@ -0,0 +1,232 @@ +package proxy + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/plugins/registry" + "github.com/auth0/auth0-cli/internal/plugins/registry/registrytest" +) + +// loadFixture reads the scope table and the secret fields of testdata. +func loadFixture(t *testing.T) (registry.ScopeTable, registry.SecretFields) { + t.Helper() + + content, err := os.ReadFile("testdata/scope-table.json") + require.NoError(t, err) + + var fixture struct { + ScopeTable registry.ScopeTable `json:"scopeTable"` + SecretFields registry.SecretFields `json:"secretFields"` + } + require.NoError(t, json.Unmarshal(content, &fixture)) + + return fixture.ScopeTable, fixture.SecretFields +} + +// testPolicy returns the policy of the fixture, after the index that holds it +// went through the registry parser. +func testPolicy(t *testing.T) *Policy { + t.Helper() + + table, fields := loadFixture(t) + index, err := registry.ParseIndex(registrytest.NewIndexBuilder().SetScopeTable(table).SetSecretFields(fields).Build()) + require.NoError(t, err) + + policy, err := NewPolicy(index) + require.NoError(t, err) + + return policy +} + +// indexWith returns an index with the given rules and secret field names. +func indexWith(rules []registry.ScopeRule, names ...string) *registry.Index { + return ®istry.Index{ + ScopeTable: registry.ScopeTable{FormatVersion: 1, Rules: rules}, + SecretFields: registry.SecretFields{FormatVersion: 1, Names: names}, + } +} + +func TestNewPolicy(t *testing.T) { + oneRule := []registry.ScopeRule{{Method: "GET", Path: "/api/v2/clients", AnyOf: []string{"read:clients"}}} + + tests := []struct { + name string + index *registry.Index + wantErr error + }{ + {name: "a valid index", index: indexWith(oneRule, "client_secret")}, + {name: "no index", index: nil, wantErr: ErrNoScopeTable}, + {name: "an index without a scope table", index: ®istry.Index{}, wantErr: ErrNoScopeTable}, + {name: "a scope table without rules", index: indexWith(nil, "client_secret"), wantErr: ErrNoScopeTable}, + {name: "a scope table of an unknown format", index: ®istry.Index{ + ScopeTable: registry.ScopeTable{FormatVersion: 2, Rules: oneRule}, + SecretFields: registry.SecretFields{FormatVersion: 1, Names: []string{"client_secret"}}, + }, wantErr: ErrNoScopeTable}, + {name: "no secret fields", index: ®istry.Index{ScopeTable: registry.ScopeTable{FormatVersion: 1, Rules: oneRule}}, wantErr: ErrNoSecretFields}, + {name: "secret fields without names", index: indexWith(oneRule), wantErr: ErrNoSecretFields}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policy, err := NewPolicy(test.index) + + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + assert.Nil(t, policy) + + return + } + + require.NoError(t, err) + assert.NoError(t, policy.usable()) + }) + } +} + +func TestNewPolicy_RejectsWhatTheValidatorsReject(t *testing.T) { + tests := []struct { + name string + rules []registry.ScopeRule + names []string + want string + }{ + { + name: "a rule outside the Management API", + rules: []registry.ScopeRule{{Method: "GET", Path: "/oauth/token", AnyOf: []string{"read:a"}}}, + names: []string{"client_secret"}, + want: "scopeTable.rules[0]", + }, + { + name: "a secret field name with a dot", + rules: []registry.ScopeRule{{Method: "GET", Path: "/api/v2/a", AnyOf: []string{"read:a"}}}, + names: []string{"options.client_secret"}, + want: "secretFields.names[0]:", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policy, err := NewPolicy(indexWith(test.rules, test.names...)) + + assert.Nil(t, policy) + require.ErrorIs(t, err, registry.ErrInvalidIndex) + assert.Contains(t, err.Error(), test.want) + }) + } +} + +func TestPolicy_match(t *testing.T) { + rules := []registry.ScopeRule{ + // Written in the order that makes a naive first match wrong. + {Method: "GET", Path: "/api/v2/clients/{id}", AnyOf: []string{"read:client"}}, + {Method: "GET", Path: "/api/v2/clients/count", AnyOf: []string{"read:count"}}, + {Method: "GET", Path: "/api/v2/a/{x}/c", AnyOf: []string{"read:param_first"}}, + {Method: "GET", Path: "/api/v2/a/b/{y}", AnyOf: []string{"read:literal_first"}}, + {Method: "GET", Path: "/api/v2/a/b/c", AnyOf: []string{"read:exact"}}, + {Method: "DELETE", Path: "/api/v2/clients/{id}", AnyOf: []string{"delete:client"}}, + {Method: "GET", Path: "/api/v2/Users", AnyOf: []string{"read:users"}}, + } + + policy, err := NewPolicy(indexWith(rules, "client_secret")) + require.NoError(t, err) + + tests := []struct { + name string + method string + segments []string + want string + }{ + {name: "a parameter matches one segment", method: "GET", segments: []string{"clients", "abc"}, want: "read:client"}, + {name: "a literal beats a parameter", method: "GET", segments: []string{"clients", "count"}, want: "read:count"}, + {name: "the first difference decides, a literal beats a parameter", method: "GET", segments: []string{"a", "b", "x"}, want: "read:literal_first"}, + {name: "an exact rule beats both", method: "GET", segments: []string{"a", "b", "c"}, want: "read:exact"}, + {name: "a parameter in the middle", method: "GET", segments: []string{"a", "z", "c"}, want: "read:param_first"}, + {name: "the method picks the rule", method: "DELETE", segments: []string{"clients", "abc"}, want: "delete:client"}, + {name: "a method the rule does not list", method: "PATCH", segments: []string{"clients", "abc"}}, + {name: "a method with a literal rule only", method: "DELETE", segments: []string{"clients", "count"}, want: "delete:client"}, + {name: "one segment too many", method: "GET", segments: []string{"clients", "abc", "def"}}, + {name: "one segment too few", method: "GET", segments: []string{"clients"}}, + {name: "a literal is case sensitive", method: "GET", segments: []string{"users"}}, + {name: "a literal matches in its own case", method: "GET", segments: []string{"Users"}, want: "read:users"}, + {name: "a parameter does not match an empty segment", method: "GET", segments: []string{"clients", ""}}, + {name: "an unknown resource", method: "GET", segments: []string{"other"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + matched, ok := policy.match(test.method, test.segments) + + assert.Equal(t, test.want != "", ok) + + if test.want != "" { + assert.Equal(t, []string{test.want}, matched.anyOf) + } + }) + } +} + +func TestRule_allowedBy(t *testing.T) { + r := rule{anyOf: []string{"read:clients", "read:client_keys"}} + + tests := []struct { + name string + declared []string + want bool + }{ + {name: "the first alternative", declared: []string{"read:clients"}, want: true}, + {name: "the other alternative", declared: []string{"read:users", "read:client_keys"}, want: true}, + {name: "none of them", declared: []string{"read:users"}}, + {name: "nothing declared"}, + {name: "a scope in another case", declared: []string{"READ:CLIENTS"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, r.allowedBy(scopeSet(test.declared...))) + }) + } +} + +func TestPolicy_isSecret(t *testing.T) { + policy := testPolicy(t) + + assert.True(t, policy.isSecret("client_secret")) + assert.True(t, policy.isSecret("Client_Secret")) + assert.True(t, policy.isSecret("SIGNING_KEYS")) + assert.False(t, policy.isSecret("client_id")) + assert.False(t, policy.isSecret("client_secret_expires_at")) +} + +func TestPolicy_isSecretFollowsEqualFold(t *testing.T) { + policy := testPolicy(t) + + tests := []struct { + name string + key string + }{ + {name: "a long s", key: "client_\u017fecret"}, + {name: "a capital long s", key: "CLIENT_\u017fECRET"}, + {name: "a Kelvin sign", key: "signing_\u212aeys"}, + {name: "a Kelvin sign in the middle of a name", key: "private_\u212aey"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // The test is only about names that EqualFold treats as the same. + require.True(t, strings.EqualFold(test.key, "client_secret") || strings.EqualFold(test.key, "signing_keys") || strings.EqualFold(test.key, "private_key")) + assert.True(t, policy.isSecret(test.key)) + + got, err := stripSecrets([]byte(`{"`+test.key+`":"s","ok":1}`), policy.isSecret) + require.NoError(t, err) + assert.JSONEq(t, `{"ok":1}`, string(got)) + }) + } + + assert.False(t, policy.isSecret("client_\u0131secret"), "a dotless i is not a case variant of anything in the list") +} diff --git a/internal/plugins/proxy/proxy.go b/internal/plugins/proxy/proxy.go new file mode 100644 index 000000000..686ee0159 --- /dev/null +++ b/internal/plugins/proxy/proxy.go @@ -0,0 +1,740 @@ +// Package proxy runs the loopback HTTPS proxy that a plugin process uses to call +// the Auth0 Management API. +// +// The plugin never receives the access token of the user or machine session. It +// gets a per-run secret instead, and the proxy swaps the secret for the real +// token on the calls it forwards. The proxy only forwards the calls that the +// invoked command declared scopes for, removes secret fields from every response, +// and denies everything else, so a plugin cannot reach more than it asked the +// user to approve. +package proxy + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/subtle" + "crypto/tls" + "encoding/hex" + "errors" + "fmt" + "io" + "log" + "mime" + "net" + "net/http" + "os" + "path/filepath" + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/auth0/auth0-cli/internal/plugins" +) + +// The limits on what the proxy accepts, buffers and waits for. +const ( + maxRequestBody = 10 << 20 + maxResponseBody = 32 << 20 + upstreamTimeout = 60 * time.Second + readHeaderTimeout = 10 * time.Second + readTimeout = 60 * time.Second + idleTimeout = 60 * time.Second + maxHeaderBytes = 64 << 10 + shutdownTimeout = 2 * time.Second + + // The in-flight calls are how many calls the proxy works on at once. The + // others wait, so that a plugin cannot use up the memory of the CLI with + // buffered bodies. + maxInFlight = 16 + + // The refreshes are how often the proxy asks the token source for a new + // token in one run. + maxRefreshes = 3 +) + +// requestHeaders are the headers of a request that are forwarded. Every other +// header is dropped, which removes method override headers, cookies, forwarded +// addresses, and the Authorization header of the plugin. +var requestHeaders = []string{"Accept", "Content-Type", "User-Agent", "Auth0-Client"} + +// droppedResponseHeaders are the headers of a response that are never passed on. +// Content-Length and Content-Encoding are replaced because the body is rewritten. +var droppedResponseHeaders = []string{ + "Set-Cookie", "Set-Cookie2", "Content-Length", "Content-Encoding", + "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", + "Proxy-Connection", "Te", "Trailer", "Transfer-Encoding", "Upgrade", +} + +// TokenSource supplies the access token of the session that the proxy calls the +// Management API with. +type TokenSource interface { + // Token returns the current access token. + Token(ctx context.Context) (string, error) + + // Refresh gets a new access token after the current one was rejected. It + // returns ErrReloginRequired when the session cannot be renewed without the + // user. + Refresh(ctx context.Context) (string, error) +} + +// Config is what Start needs to run a proxy. +type Config struct { + // PluginID names the plugin in messages. + PluginID string + + // Domain is the tenant domain. It must be a bare host name. + Domain string + + // Tokens supplies the access token that replaces the secret of the plugin. + Tokens TokenSource + + // Policy says which calls a scope allows and which fields are secret. + Policy *Policy + + // Scopes are the scopes that the invoked command declared. A call is only + // forwarded when one of them allows it, so no scopes means no calls. + Scopes []string + + // Dir is the directory in which the proxy makes its own directory for the + // certificate. An empty Dir means the temporary directory of the system. + Dir string + + // Transport reaches the tenant. It is meant for tests. A nil Transport is a + // copy of the default transport that only speaks HTTP/1.1. + Transport http.RoundTripper +} + +// Proxy is a running proxy. Stop it with Close. +type Proxy struct { + pluginID string + domain string + tokens TokenSource + policy *Policy + scopes map[string]struct{} + transport http.RoundTripper + + // The owned transport is the transport the proxy built itself, which is the + // only one it closes. It is nil when the caller supplied the transport. + owned *http.Transport + + // The slots hold one entry for every call in flight. + slots chan struct{} + + // The timeout is how long one request to the tenant, or one call to the token + // source, may take. It is the upstream timeout, which tests shorten. + timeout time.Duration + + // The secret is what the plugin sends as its bearer token. + secret string + + // The address is that of the listener, which is also the Host that requests + // must carry. + addr string + server *http.Server + dir string + caPath string + + denials denialLog + + // The refresh mutex makes the calls that failed with the same token share one + // refresh. It also guards the count of refreshes. + refreshMu sync.Mutex + refreshes int + + // The stop function ends the watch on the context of Start. + stop func() bool + closeOnce sync.Once + closeErr error +} + +// Start starts a proxy on a loopback port and returns it. The proxy stops when +// ctx ends or when Close is called. It returns ErrNoScopeTable or +// ErrNoSecretFields when the policy cannot keep a plugin within its scopes, and +// ErrInvalidDomain when the domain is not a host name. +func Start(ctx context.Context, cfg Config) (*Proxy, error) { + domain, err := checkDomain(cfg.Domain) + if err != nil { + return nil, err + } + + if err := cfg.Policy.usable(); err != nil { + return nil, err + } + + if cfg.Tokens == nil || cfg.PluginID == "" { + return nil, errors.New("the proxy needs a plugin ID and a token source") + } + + secret, err := newSecret() + if err != nil { + return nil, err + } + + cert, certPEM, err := newCertificate(time.Now()) + if err != nil { + return nil, err + } + + dir, err := os.MkdirTemp(cfg.Dir, "auth0-plugin-") + if err != nil { + return nil, fmt.Errorf("failed to create the directory for the proxy certificate: %w", err) + } + + caPath := filepath.Join(dir, "ca.pem") + if err := os.WriteFile(caPath, certPEM, 0o600); err != nil { + _ = os.RemoveAll(dir) + + return nil, fmt.Errorf("failed to write the proxy certificate: %w", err) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + _ = os.RemoveAll(dir) + + return nil, fmt.Errorf("failed to listen on the loopback address: %w", err) + } + + p := &Proxy{ + pluginID: cfg.PluginID, + domain: domain, + tokens: cfg.Tokens, + policy: cfg.Policy, + scopes: make(map[string]struct{}, len(cfg.Scopes)), + transport: cfg.Transport, + slots: make(chan struct{}, maxInFlight), + timeout: upstreamTimeout, + secret: secret, + addr: listener.Addr().String(), + dir: dir, + caPath: caPath, + } + + for _, scope := range cfg.Scopes { + p.scopes[scope] = struct{}{} + } + + if p.transport == nil { + p.owned = newTransport() + p.transport = p.owned + } + + p.server = &http.Server{ + Handler: http.HandlerFunc(p.serve), + ReadHeaderTimeout: readHeaderTimeout, + ReadTimeout: readTimeout, + IdleTimeout: idleTimeout, + MaxHeaderBytes: maxHeaderBytes, + ErrorLog: log.New(io.Discard, "", 0), + + // An empty map turns HTTP/2 off, so that one parser sees every request. + TLSNextProto: map[string]func(*http.Server, *tls.Conn, http.Handler){}, + } + + tlsListener := tls.NewListener(listener, &tls.Config{ + Certificates: []tls.Certificate{cert}, + MinVersion: tls.VersionTLS12, + NextProtos: []string{"http/1.1"}, + }) + + go func() { _ = p.server.Serve(tlsListener) }() + + p.stop = context.AfterFunc(ctx, func() { _ = p.shutdown() }) + + return p, nil +} + +// Env returns the environment variables that point a plugin at the proxy: the +// address and the secret to call it with, and the certificate to trust, which +// is passed in the variables that Go, Node and Bun read their extra roots from. +func (p *Proxy) Env() []string { + return []string{ + "AUTH0_PLUGIN_DOMAIN=" + p.addr, + "AUTH0_PLUGIN_MANAGEMENT_TOKEN=" + p.secret, + "SSL_CERT_FILE=" + p.caPath, + "NODE_EXTRA_CA_CERTS=" + p.caPath, + "AUTH0_PLUGIN_CA_CERT=" + p.caPath, + } +} + +// Summary returns one line that says which calls were denied, or an empty string +// when none was. It counts the calls the proxy denied and, apart from them, the +// calls that Auth0 refused because the session lacks a scope. +func (p *Proxy) Summary() string { + return p.denials.summary(p.pluginID) +} + +// Close stops the proxy, closes the idle connections of the transport it built +// and removes its certificate. It is safe to call twice. +func (p *Proxy) Close() error { + p.stop() + + return p.shutdown() +} + +// shutdown does the work of Close once, whether the context ended or Close was +// called. +func (p *Proxy) shutdown() error { + p.closeOnce.Do(func() { + ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + if err := p.server.Shutdown(ctx); err != nil { + _ = p.server.Close() + } + + // A transport that the caller supplied is the caller's to close. + if p.owned != nil { + p.owned.CloseIdleConnections() + } + + if err := os.RemoveAll(p.dir); err != nil { + p.closeErr = fmt.Errorf("failed to remove the proxy certificate: %w", err) + } + }) + + return p.closeErr +} + +// serve answers one request, going through the checks in order. +func (p *Proxy) serve(w http.ResponseWriter, r *http.Request) { + // A request with the wrong secret is not the plugin, so it is not counted as + // something the plugin was denied. + if rej := checkBearer(r, p.secret); rej != nil { + rej.write(w) + + return + } + + rej := checkRequestLine(r, p.addr) + + var t target + if rej == nil { + t, rej = parseTarget(r.RequestURI) + } + + if rej == nil { + rej = checkScope(p.policy, p.scopes, p.pluginID, r.Method, t) + } + + if rej != nil { + path, _, _ := strings.Cut(r.RequestURI, "?") + p.denials.deny(rej.scope, r.Method, path) + rej.write(w) + + return + } + + p.forward(w, r, t) +} + +// checkBearer checks that the request carries the secret of the run as its +// bearer token. The secret is only read from the header, never from the query. +func checkBearer(r *http.Request, secret string) *rejection { + var presented string + + ok := false + if values := r.Header.Values("Authorization"); len(values) == 1 { + presented, ok = strings.CutPrefix(values[0], "Bearer ") + } + + if ok && subtle.ConstantTimeCompare([]byte(presented), []byte(secret)) == 1 { + return nil + } + + rej := reject(http.StatusUnauthorized, codeInvalidProxyToken, "The proxy token is missing or wrong.") + rej.header = http.Header{} + rej.header.Set("WWW-Authenticate", "Bearer") + + return rej +} + +// checkRequestLine checks the parts of a request that are about where it goes +// and how: it must be an ordinary request to the listener, with one of the +// methods the proxy forwards. +func checkRequestLine(r *http.Request, addr string) *rejection { + switch { + case r.Method == http.MethodConnect: + return reject(http.StatusBadRequest, codeBadRequest, "The proxy does not tunnel connections.") + case !strings.HasPrefix(r.RequestURI, "/"): + return reject(http.StatusBadRequest, codeBadRequest, + "The request target "+quoted(r.RequestURI)+" must be a path, not a URL.") + case r.Host != addr: + return reject(http.StatusBadRequest, codeBadRequest, + "The Host header "+quoted(r.Host)+" must be the address of the proxy, "+addr+".") + case !slices.Contains(allowedMethods, r.Method): + rej := reject(http.StatusMethodNotAllowed, codeMethodNotAllowed, + "The method "+quoted(r.Method)+" is not allowed. Use "+strings.Join(allowedMethods, ", ")+".") + rej.header = http.Header{} + rej.header.Set("Allow", strings.Join(allowedMethods, ", ")) + + return rej + } + + return nil +} + +// checkScope checks that the call is in the scope table, that no other rule +// could be meant by it, and that the command declared a scope that allows it. +func checkScope(policy *Policy, declared map[string]struct{}, pluginID, method string, t target) *rejection { + matched, ok := policy.match(method, t.segments) + if !ok { + return reject(http.StatusForbidden, codeNoScopeRule, fmt.Sprintf( + "Plugin %s cannot call %s %s because this call is not in the registry's scope table.", + safeText(pluginID), method, safeText(t.path))) + } + + if policy.ambiguous(method, t.segments, matched) { + return reject(http.StatusBadRequest, codeBadRequest, fmt.Sprintf( + "Plugin %s cannot call %s %s because the path is ambiguous: it also matches another endpoint when letter case is ignored.", + safeText(pluginID), method, safeText(t.path))) + } + + if matched.allowedBy(declared) { + return nil + } + + needed := safeText(matched.anyOf[0]) + + var others string + + if len(matched.anyOf) > 1 { + alternatives := make([]string, 0, len(matched.anyOf)-1) + for _, scope := range matched.anyOf[1:] { + alternatives = append(alternatives, safeText(scope)) + } + + others = " (or: " + strings.Join(alternatives, ", ") + ")" + } + + rej := reject(http.StatusForbidden, codeInsufficientScope, fmt.Sprintf( + "Plugin %s did not declare scope %q%s required for %s %s. Add it to permissions.scopes in plugin.json.", + safeText(pluginID), needed, others, method, safeText(t.path))) + rej.scope = needed + + return rej +} + +// outgoing is a request to the tenant, ready to be sent more than once. +type outgoing struct { + method string + target target + header http.Header + body []byte +} + +// upstreamResponse is a response of the tenant, read in full. +type upstreamResponse struct { + status int + header http.Header + body []byte +} + +// forward sends the checked request to the tenant and answers the plugin with +// the response, after the secrets were removed from it. +func (p *Proxy) forward(w http.ResponseWriter, r *http.Request, t target) { + // A slot is taken before the body is read, so that the calls that wait do not + // hold a buffered body. A call whose client went away stops waiting. + select { + case p.slots <- struct{}{}: + defer func() { <-p.slots }() + case <-r.Context().Done(): + return + } + + body, rej := readRequestBody(w, r) + if rej != nil { + rej.write(w) + + return + } + + request := outgoing{method: r.Method, target: t, header: make(http.Header), body: body} + + for _, name := range requestHeaders { + if values := r.Header.Values(name); len(values) > 0 { + request.header[name] = values + } + } + + response, rej := p.call(r.Context(), request) + if rej != nil { + rej.write(w) + + return + } + + if response.status == http.StatusForbidden { + p.denials.refuse() + } + + cleaned, rej := p.inspect(response) + if rej != nil { + rej.write(w) + + return + } + + writeResponse(w, response, cleaned) +} + +// readRequestBody reads the body of the request, up to the limit. +func readRequestBody(w http.ResponseWriter, r *http.Request) ([]byte, *rejection) { + if r.ContentLength > maxRequestBody { + return nil, requestTooLarge() + } + + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxRequestBody)) + if err != nil { + var limit *http.MaxBytesError + if errors.As(err, &limit) { + return nil, requestTooLarge() + } + + return nil, reject(http.StatusBadRequest, codeBadRequest, "The request body could not be read.") + } + + return body, nil +} + +// requestTooLarge is what the plugin gets when its request body is over the limit. +func requestTooLarge() *rejection { + return reject(http.StatusRequestEntityTooLarge, codePayloadTooLarge, "The request body is too large.") +} + +// sessionExpired is what the plugin gets when the session was rejected and could +// not be renewed. +func sessionExpired() *rejection { + return reject(http.StatusUnauthorized, codeSessionExpired, + "Your Auth0 session has expired or was rejected. Run `auth0 login` and try again.") +} + +// call sends the request with the token of the session. When the tenant answers +// 401 it asks for a new token once and sends the request again once. A second 401 +// is not tried again. +func (p *Proxy) call(ctx context.Context, request outgoing) (upstreamResponse, *rejection) { + token, err := p.token(ctx, p.tokens.Token) + if err != nil || token == "" { + return upstreamResponse{}, sessionExpired() + } + + response, rej := p.send(ctx, request, token) + if rej != nil || response.status != http.StatusUnauthorized { + return response, rej + } + + renewed, err := p.refresh(ctx, token) + if err != nil || renewed == "" || renewed == token { + return upstreamResponse{}, sessionExpired() + } + + response, rej = p.send(ctx, request, renewed) + if rej == nil && response.status == http.StatusUnauthorized { + return upstreamResponse{}, sessionExpired() + } + + return response, rej +} + +// refresh gets a new token after failed was rejected. Calls that were rejected +// with the same token queue up here, and only the first one asks the source: +// the others find the token already replaced. The source is asked at most +// maxRefreshes times in a run, so that a tenant that keeps answering 401 cannot +// make the proxy hammer it. +func (p *Proxy) refresh(ctx context.Context, failed string) (string, error) { + p.refreshMu.Lock() + defer p.refreshMu.Unlock() + + current, err := p.token(ctx, p.tokens.Token) + if err != nil { + return "", err + } + + if current != failed { + return current, nil + } + + if p.refreshes >= maxRefreshes { + return "", errRefreshLimit + } + + p.refreshes++ + + return p.token(ctx, p.tokens.Refresh) +} + +// token asks the token source with get, giving it the upstream timeout. A source +// that hangs would otherwise leave every other call waiting for the refresh +// mutex, each holding a slot. +func (p *Proxy) token(ctx context.Context, get func(context.Context) (string, error)) (string, error) { + ctx, cancel := context.WithTimeout(ctx, p.timeout) + defer cancel() + + return get(ctx) +} + +// send makes one request to the tenant and reads the whole response. It does not +// follow redirects: the token only goes where the proxy chose to send it. +func (p *Proxy) send(ctx context.Context, request outgoing, token string) (upstreamResponse, *rejection) { + ctx, cancel := context.WithTimeout(ctx, p.timeout) + defer cancel() + + var body io.Reader + if len(request.body) > 0 { + body = bytes.NewReader(request.body) + } + + upstream, err := http.NewRequestWithContext(ctx, request.method, "https://"+p.domain, body) + if err != nil { + return upstreamResponse{}, reject(http.StatusBadGateway, codeUpstreamFailed, "The request to Auth0 could not be made.") + } + + // Forward the decoded path that the scope check saw. The URL type escapes it + // the one way, so nothing is interpreted a second time. + upstream.URL.Path = request.target.path + upstream.URL.RawQuery = request.target.query + upstream.Header = request.header.Clone() + upstream.Header.Set("Authorization", "Bearer "+token) + + resp, err := p.transport.RoundTrip(upstream) + if err != nil { + return upstreamResponse{}, reject(http.StatusBadGateway, codeUpstreamFailed, "Auth0 could not be reached.") + } + + defer resp.Body.Close() + + if resp.StatusCode >= 300 && resp.StatusCode < 400 && resp.StatusCode != http.StatusNotModified { + return upstreamResponse{}, reject(http.StatusBadGateway, codeUpstreamRedirect, "Auth0 answered with a redirect, which the proxy does not follow.") + } + + content, err := plugins.ReadCapped(resp.Body, maxResponseBody) + if errors.Is(err, plugins.ErrTooLarge) { + return upstreamResponse{}, reject(http.StatusBadGateway, codeUnsupportedResponse, "The response of Auth0 is too large for the proxy to check.") + } + + if err != nil { + return upstreamResponse{}, reject(http.StatusBadGateway, codeUpstreamFailed, "The response of Auth0 could not be read.") + } + + return upstreamResponse{status: resp.StatusCode, header: resp.Header, body: content}, nil +} + +// inspect returns the body of the response without its secret fields. A body +// that is not JSON cannot be checked, so it is not passed on. +func (p *Proxy) inspect(response upstreamResponse) ([]byte, *rejection) { + if len(response.body) == 0 { + return nil, nil + } + + if encoding := response.header.Get("Content-Encoding"); encoding != "" && !strings.EqualFold(encoding, "identity") { + return nil, unsupportedResponse() + } + + if !isJSON(response.header.Get("Content-Type")) { + return nil, unsupportedResponse() + } + + cleaned, err := stripSecrets(response.body, p.policy.isSecret) + if err != nil { + return nil, unsupportedResponse() + } + + return cleaned, nil +} + +// unsupportedResponse is what the plugin gets when a response cannot be checked +// for secrets. +func unsupportedResponse() *rejection { + return reject(http.StatusBadGateway, codeUnsupportedResponse, + "The response of Auth0 is not JSON that the proxy can check, so it was not passed on.") +} + +// isJSON reports whether a Content-Type header names JSON. +func isJSON(contentType string) bool { + mediaType, _, err := mime.ParseMediaType(contentType) + + return err == nil && (mediaType == "application/json" || strings.HasSuffix(mediaType, "+json")) +} + +// writeResponse answers the plugin with the status and the headers of the +// response and the cleaned body. +func writeResponse(w http.ResponseWriter, response upstreamResponse, body []byte) { + // The headers that the Connection header names are hop by hop too. + var named []string + + for _, value := range response.header.Values("Connection") { + for name := range strings.SplitSeq(value, ",") { + named = append(named, http.CanonicalHeaderKey(strings.TrimSpace(name))) + } + } + + for name, values := range response.header { + if !slices.Contains(droppedResponseHeaders, name) && !slices.Contains(named, name) { + w.Header()[name] = values + } + } + + if response.status != http.StatusNoContent && response.status != http.StatusNotModified { + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + } + + w.WriteHeader(response.status) + _, _ = w.Write(body) +} + +// checkDomain checks that the tenant domain is a bare host name with a dot in +// it, and returns it in lowercase. A scheme, port, path, user info or an IP +// address are not host names, so none of them is accepted. +func checkDomain(domain string) (string, error) { + lower := strings.ToLower(domain) + labels := strings.Split(lower, ".") + + valid := len(lower) <= 253 && len(labels) >= 2 + for _, label := range labels { + valid = valid && len(label) >= 1 && len(label) <= 63 && + !strings.HasPrefix(label, "-") && !strings.HasSuffix(label, "-") && + strings.Trim(label, "abcdefghijklmnopqrstuvwxyz0123456789-") == "" + } + + // A top level domain is never all digits, which also rules out IP addresses. + valid = valid && strings.Trim(labels[len(labels)-1], "0123456789") != "" + + if !valid { + return "", fmt.Errorf("%w (got %q)", ErrInvalidDomain, domain) + } + + return lower, nil +} + +// newSecret returns the secret the plugin calls the proxy with. +func newSecret() (string, error) { + random := make([]byte, 32) + if _, err := rand.Read(random); err != nil { + return "", fmt.Errorf("failed to generate the proxy secret: %w", err) + } + + return hex.EncodeToString(random), nil +} + +// newTransport returns the transport that reaches the tenant: a copy of the +// default transport that speaks HTTP/1.1 only. Once the default transport has +// been used, its TLS config lists h2 in the protocols it offers, and the clone +// carries that over. The server would then pick h2, which the clone cannot +// speak, so the offer is narrowed to HTTP/1.1. +func newTransport() *http.Transport { + transport := &http.Transport{Proxy: http.ProxyFromEnvironment, TLSHandshakeTimeout: 10 * time.Second} + if base, ok := http.DefaultTransport.(*http.Transport); ok { + transport = base.Clone() + } + + transport.ForceAttemptHTTP2 = false + transport.TLSNextProto = map[string]func(string, *tls.Conn) http.RoundTripper{} + + if transport.TLSClientConfig != nil { + transport.TLSClientConfig.NextProtos = []string{"http/1.1"} + } + + return transport +} diff --git a/internal/plugins/proxy/proxy_test.go b/internal/plugins/proxy/proxy_test.go new file mode 100644 index 000000000..d9b7b5a6b --- /dev/null +++ b/internal/plugins/proxy/proxy_test.go @@ -0,0 +1,1650 @@ +package proxy + +import ( + "bufio" + "bytes" + "compress/gzip" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "testing" + "testing/iotest" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/auth0/auth0-cli/internal/plugins/registry" +) + +const testDomain = "example.com" + +// seenRequest is a request that reached the fake Management API. +type seenRequest struct { + method string + requestURI string + host string + header http.Header + body []byte +} + +// upstream is a fake Management API that records what reaches it. +type upstream struct { + server *httptest.Server + + // The closed count is how many connections to the fake were closed. + closed atomic.Int32 + + mu sync.Mutex + seen []seenRequest +} + +// newUpstream starts a fake Management API that answers with handler. +func newUpstream(t *testing.T, handler http.HandlerFunc) *upstream { + t.Helper() + + u := &upstream{} + + u.server = httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + u.mu.Lock() + u.seen = append(u.seen, seenRequest{method: r.Method, requestURI: r.RequestURI, host: r.Host, header: r.Header.Clone(), body: body}) + u.mu.Unlock() + + r.Body = io.NopCloser(bytes.NewReader(body)) + handler(w, r) + })) + u.server.Config.ConnState = func(_ net.Conn, state http.ConnState) { + if state == http.StateClosed { + u.closed.Add(1) + } + } + u.server.StartTLS() + t.Cleanup(u.server.Close) + + return u +} + +// transport returns a transport that sends every request to the fake, which it +// trusts, whatever host the request is for. +func (u *upstream) transport() http.RoundTripper { + pool := x509.NewCertPool() + pool.AddCert(u.server.Certificate()) + + return &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool}, + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, u.server.Listener.Addr().String()) + }, + } +} + +// requests returns what reached the fake so far. +func (u *upstream) requests() []seenRequest { + u.mu.Lock() + defer u.mu.Unlock() + + return append([]seenRequest(nil), u.seen...) +} + +// fakeTokens is a token source that counts how often it was asked to refresh. +type fakeTokens struct { + mu sync.Mutex + current string + refreshTo string + tokenErr error + refreshErr error + refreshes int +} + +func (f *fakeTokens) Token(context.Context) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + + return f.current, f.tokenErr +} + +func (f *fakeTokens) Refresh(context.Context) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.refreshes++ + if f.refreshErr != nil { + return "", f.refreshErr + } + + f.current = f.refreshTo + + return f.current, nil +} + +func (f *fakeTokens) refreshCount() int { + f.mu.Lock() + defer f.mu.Unlock() + + return f.refreshes +} + +// scopeSet returns the scopes as the set that a command declares. +func scopeSet(scopes ...string) map[string]struct{} { + set := make(map[string]struct{}, len(scopes)) + for _, scope := range scopes { + set[scope] = struct{}{} + } + + return set +} + +// okJSON answers every request with an empty JSON object. +func okJSON(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{}`) +} + +// testEnv is a running proxy, the fake it forwards to, and a client that trusts it. +type testEnv struct { + t *testing.T + proxy *Proxy + upstream *upstream + tokens *fakeTokens + pool *x509.CertPool + client *http.Client +} + +// startProxy starts a proxy in front of a fake Management API that answers with +// handler, for a command that declared scopes. +func startProxy(t *testing.T, scopes []string, handler http.HandlerFunc, change ...func(*Config)) *testEnv { + t.Helper() + + u := newUpstream(t, handler) + tokens := &fakeTokens{current: "real-token", refreshTo: "new-token"} + + cfg := Config{ + PluginID: "auth0/checkmate", + Domain: testDomain, + Tokens: tokens, + Policy: testPolicy(t), + Scopes: scopes, + Dir: t.TempDir(), + Transport: u.transport(), + } + for _, apply := range change { + apply(&cfg) + } + + p, err := Start(t.Context(), cfg) + require.NoError(t, err) + t.Cleanup(func() { _ = p.Close() }) + + // The client only trusts what the plugin is told to trust. + pem, err := os.ReadFile(envValue(t, p.Env(), "AUTH0_PLUGIN_CA_CERT")) + require.NoError(t, err) + + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(pem)) + + return &testEnv{ + t: t, proxy: p, upstream: u, tokens: tokens, pool: pool, + client: &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}, Timeout: 20 * time.Second}, + } +} + +// envValue returns the value of a variable of an environment. +func envValue(t *testing.T, env []string, name string) string { + t.Helper() + + for _, entry := range env { + if value, ok := strings.CutPrefix(entry, name+"="); ok { + return value + } + } + + require.Failf(t, "variable not found", "%s", name) + + return "" +} + +// do makes a call to the proxy as the plugin would. +func (e *testEnv) do(method, target string, body io.Reader, header ...string) (*http.Response, []byte) { + e.t.Helper() + + req, err := http.NewRequest(method, "https://"+e.proxy.addr+target, body) + require.NoError(e.t, err) + + req.Header.Set("Authorization", "Bearer "+e.proxy.secret) + + for n := 0; n+1 < len(header); n += 2 { + req.Header.Add(header[n], header[n+1]) + } + + resp, err := e.client.Do(req) + require.NoError(e.t, err) + + defer resp.Body.Close() + + content, err := io.ReadAll(resp.Body) + require.NoError(e.t, err) + + return resp, content +} + +// raw sends the lines as a request, exactly as they are, and reads the answer. +func (e *testEnv) raw(lines ...string) (*http.Response, []byte) { + e.t.Helper() + + conn, err := tls.Dial("tcp", e.proxy.addr, &tls.Config{RootCAs: e.pool}) + require.NoError(e.t, err) + + defer conn.Close() + + require.NoError(e.t, conn.SetDeadline(time.Now().Add(10*time.Second))) + + _, err = io.WriteString(conn, strings.Join(lines, "\r\n")+"\r\nConnection: close\r\n\r\n") + require.NoError(e.t, err) + + method, _, _ := strings.Cut(lines[0], " ") + + resp, err := http.ReadResponse(bufio.NewReader(conn), &http.Request{Method: method}) + require.NoError(e.t, err) + + defer resp.Body.Close() + + content, err := io.ReadAll(resp.Body) + require.NoError(e.t, err) + + return resp, content +} + +// get makes a call to the proxy without its secret, which is enough to find out +// whether the connection works. +func get(t *testing.T, client *http.Client, addr, target string) (*http.Response, error) { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "https://"+addr+target, nil) + require.NoError(t, err) + + return client.Do(req) +} + +// errorBody decodes the error body of the Management API. +func errorBody(t *testing.T, content []byte) map[string]any { + t.Helper() + + var body map[string]any + require.NoError(t, json.Unmarshal(content, &body), string(content)) + + return body +} + +func TestCheckBearer(t *testing.T) { + const secret = "0123456789abcdef" + + header := func(values ...string) *http.Request { + r := httptest.NewRequest(http.MethodGet, "/api/v2/clients", nil) + r.Header["Authorization"] = values + + return r + } + + tests := []struct { + name string + r *http.Request + want bool + }{ + {name: "the secret", r: header("Bearer " + secret), want: true}, + {name: "no header", r: header()}, + {name: "a wrong secret", r: header("Bearer 0123456789abcdee")}, + {name: "a prefix of the secret", r: header("Bearer 0123456789abcde")}, + {name: "the secret and more", r: header("Bearer " + secret + "0")}, + {name: "no secret", r: header("Bearer ")}, + {name: "another scheme", r: header("Basic " + secret)}, + {name: "a lowercase scheme", r: header("bearer " + secret)}, + {name: "no scheme", r: header(secret)}, + {name: "the secret twice", r: header("Bearer "+secret, "Bearer "+secret)}, + {name: "a wrong secret first", r: header("Bearer wrong", "Bearer "+secret)}, + {name: "the secret in the query", r: httptest.NewRequest(http.MethodGet, "/api/v2/clients?access_token="+secret, nil)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + rej := checkBearer(test.r, secret) + + if test.want { + assert.Nil(t, rej) + return + } + + require.NotNil(t, rej) + assert.Equal(t, http.StatusUnauthorized, rej.status) + assert.Equal(t, codeInvalidProxyToken, rej.code) + assert.NotContains(t, rej.message, secret) + }) + } +} + +func TestCheckRequestLine(t *testing.T) { + const addr = "127.0.0.1:4000" + + tests := []struct { + name string + method string + requestURI string + host string + wantStatus int + }{ + {name: "an ordinary call", method: "GET", requestURI: "/api/v2/clients", host: addr}, + {name: "every forwarded method", method: "PATCH", requestURI: "/api/v2/clients/a", host: addr}, + {name: "CONNECT", method: "CONNECT", requestURI: "example.com:443", host: addr, wantStatus: 400}, + {name: "an absolute URL to the tenant", method: "GET", requestURI: "https://example.com/api/v2/clients", host: addr, wantStatus: 400}, + {name: "an absolute URL to another host", method: "GET", requestURI: "http://evil.test/api/v2/clients", host: addr, wantStatus: 400}, + {name: "the tenant as Host", method: "GET", requestURI: "/api/v2/clients", host: "example.com", wantStatus: 400}, + {name: "another host as Host", method: "GET", requestURI: "/api/v2/clients", host: "evil.test", wantStatus: 400}, + {name: "localhost as Host", method: "GET", requestURI: "/api/v2/clients", host: "localhost:4000", wantStatus: 400}, + {name: "another port as Host", method: "GET", requestURI: "/api/v2/clients", host: "127.0.0.1:4001", wantStatus: 400}, + {name: "no Host", method: "GET", requestURI: "/api/v2/clients", wantStatus: 400}, + {name: "TRACE", method: "TRACE", requestURI: "/api/v2/clients", host: addr, wantStatus: 405}, + {name: "OPTIONS", method: "OPTIONS", requestURI: "/api/v2/clients", host: addr, wantStatus: 405}, + {name: "HEAD", method: "HEAD", requestURI: "/api/v2/clients", host: addr, wantStatus: 405}, + {name: "an unknown method", method: "PROPFIND", requestURI: "/api/v2/clients", host: addr, wantStatus: 405}, + {name: "a method in lowercase", method: "get", requestURI: "/api/v2/clients", host: addr, wantStatus: 405}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := &http.Request{Method: test.method, RequestURI: test.requestURI, Host: test.host} + + rej := checkRequestLine(r, addr) + + if test.wantStatus == 0 { + assert.Nil(t, rej) + return + } + + require.NotNil(t, rej) + assert.Equal(t, test.wantStatus, rej.status) + + if test.wantStatus == http.StatusMethodNotAllowed { + assert.Equal(t, "GET, POST, PUT, PATCH, DELETE", rej.header.Get("Allow")) + } + }) + } +} + +func TestCheckScope(t *testing.T) { + policy := testPolicy(t) + + tests := []struct { + name string + declared map[string]struct{} + method string + target string + wantStatus int + wantCode errorCode + wantMessage string + }{ + {name: "a declared scope", declared: scopeSet("read:users"), method: "GET", target: "/api/v2/users"}, + {name: "a declared scope and others", declared: scopeSet("read:clients", "read:users"), method: "GET", target: "/api/v2/users/abc"}, + {name: "the other alternative", declared: scopeSet("read:client_keys"), method: "GET", target: "/api/v2/clients/abc"}, + {name: "only the summary scope", declared: scopeSet("read:client_summary"), method: "GET", target: "/api/v2/clients/abc"}, + {name: "a parameter in camel case", declared: scopeSet("read:actions"), method: "GET", target: "/api/v2/actions/actions/act_123/versions/v1"}, + {name: "an identifier in mixed case", declared: scopeSet("read:clients"), method: "GET", target: "/api/v2/clients/AbC123xYz"}, + {name: "a user identifier in mixed case", declared: scopeSet("read:users"), method: "GET", target: "/api/v2/users/auth0%7CABC123"}, + { + name: "an undeclared scope", declared: scopeSet("read:clients"), method: "GET", target: "/api/v2/users", + wantStatus: 403, wantCode: codeInsufficientScope, + wantMessage: `Plugin auth0/checkmate did not declare scope "read:users" required for GET /api/v2/users. Add it to permissions.scopes in plugin.json.`, + }, + { + name: "an undeclared scope with alternatives", declared: scopeSet("read:users"), method: "GET", target: "/api/v2/clients/abc", + wantStatus: 403, wantCode: codeInsufficientScope, + wantMessage: `Plugin auth0/checkmate did not declare scope "read:clients" (or: read:client_keys, read:client_credentials, read:client_summary) required for GET /api/v2/clients/abc. Add it to permissions.scopes in plugin.json.`, + }, + {name: "no scopes at all", declared: scopeSet(), method: "GET", target: "/api/v2/users", wantStatus: 403, wantCode: codeInsufficientScope}, + {name: "a nil set of scopes", method: "GET", target: "/api/v2/users", wantStatus: 403, wantCode: codeInsufficientScope}, + {name: "an endpoint that is not in the table", declared: scopeSet("read:users", "read:clients"), method: "GET", target: "/api/v2/actions", wantStatus: 403, wantCode: codeNoScopeRule}, + {name: "a method the rule does not list", declared: scopeSet("read:users", "delete:users"), method: "DELETE", target: "/api/v2/users/abc", wantStatus: 403, wantCode: codeNoScopeRule}, + {name: "an extra segment", declared: scopeSet("read:users"), method: "GET", target: "/api/v2/users/abc/def/ghi", wantStatus: 403, wantCode: codeNoScopeRule}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parsed, rej := parseTarget(test.target) + require.Nil(t, rej) + + rej = checkScope(policy, test.declared, "auth0/checkmate", test.method, parsed) + + if test.wantStatus == 0 { + assert.Nil(t, rej) + return + } + + require.NotNil(t, rej) + assert.Equal(t, test.wantStatus, rej.status) + assert.Equal(t, test.wantCode, rej.code) + + if test.wantMessage != "" { + assert.Equal(t, test.wantMessage, rej.message) + } + + if test.wantCode == codeInsufficientScope { + assert.NotEmpty(t, rej.scope, "the summary names the missing scope") + } + }) + } +} + +// organizationsPolicy is a policy with two rules that overlap when letter case is +// ignored: the members of an organization by identifier, and an organization by +// name. +func organizationsPolicy(t *testing.T) *Policy { + t.Helper() + + policy, err := NewPolicy(indexWith([]registry.ScopeRule{ + {Method: "GET", Path: "/api/v2/organizations/{id}/members", AnyOf: []string{"read:organization_members"}}, + {Method: "GET", Path: "/api/v2/organizations/name/{name}", AnyOf: []string{"read:organizations"}}, + }, "client_secret")) + require.NoError(t, err) + + return policy +} + +func TestCheckScope_RejectsACallThatCaseCouldRouteToAnotherRule(t *testing.T) { + policy := organizationsPolicy(t) + both := scopeSet("read:organization_members", "read:organizations") + + tests := []struct { + name string + declared map[string]struct{} + target string + wantCode errorCode + }{ + {name: "the literal in capitals", declared: both, target: "/api/v2/organizations/NAME/members", wantCode: codeBadRequest}, + {name: "the literal with one capital", declared: both, target: "/api/v2/organizations/Name/members", wantCode: codeBadRequest}, + {name: "no scope declared does not hide it", declared: scopeSet(), target: "/api/v2/organizations/NAME/members", wantCode: codeBadRequest}, + {name: "only the scope of the matching rule", declared: scopeSet("read:organization_members"), target: "/api/v2/organizations/NAME/members", wantCode: codeBadRequest}, + {name: "an identifier that matches only one rule", declared: both, target: "/api/v2/organizations/org_ABC/members"}, + {name: "the literal as the identifier", declared: both, target: "/api/v2/organizations/name/members"}, + {name: "the rule by name", declared: both, target: "/api/v2/organizations/name/Acme"}, + {name: "a call that no rule matches", declared: both, target: "/api/v2/organizations/NAME/other", wantCode: codeNoScopeRule}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parsed, rej := parseTarget(test.target) + require.Nil(t, rej) + + rej = checkScope(policy, test.declared, "auth0/checkmate", "GET", parsed) + + if test.wantCode == "" { + assert.Nil(t, rej) + return + } + + require.NotNil(t, rej) + assert.Equal(t, test.wantCode, rej.code) + + if test.wantCode == codeBadRequest { + assert.Equal(t, http.StatusBadRequest, rej.status) + assert.Contains(t, rej.message, "ambiguous") + } + }) + } +} + +func TestProxy_DeniesAnAmbiguousCall(t *testing.T) { + e := startProxy(t, []string{"read:organization_members", "read:organizations"}, okJSON, func(c *Config) { c.Policy = organizationsPolicy(t) }) + + resp, content := e.do(http.MethodGet, "/api/v2/organizations/NAME/members", nil) + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "bad_request", errorBody(t, content)["errorCode"]) + assert.Contains(t, errorBody(t, content)["message"], "ambiguous") + assert.Empty(t, e.upstream.requests()) + assert.Equal(t, "Plugin auth0/checkmate: 1 API call was denied (GET /api/v2/organizations/NAME/members). Review permissions.scopes in plugin.json.", + e.proxy.Summary()) + + resp, _ = e.do(http.MethodGet, "/api/v2/organizations/org_ABC/members", nil) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Len(t, e.upstream.requests(), 1) +} + +func TestReadRequestBody(t *testing.T) { + tests := []struct { + name string + body io.Reader + length int64 + wantStatus int + }{ + {name: "no body", body: http.NoBody}, + {name: "a body of the limit", body: strings.NewReader(strings.Repeat("a", maxRequestBody)), length: maxRequestBody}, + {name: "a length above the limit", body: strings.NewReader("a"), length: maxRequestBody + 1, wantStatus: 413}, + {name: "a body above the limit with no length", body: strings.NewReader(strings.Repeat("a", maxRequestBody+1)), length: -1, wantStatus: 413}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodPost, "/api/v2/clients", test.body) + r.ContentLength = test.length + + body, rej := readRequestBody(httptest.NewRecorder(), r) + + if test.wantStatus != 0 { + require.NotNil(t, rej) + assert.Equal(t, test.wantStatus, rej.status) + + return + } + + require.Nil(t, rej) + assert.Len(t, body, int(test.length)) + }) + } +} + +func TestCheckDomain(t *testing.T) { + tests := []struct { + domain string + want string + }{ + {domain: "example.us.auth0.com", want: "example.us.auth0.com"}, + {domain: "Example.EU.auth0.com", want: "example.eu.auth0.com"}, + {domain: "tenant.example.com", want: "tenant.example.com"}, + {domain: "a-b.example.com", want: "a-b.example.com"}, + {domain: ""}, + {domain: "localhost"}, + {domain: "https://example.com"}, + {domain: "example.com:443"}, + {domain: "example.com/api"}, + {domain: "user@example.com"}, + {domain: "user:pass@example.com"}, + {domain: "example.com?x=1"}, + {domain: "example.com#x"}, + {domain: "example.com."}, + {domain: ".example.com"}, + {domain: "a..example.com"}, + {domain: "-a.example.com"}, + {domain: "a-.example.com"}, + {domain: "exa mple.com"}, + {domain: "exa_mple.com"}, + {domain: "example.com\n"}, + {domain: "exämple.com"}, + {domain: "127.0.0.1"}, + {domain: "10.0.0.1"}, + {domain: "[::1]"}, + {domain: strings.Repeat("a", 64) + ".com"}, + {domain: strings.Repeat("a.", 130) + "com"}, + } + + for _, test := range tests { + t.Run(test.domain, func(t *testing.T) { + got, err := checkDomain(test.domain) + + if test.want == "" { + require.ErrorIs(t, err, ErrInvalidDomain) + return + } + + require.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } +} + +func TestStart_RejectsAnUnsafeConfig(t *testing.T) { + good := func() Config { + return Config{PluginID: "auth0/checkmate", Domain: testDomain, Tokens: &fakeTokens{}, Policy: testPolicy(t), Dir: t.TempDir()} + } + + tests := []struct { + name string + change func(c *Config) + wantErr error + }{ + {name: "a bad domain", change: func(c *Config) { c.Domain = "https://example.com" }, wantErr: ErrInvalidDomain}, + {name: "no domain", change: func(c *Config) { c.Domain = "" }, wantErr: ErrInvalidDomain}, + {name: "no policy", change: func(c *Config) { c.Policy = nil }, wantErr: ErrNoScopeTable}, + {name: "an empty policy", change: func(c *Config) { c.Policy = &Policy{} }, wantErr: ErrNoScopeTable}, + {name: "a policy without secret fields", change: func(c *Config) { c.Policy = &Policy{rules: testPolicy(t).rules} }, wantErr: ErrNoSecretFields}, + {name: "no token source", change: func(c *Config) { c.Tokens = nil }}, + {name: "no plugin ID", change: func(c *Config) { c.PluginID = "" }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + cfg := good() + test.change(&cfg) + + p, err := Start(t.Context(), cfg) + + assert.Nil(t, p) + require.Error(t, err) + + if test.wantErr != nil { + assert.ErrorIs(t, err, test.wantErr) + } + + entries, readErr := os.ReadDir(cfg.Dir) + require.NoError(t, readErr) + assert.Empty(t, entries, "nothing is left behind") + }) + } +} + +func TestProxy_RejectsEveryBypass(t *testing.T) { + e := startProxy(t, []string{"read:clients", "read:users", "delete:clients", "update:clients", "create:clients"}, okJSON) + + tests := []struct { + name string + request string + host string + wantStatus int + wantCode string + }{ + {name: "dot dot", request: "GET /api/v2/../oauth/token HTTP/1.1", wantStatus: 400}, + {name: "escaped dot dot", request: "GET /api/v2/%2e%2e/users HTTP/1.1", wantStatus: 400}, + {name: "a double slash", request: "GET /api/v2//clients HTTP/1.1", wantStatus: 400}, + {name: "path parameters", request: "GET /api/v2/clients;x=1 HTTP/1.1", wantStatus: 400}, + {name: "the prefix in capitals", request: "GET /API/V2/clients HTTP/1.1", wantStatus: 403, wantCode: "forbidden_path"}, + {name: "the token endpoint", request: "POST /oauth/token HTTP/1.1", wantStatus: 403, wantCode: "forbidden_path"}, + {name: "a method the rule does not list", request: "PUT /api/v2/clients/a HTTP/1.1", wantStatus: 403, wantCode: "no_scope_rule"}, + {name: "an absolute URL to another host", request: "GET http://evil.test/api/v2/clients HTTP/1.1", wantStatus: 400}, + {name: "CONNECT", request: "CONNECT example.com:443 HTTP/1.1", wantStatus: 400}, + {name: "the tenant as Host", request: "GET /api/v2/clients HTTP/1.1", host: testDomain, wantStatus: 400}, + {name: "TRACE", request: "TRACE /api/v2/clients HTTP/1.1", wantStatus: 405}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + host := test.host + if host == "" { + host = e.proxy.addr + } + + resp, content := e.raw(test.request, "Host: "+host, "Authorization: Bearer "+e.proxy.secret) + + assert.Equal(t, test.wantStatus, resp.StatusCode, string(content)) + assert.Empty(t, e.upstream.requests(), "nothing reaches the Management API") + + if test.wantStatus == http.StatusMethodNotAllowed { + assert.NotEmpty(t, resp.Header.Get("Allow")) + } + + if test.wantCode != "" { + assert.Equal(t, test.wantCode, errorBody(t, content)["errorCode"]) + } + }) + } +} + +func TestProxy_RejectsAWrongProxyToken(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, okJSON) + secret := e.proxy.secret + + tests := []struct { + name string + lines []string + }{ + {name: "no token", lines: []string{"GET /api/v2/clients HTTP/1.1", "Host: " + e.proxy.addr}}, + {name: "a wrong token", lines: []string{"GET /api/v2/clients HTTP/1.1", "Host: " + e.proxy.addr, "Authorization: Bearer " + strings.Repeat("0", 64)}}, + {name: "the token in the query", lines: []string{"GET /api/v2/clients?access_token=" + secret + " HTTP/1.1", "Host: " + e.proxy.addr}}, + {name: "the token in another header", lines: []string{"GET /api/v2/clients HTTP/1.1", "Host: " + e.proxy.addr, "X-Token: " + secret}}, + {name: "a wrong token before a bad request", lines: []string{"GET /api/v2/../x HTTP/1.1", "Host: evil.test", "Authorization: Bearer wrong"}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resp, content := e.raw(test.lines...) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, "invalid_proxy_token", errorBody(t, content)["errorCode"]) + assert.NotContains(t, string(content), secret) + }) + } + + assert.Empty(t, e.upstream.requests()) + assert.Empty(t, e.proxy.Summary(), "a request without the token is not the plugin, so it is not a denial") +} + +func TestProxy_Forwards(t *testing.T) { + e := startProxy(t, []string{"read:clients", "update:clients"}, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + }) + + resp, content := e.do(http.MethodPatch, "/api/v2/clients/auth0%7C123?fields=a%2Cb&include_fields=true", strings.NewReader(`{"name":"x"}`), + "Content-Type", "application/json", + "Accept", "application/json", + "User-Agent", "checkmate/1.0", + "Auth0-Client", "eyJuYW1lIjoiY2hlY2ttYXRlIn0", + "Cookie", "session=abc", + "X-Forwarded-For", "10.0.0.1", + "X-HTTP-Method-Override", "DELETE", + "X-Method-Override", "DELETE", + "Accept-Encoding", "br", + "Proxy-Authorization", "Basic abc", + "X-Custom", "yes", + ) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.JSONEq(t, `{"ok":true}`, string(content)) + + seen := e.upstream.requests() + require.Len(t, seen, 1) + got := seen[0] + + assert.Equal(t, "PATCH", got.method) + assert.Equal(t, "/api/v2/clients/auth0%7C123?fields=a%2Cb&include_fields=true", got.requestURI) + assert.Equal(t, testDomain, got.host) + assert.JSONEq(t, `{"name":"x"}`, string(got.body)) + + assert.Equal(t, "Bearer real-token", got.header.Get("Authorization"), "the real token replaces the secret") + assert.NotContains(t, strings.Join(got.header.Values("Authorization"), " "), e.proxy.secret) + assert.Equal(t, "application/json", got.header.Get("Content-Type")) + assert.Equal(t, "application/json", got.header.Get("Accept")) + assert.Equal(t, "checkmate/1.0", got.header.Get("User-Agent")) + assert.Equal(t, "eyJuYW1lIjoiY2hlY2ttYXRlIn0", got.header.Get("Auth0-Client")) + + for _, dropped := range []string{"Cookie", "X-Forwarded-For", "X-Http-Method-Override", "X-Method-Override", "Proxy-Authorization", "X-Custom"} { + assert.Empty(t, got.header.Values(dropped), dropped) + } + + assert.NotEqual(t, "br", got.header.Get("Accept-Encoding"), "the transport asks for what it can decode") + assert.Empty(t, e.proxy.Summary()) +} + +func TestProxy_EnforcesScopes(t *testing.T) { + tests := []struct { + name string + scopes []string + method string + target string + wantStatus int + wantCode string + }{ + {name: "a declared scope", scopes: []string{"read:users"}, method: "GET", target: "/api/v2/users", wantStatus: 200}, + {name: "a read scope does not allow a delete", scopes: []string{"read:clients"}, method: "DELETE", target: "/api/v2/clients/abc", wantStatus: 403, wantCode: "insufficient_scope"}, + {name: "an endpoint that is not in the table", scopes: []string{"read:users"}, method: "GET", target: "/api/v2/actions", wantStatus: 403, wantCode: "no_scope_rule"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := startProxy(t, test.scopes, okJSON) + + resp, content := e.do(test.method, test.target, nil) + + assert.Equal(t, test.wantStatus, resp.StatusCode, string(content)) + + if test.wantStatus == http.StatusOK { + assert.Len(t, e.upstream.requests(), 1) + return + } + + assert.Empty(t, e.upstream.requests()) + + body := errorBody(t, content) + assert.Equal(t, test.wantCode, body["errorCode"]) + assert.Equal(t, "Forbidden", body["error"]) + assert.EqualValues(t, 403, body["statusCode"]) + assert.Contains(t, body["message"], "auth0/checkmate") + }) + } +} + +func TestProxy_Summary(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = io.WriteString(w, `{"statusCode":403,"error":"Forbidden","message":"Insufficient scope"}`) + }) + + assert.Empty(t, e.proxy.Summary()) + + e.do(http.MethodGet, "/api/v2/users", nil) + e.do(http.MethodGet, "/api/v2/users", nil) + e.do(http.MethodGet, "/oauth/token", nil) + + resp, _ := e.do(http.MethodGet, "/api/v2/clients", nil) + assert.Equal(t, http.StatusForbidden, resp.StatusCode, "Auth0 refused and the proxy passes that on") + + assert.Equal(t, "Plugin auth0/checkmate: 3 API calls were denied (read:users for GET /api/v2/users, GET /oauth/token) and Auth0 refused 1 call because your session lacks a scope. Review permissions.scopes in plugin.json.", + e.proxy.Summary()) +} + +func TestProxy_ResponseHandling(t *testing.T) { + gzipped := func(s string) []byte { + var buf bytes.Buffer + w := gzip.NewWriter(&buf) + _, _ = w.Write([]byte(s)) + _ = w.Close() + + return buf.Bytes() + } + + tests := []struct { + name string + handler http.HandlerFunc + wantStatus int + wantBody string + wantCode string + wantHeaders map[string]string + }{ + { + name: "secrets are removed", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _, _ = io.WriteString(w, `{"client_id":"a","client_secret":"s","jwt_configuration":{"secret_encoded":true},"signing_keys":[{"key":"k"}],"app":{"options":{"Client_Secret":"x","scope":"openid"}}}`) + }, + wantStatus: 200, + wantBody: `{"client_id":"a","jwt_configuration":{"secret_encoded":true},"app":{"options":{"scope":"openid"}}}`, + }, + { + name: "a content type that ends in +json", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/problem+json") + _, _ = io.WriteString(w, `{"private_key":"k","title":"t"}`) + }, + wantStatus: 200, + wantBody: `{"title":"t"}`, + }, + { + name: "an error body is cleaned too", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = io.WriteString(w, `{"statusCode":400,"message":"bad","client_secret":"s"}`) + }, + wantStatus: 400, + wantBody: `{"statusCode":400,"message":"bad"}`, + }, + { + name: "a gzip body is decoded and cleaned", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "gzip") + _, _ = w.Write(gzipped(`{"client_secret":"s","a":1}`)) + }, + wantStatus: 200, + wantBody: `{"a":1}`, + wantHeaders: map[string]string{"Content-Encoding": ""}, + }, + { + name: "cookies and hop by hop headers are dropped, the length is recomputed", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Set-Cookie", "session=abc") + w.Header().Set("Keep-Alive", "timeout=5") + w.Header().Set("Connection", "X-Hop") + w.Header().Set("X-Hop", "1") + w.Header().Set("X-Ratelimit-Remaining", "9") + _, _ = io.WriteString(w, `{"a":1,"client_secret":"a long secret value"}`) + }, + wantStatus: 200, + wantBody: `{"a":1}`, + wantHeaders: map[string]string{ + "Set-Cookie": "", "Keep-Alive": "", "X-Hop": "", "Content-Length": "7", "X-Ratelimit-Remaining": "9", + }, + }, + { + name: "no content", + handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }, + wantStatus: 204, + }, + { + name: "an empty body", + handler: func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/plain") }, + wantStatus: 200, + wantHeaders: map[string]string{"Content-Length": "0"}, + }, + { + name: "a body that is not JSON", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, `client_secret=abc`) + }, + wantStatus: 502, + wantCode: "unsupported_response", + }, + { + name: "a body with no content type", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header()["Content-Type"] = nil + _, _ = io.WriteString(w, `{"client_secret":"s"}`) + }, + wantStatus: 502, + wantCode: "unsupported_response", + }, + { + name: "a binary body that says it is JSON", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte{0x00, 0x01, 0xff}) + }, + wantStatus: 502, + wantCode: "unsupported_response", + }, + { + name: "an encoding that cannot be decoded", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Content-Encoding", "br") + _, _ = io.WriteString(w, `{"client_secret":"s"}`) + }, + wantStatus: 502, + wantCode: "unsupported_response", + }, + { + name: "a body above the limit", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + // Valid JSON in the first bytes, so that only the size can reject it. + _, _ = io.WriteString(w, "{}"+strings.Repeat(" ", maxResponseBody)) + }, + wantStatus: 502, + wantCode: "unsupported_response", + }, + { + name: "a redirect", + handler: func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "https://evil.test/steal", http.StatusFound) + }, + wantStatus: 502, + wantCode: "upstream_redirect", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, test.handler) + + resp, content := e.do(http.MethodGet, "/api/v2/clients", nil) + + assert.Equal(t, test.wantStatus, resp.StatusCode, string(content)) + + switch { + case test.wantCode != "": + assert.Equal(t, test.wantCode, errorBody(t, content)["errorCode"]) + assert.NotContains(t, string(content), "client_secret") + assert.NotContains(t, resp.Header.Get("Location"), "evil") + case test.wantBody != "": + assert.Equal(t, test.wantBody, string(content)) + default: + assert.Empty(t, content) + } + + for name, want := range test.wantHeaders { + if want == "" { + assert.Empty(t, resp.Header.Values(name), name) + } else { + assert.Equal(t, want, resp.Header.Get(name), name) + } + } + + if test.name == "a redirect" { + assert.Len(t, e.upstream.requests(), 1, "the redirect is not followed") + } + }) + } +} + +func TestProxy_RefreshesTheTokenOnce(t *testing.T) { + const callers = 20 + + t.Run("one refresh for many calls that failed with the same token", func(t *testing.T) { + // Only maxInFlight calls are in flight at once, and these are the ones that + // fail with the first token. The others find the new token when they run. + var arrived sync.WaitGroup + + arrived.Add(maxInFlight) + + release := make(chan struct{}) + + // The refresh takes time, so that the calls reach it while the first one is in it. + source := &fakeTokens{current: "real-token", refreshTo: "new-token"} + tokens := &funcTokens{ + token: source.Token, + refresh: func(ctx context.Context) (string, error) { + select { + case <-time.After(300 * time.Millisecond): + case <-ctx.Done(): + return "", ctx.Err() + } + + return source.Refresh(ctx) + }, + } + + e := startProxy(t, []string{"read:clients"}, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") == "Bearer real-token" { + arrived.Done() + <-release + w.WriteHeader(http.StatusUnauthorized) + + return + } + + okJSON(w, r) + }, func(c *Config) { c.Tokens = tokens }) + + statuses := make(chan int, callers) + + var wg sync.WaitGroup + + for range callers { + wg.Go(func() { + resp, _ := e.do(http.MethodGet, "/api/v2/clients", nil) + statuses <- resp.StatusCode + }) + } + + everyoneWaiting := make(chan struct{}) + go func() { arrived.Wait(); close(everyoneWaiting) }() + + select { + case <-everyoneWaiting: + case <-time.After(15 * time.Second): + t.Fatal("the calls did not all reach the Management API") + } + + close(release) + wg.Wait() + close(statuses) + + for status := range statuses { + assert.Equal(t, http.StatusOK, status) + } + + assert.Equal(t, 1, source.refreshCount(), "the calls shared the one refresh") + assert.Len(t, e.upstream.requests(), callers+maxInFlight, "every call that failed was sent again once") + }) + + t.Run("a second 401 is not tried again", func(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, rejectEverything) + + resp, content := e.do(http.MethodGet, "/api/v2/clients", nil) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, "session_expired", errorBody(t, content)["errorCode"]) + assert.Equal(t, 1, e.tokens.refreshCount()) + assert.Len(t, e.upstream.requests(), 2) + }) + + tests := []struct { + name string + change func(f *fakeTokens) + }{ + {name: "a session that cannot be renewed", change: func(f *fakeTokens) { f.refreshErr = ErrReloginRequired }}, + {name: "a refresh that fails", change: func(f *fakeTokens) { f.refreshErr = errors.New("the tenant is down") }}, + {name: "a refresh that gives the same token", change: func(f *fakeTokens) { f.refreshTo = "real-token" }}, + } + + for _, test := range tests { + t.Run(test.name+" gives session_expired without a replay", func(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, rejectEverything) + test.change(e.tokens) + + resp, content := e.do(http.MethodGet, "/api/v2/clients", nil) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + body := errorBody(t, content) + assert.Equal(t, "session_expired", body["errorCode"]) + assert.Contains(t, body["message"], "auth0 login") + assert.NotContains(t, string(content), "real-token") + assert.Len(t, e.upstream.requests(), 1) + }) + } + + t.Run("a token that cannot be read gives session_expired", func(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, okJSON) + e.tokens.tokenErr = errors.New("keyring is locked") + + resp, content := e.do(http.MethodGet, "/api/v2/clients", nil) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, "session_expired", errorBody(t, content)["errorCode"]) + assert.Empty(t, e.upstream.requests()) + }) +} + +func TestProxy_ServesOnlyOverTheTrustedCertificate(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, okJSON) + + t.Run("a client that does not", func(t *testing.T) { + client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: x509.NewCertPool()}}} + + _, err := get(t, client, e.proxy.addr, "/api/v2/clients") + + var unknown x509.UnknownAuthorityError + require.ErrorAs(t, err, &unknown) + }) + + t.Run("a client that asks for HTTP/2 still gets HTTP/1.1", func(t *testing.T) { + client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: e.pool}, ForceAttemptHTTP2: true}} + + resp, err := get(t, client, e.proxy.addr, "/userinfo") + require.NoError(t, err) + + defer resp.Body.Close() + + assert.Equal(t, 1, resp.ProtoMajor) + }) + + t.Run("a client that only speaks TLS 1.1", func(t *testing.T) { + conn, err := tls.Dial("tcp", e.proxy.addr, &tls.Config{RootCAs: e.pool, MaxVersion: tls.VersionTLS11}) + if err == nil { + _ = conn.Close() + } + + assert.Error(t, err) + }) +} + +func TestProxy_Env(t *testing.T) { + e := startProxy(t, nil, okJSON) + env := e.proxy.Env() + + host, port, err := net.SplitHostPort(envValue(t, env, "AUTH0_PLUGIN_DOMAIN")) + require.NoError(t, err) + assert.Equal(t, "127.0.0.1", host) + assert.NotEmpty(t, port) + + secret := envValue(t, env, "AUTH0_PLUGIN_MANAGEMENT_TOKEN") + assert.Regexp(t, `^[0-9a-f]{64}$`, secret) + assert.NotEqual(t, "real-token", secret) + + ca := envValue(t, env, "AUTH0_PLUGIN_CA_CERT") + assert.Equal(t, ca, envValue(t, env, "SSL_CERT_FILE")) + assert.Equal(t, ca, envValue(t, env, "NODE_EXTRA_CA_CERTS")) + assert.Len(t, env, 5) + + other := startProxy(t, nil, okJSON) + assert.NotEqual(t, secret, envValue(t, other.proxy.Env(), "AUTH0_PLUGIN_MANAGEMENT_TOKEN"), "every run has its own secret") +} + +func TestProxy_Close(t *testing.T) { + t.Run("removes the certificate and stops serving", func(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, okJSON) + ca := envValue(t, e.proxy.Env(), "AUTH0_PLUGIN_CA_CERT") + + require.FileExists(t, ca) + require.NoError(t, e.proxy.Close()) + assert.NoFileExists(t, ca) + assert.NoDirExists(t, filepath.Dir(ca)) + assert.NoError(t, e.proxy.Close(), "closing twice is fine") + + _, err := get(t, e.client, e.proxy.addr, "/api/v2/clients") + assert.Error(t, err) + }) + + t.Run("leaves the directory it was given", func(t *testing.T) { + parent := t.TempDir() + e := startProxy(t, nil, okJSON, func(c *Config) { c.Dir = parent }) + + ca := envValue(t, e.proxy.Env(), "AUTH0_PLUGIN_CA_CERT") + assert.Equal(t, parent, filepath.Dir(filepath.Dir(ca))) + + require.NoError(t, e.proxy.Close()) + assert.DirExists(t, parent) + + entries, err := os.ReadDir(parent) + require.NoError(t, err) + assert.Empty(t, entries) + }) + + t.Run("happens when the context ends", func(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + p, err := Start(ctx, Config{ + PluginID: "auth0/checkmate", + Domain: testDomain, + Tokens: &fakeTokens{}, + Policy: testPolicy(t), + Dir: t.TempDir(), + Transport: newUpstream(t, okJSON).transport(), + }) + require.NoError(t, err) + + ca := envValue(t, p.Env(), "AUTH0_PLUGIN_CA_CERT") + require.FileExists(t, ca) + + cancel() + + assert.Eventually(t, func() bool { + _, err := os.Stat(ca) + + return errors.Is(err, os.ErrNotExist) + }, 5*time.Second, 10*time.Millisecond) + + assert.NoError(t, p.Close()) + }) +} + +func TestProxy_CertificateFilePermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file modes do not apply on Windows") + } + + e := startProxy(t, nil, okJSON) + ca := envValue(t, e.proxy.Env(), "AUTH0_PLUGIN_CA_CERT") + + file, err := os.Stat(ca) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), file.Mode().Perm()) + + dir, err := os.Stat(filepath.Dir(ca)) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), dir.Mode().Perm()) +} + +func TestNewTransport(t *testing.T) { + transport := newTransport() + + assert.False(t, transport.DisableCompression) +} + +func TestNewTransport_OffersHTTP1OnlyOverTLS(t *testing.T) { + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + })) + server.EnableHTTP2 = true + server.StartTLS() + t.Cleanup(server.Close) + + pool := x509.NewCertPool() + pool.AddCert(server.Certificate()) + + // A default transport that has been used already offers h2 in its TLS config. + warmed := &http.Transport{ + ForceAttemptHTTP2: true, + TLSClientConfig: &tls.Config{RootCAs: pool, NextProtos: []string{"h2", "http/1.1"}}, + } + + original := http.DefaultTransport + http.DefaultTransport = warmed + t.Cleanup(func() { http.DefaultTransport = original }) + + transport := newTransport() + t.Cleanup(transport.CloseIdleConnections) + + require.NotNil(t, transport.TLSClientConfig) + assert.Equal(t, []string{"http/1.1"}, transport.TLSClientConfig.NextProtos) + + resp, err := (&http.Client{Transport: transport}).Get(server.URL) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "ok", string(body)) + assert.Equal(t, 1, resp.ProtoMajor) + + // The shared default transport keeps what it offered. + assert.Contains(t, warmed.TLSClientConfig.NextProtos, "h2") +} + +func TestProxy_LimitsTheCallsInFlight(t *testing.T) { + const callers = 3 * maxInFlight + + var ( + mu sync.Mutex + inFlight, peaked int + ) + + e := startProxy(t, []string{"read:clients"}, func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + inFlight++ + peaked = max(peaked, inFlight) + mu.Unlock() + + time.Sleep(200 * time.Millisecond) + + mu.Lock() + inFlight-- + mu.Unlock() + + okJSON(w, r) + }) + + var wg sync.WaitGroup + + for range callers { + wg.Go(func() { + resp, _ := e.do(http.MethodGet, "/api/v2/clients", nil) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + } + + wg.Wait() + + assert.Len(t, e.upstream.requests(), callers, "every call is answered in the end") + mu.Lock() + defer mu.Unlock() + + assert.LessOrEqual(t, peaked, maxInFlight) + assert.Empty(t, e.proxy.slots, "every slot was given back") +} + +func TestProxy_forward_WaitsForASlotBeforeReadingTheBody(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, okJSON) + + // Every slot is taken, so the next call has to wait. + for range maxInFlight { + e.proxy.slots <- struct{}{} + } + + ctx, cancel := context.WithCancel(t.Context()) + body := &countingBody{} + + r := httptest.NewRequestWithContext(ctx, http.MethodPost, "/api/v2/clients", body) + target, rej := parseTarget("/api/v2/clients") + require.Nil(t, rej) + + done := make(chan struct{}) + + go func() { + defer close(done) + + e.proxy.forward(httptest.NewRecorder(), r, target) + }() + + select { + case <-done: + t.Fatal("the call did not wait for a slot") + case <-time.After(200 * time.Millisecond): + } + + assert.Zero(t, body.reads.Load(), "the body is not read while the call waits") + + cancel() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("a call that waits for a slot did not stop when its request was cancelled") + } + + assert.Zero(t, body.reads.Load()) + assert.Len(t, e.proxy.slots, maxInFlight, "the call that gave up took no slot and gave none back") + assert.Empty(t, e.upstream.requests()) +} + +// countingBody is a request body that counts how often it is read. +type countingBody struct { + reads atomic.Int32 +} + +func (b *countingBody) Read([]byte) (int, error) { + b.reads.Add(1) + + return 0, io.EOF +} + +func TestProxy_RefreshesAtMostThreeTimes(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + + // Every refresh gives a token that the tenant rejects again. + for n := range 3 * maxRefreshes { + e.tokens.mu.Lock() + e.tokens.refreshTo = fmt.Sprintf("token-%d", n) + e.tokens.mu.Unlock() + + resp, content := e.do(http.MethodGet, "/api/v2/clients", nil) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, "session_expired", errorBody(t, content)["errorCode"]) + } + + assert.Equal(t, maxRefreshes, e.tokens.refreshCount()) + + requests := len(e.upstream.requests()) + e.do(http.MethodGet, "/api/v2/clients", nil) + assert.Equal(t, requests+1, len(e.upstream.requests()), "after the limit a 401 is answered without a replay") +} + +// idleClosingTransport is a transport that counts how often it is told to close +// its idle connections. +type idleClosingTransport struct { + http.RoundTripper + closes atomic.Int32 +} + +func (t *idleClosingTransport) CloseIdleConnections() { + t.closes.Add(1) +} + +func TestProxy_Close_ClosesOnlyTheTransportItBuilt(t *testing.T) { + t.Run("one the caller supplied is left alone", func(t *testing.T) { + given := &idleClosingTransport{} + e := startProxy(t, []string{"read:clients"}, okJSON, func(c *Config) { c.Transport = given }) + + require.NoError(t, e.proxy.Close()) + assert.Zero(t, given.closes.Load()) + }) + + t.Run("its own is closed", func(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, okJSON, func(c *Config) { c.Transport = nil }) + + // Aim the transport that the proxy built at the fake. + owned := e.proxy.owned + require.NotNil(t, owned) + require.Same(t, owned, e.proxy.transport) + + given := e.upstream.transport().(*http.Transport) + owned.Proxy = nil + owned.DialContext = given.DialContext + owned.TLSClientConfig = given.TLSClientConfig + + resp, _ := e.do(http.MethodGet, "/api/v2/clients", nil) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Zero(t, e.upstream.closed.Load(), "the connection to the fake is idle and open") + + require.NoError(t, e.proxy.Close()) + + assert.Eventually(t, func() bool { return e.upstream.closed.Load() > 0 }, 5*time.Second, 10*time.Millisecond, + "the idle connection to the tenant is closed") + }) +} + +// funcTokens is a token source whose answers come from functions. +type funcTokens struct { + token func(ctx context.Context) (string, error) + refresh func(ctx context.Context) (string, error) +} + +func (f *funcTokens) Token(ctx context.Context) (string, error) { return f.token(ctx) } + +func (f *funcTokens) Refresh(ctx context.Context) (string, error) { return f.refresh(ctx) } + +// untilDone blocks until ctx ends, as a token source that waits on something +// that never happens would. +func untilDone(ctx context.Context) (string, error) { + <-ctx.Done() + + return "", ctx.Err() +} + +// rejectEverything answers every request with 401. +func rejectEverything(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) +} + +// hangingTransport is a transport that never answers until the request ends. +type hangingTransport struct{} + +func (hangingTransport) RoundTrip(r *http.Request) (*http.Response, error) { + <-r.Context().Done() + + return nil, r.Context().Err() +} + +func TestProxy_StopsWaitingForATenantThatHangs(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, okJSON, func(c *Config) { c.Transport = hangingTransport{} }) + e.proxy.timeout = 50 * time.Millisecond + e.client.Timeout = 5 * time.Second + + resp, content := e.do(http.MethodGet, "/api/v2/clients", nil) + + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) + assert.Equal(t, "upstream_failed", errorBody(t, content)["errorCode"]) + assert.Eventually(t, func() bool { return len(e.proxy.slots) == 0 }, 5*time.Second, 10*time.Millisecond, + "the slot of the call was given back") +} + +func TestProxy_StopsWaitingForATokenSourceThatHangs(t *testing.T) { + var asked atomic.Int32 + + tests := []struct { + name string + tokens *funcTokens + wantRequests int + }{ + { + name: "when it is asked for the token", + tokens: &funcTokens{token: untilDone}, + }, + { + name: "when it is asked for the token again during a refresh", + tokens: &funcTokens{token: func(ctx context.Context) (string, error) { + if asked.Add(1) == 1 { + return "real-token", nil + } + + return untilDone(ctx) + }}, + wantRequests: 1, + }, + { + name: "when it is asked to refresh", + tokens: &funcTokens{token: func(context.Context) (string, error) { return "real-token", nil }, refresh: untilDone}, + wantRequests: 1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, rejectEverything, func(c *Config) { c.Tokens = test.tokens }) + e.proxy.timeout = 50 * time.Millisecond + e.client.Timeout = 5 * time.Second + + resp, content := e.do(http.MethodGet, "/api/v2/clients", nil) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, "session_expired", errorBody(t, content)["errorCode"]) + assert.Len(t, e.upstream.requests(), test.wantRequests) + + require.True(t, e.proxy.refreshMu.TryLock(), "the refresh mutex was given back") + e.proxy.refreshMu.Unlock() + + assert.Eventually(t, func() bool { return len(e.proxy.slots) == 0 }, 5*time.Second, 10*time.Millisecond, + "the slot of the call was given back") + }) + } +} + +func TestProxy_RejectsARefreshThatReturnsATokenWithAnError(t *testing.T) { + tokens := &funcTokens{ + token: func(context.Context) (string, error) { return "real-token", nil }, + refresh: func(context.Context) (string, error) { return "new-token", errors.New("the refresh failed halfway") }, + } + + e := startProxy(t, []string{"read:clients"}, rejectEverything, func(c *Config) { c.Tokens = tokens }) + + resp, content := e.do(http.MethodGet, "/api/v2/clients", nil) + + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) + assert.Equal(t, "session_expired", errorBody(t, content)["errorCode"]) + assert.Len(t, e.upstream.requests(), 1, "the request was not sent again with the token that came with the error") +} + +// endlessTransport is a transport whose responses are far larger than the proxy +// reads, and which counts the bytes the proxy read. +type endlessTransport struct { + read atomic.Int64 +} + +func (e *endlessTransport) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: endlessBody{read: &e.read}, + }, nil +} + +// endlessBody is a response body of blanks that ends after three times the limit +// of the proxy, so that a proxy without a limit finishes instead of running out +// of memory. +type endlessBody struct { + read *atomic.Int64 +} + +func (b endlessBody) Read(p []byte) (int, error) { + remaining := 3*maxResponseBody - b.read.Load() + if remaining <= 0 { + return 0, io.EOF + } + + n := int(min(int64(len(p)), remaining)) + for i := range p[:n] { + p[i] = ' ' + } + + b.read.Add(int64(n)) + + return n, nil +} + +func (endlessBody) Close() error { return nil } + +func TestProxy_StopsReadingAResponseThatIsTooLarge(t *testing.T) { + transport := &endlessTransport{} + e := startProxy(t, []string{"read:clients"}, okJSON, func(c *Config) { c.Transport = transport }) + + resp, content := e.do(http.MethodGet, "/api/v2/clients", nil) + + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) + assert.Equal(t, "unsupported_response", errorBody(t, content)["errorCode"]) + + const slack = 64 << 10 + + read := transport.read.Load() + assert.Greater(t, read, int64(maxResponseBody), "the proxy read past the limit to find out that the response is too large") + assert.LessOrEqual(t, read, int64(maxResponseBody+slack), "the proxy stopped reading near the limit") +} + +// brokenBodyTransport is a transport whose responses fail while they are read. +type brokenBodyTransport struct{} + +func (brokenBodyTransport) RoundTrip(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(iotest.ErrReader(errors.New("the connection broke"))), + }, nil +} + +func TestProxy_AnswersAResponseThatCannotBeReadWithUpstreamFailed(t *testing.T) { + e := startProxy(t, []string{"read:clients"}, okJSON, func(c *Config) { c.Transport = brokenBodyTransport{} }) + + resp, content := e.do(http.MethodGet, "/api/v2/clients", nil) + + assert.Equal(t, http.StatusBadGateway, resp.StatusCode) + assert.Equal(t, "upstream_failed", errorBody(t, content)["errorCode"]) +} diff --git a/internal/plugins/proxy/strip.go b/internal/plugins/proxy/strip.go new file mode 100644 index 000000000..c5fbe711a --- /dev/null +++ b/internal/plugins/proxy/strip.go @@ -0,0 +1,187 @@ +package proxy + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" +) + +// maxJSONDepth is how deeply nested a body can be. It is the limit of +// encoding/json, which no response of the Management API comes near. +const maxJSONDepth = 10000 + +// errTooDeep is the error for a body that is nested more deeply than +// maxJSONDepth. +var errTooDeep = fmt.Errorf("not valid JSON: nested more than %d levels deep", maxJSONDepth) + +// jsonFrame is an object or an array that stripSecrets is inside of. +type jsonFrame struct { + // An object frame is an object, not an array. + object bool + + // An object frame that expects a key is waiting for its next key. + expectKey bool + + // The items are how many were written to the frame so far. + items int +} + +// stripSecrets returns body, which must be one JSON value, without the object +// members whose name is a secret field. The name is compared without regard to +// case, at any depth, and the member is removed with its value, not set to null. +// Numbers are written exactly as they were read. +// +// It reads and writes tokens and keeps the objects it is inside of on a stack of +// its own, so a deeply nested body from a remote server cannot overflow the call +// stack, and it gives up on a body nested more than maxJSONDepth levels deep. It +// returns an error for anything that is not exactly one valid JSON value, because +// a body that cannot be read cannot be checked. +func stripSecrets(body []byte, isSecret func(name string) bool) ([]byte, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + + var ( + out bytes.Buffer + frames []jsonFrame + done bool + ) + + // The output is never much longer than the body, so one allocation is enough. + out.Grow(len(body)) + + // The closed function marks that a value ended, so the enclosing object waits + // for a key. + closed := func() { + if n := len(frames); n > 0 && frames[n-1].object { + frames[n-1].expectKey = true + } + } + + // The begin function writes what comes before a value: the comma between + // array items. + begin := func() { + if n := len(frames); n > 0 && !frames[n-1].object { + if frames[n-1].items > 0 { + out.WriteByte(',') + } + + frames[n-1].items++ + } + } + + for !done { + token, err := decoder.Token() + if err != nil { + return nil, fmt.Errorf("not valid JSON: %w", err) + } + + switch token := token.(type) { + case json.Delim: + if token == '}' || token == ']' { + out.WriteByte(byte(token)) + frames = frames[:len(frames)-1] + closed() + } else { + if len(frames) >= maxJSONDepth { + return nil, errTooDeep + } + + begin() + out.WriteByte(byte(token)) + frames = append(frames, jsonFrame{object: token == '{', expectKey: token == '{'}) + } + case string: + if n := len(frames); n > 0 && frames[n-1].object && frames[n-1].expectKey { + frame := &frames[n-1] + + // The frame keeps waiting for a key while the secret is skipped. + if isSecret(token) { + if err := skipValue(decoder, len(frames)); err != nil { + return nil, err + } + + continue + } + + frame.expectKey = false + + if frame.items > 0 { + out.WriteByte(',') + } + + frame.items++ + writeString(&out, token) + out.WriteByte(':') + + continue + } + + begin() + writeString(&out, token) + closed() + default: + begin() + writeScalar(&out, token) + closed() + } + + done = len(frames) == 0 + } + + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return nil, errors.New("not valid JSON: data after the first value") + } + + return out.Bytes(), nil +} + +// skipValue reads the next value from the decoder and drops it. The value can be +// an object or an array, which are skipped by counting how deep the decoder is. +// The outer depth is how deep the value is already, for the limit. +func skipValue(decoder *json.Decoder, outer int) error { + depth := 0 + + for { + token, err := decoder.Token() + if err != nil { + return fmt.Errorf("not valid JSON: %w", err) + } + + if delim, ok := token.(json.Delim); ok { + if delim == '{' || delim == '[' { + depth++ + + if outer+depth > maxJSONDepth { + return errTooDeep + } + } else { + depth-- + } + } + + if depth == 0 { + return nil + } + } +} + +// writeString writes s as a JSON string. +func writeString(out *bytes.Buffer, s string) { + encoded, _ := json.Marshal(s) + out.Write(encoded) +} + +// writeScalar writes a number, a boolean or null as it was read. +func writeScalar(out *bytes.Buffer, token json.Token) { + switch token := token.(type) { + case json.Number: + out.WriteString(token.String()) + case bool: + out.WriteString(strconv.FormatBool(token)) + default: + out.WriteString("null") + } +} diff --git a/internal/plugins/proxy/strip_test.go b/internal/plugins/proxy/strip_test.go new file mode 100644 index 000000000..d16822df9 --- /dev/null +++ b/internal/plugins/proxy/strip_test.go @@ -0,0 +1,118 @@ +package proxy + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStripSecrets(t *testing.T) { + policy := testPolicy(t) + + tests := []struct { + name string + body string + want string + }{ + {name: "a top level secret", body: `{"client_id":"a","client_secret":"s"}`, want: `{"client_id":"a"}`}, + {name: "the first member", body: `{"client_secret":"s","client_id":"a"}`, want: `{"client_id":"a"}`}, + {name: "the only member", body: `{"client_secret":"s"}`, want: `{}`}, + {name: "removed and not nulled", body: `{"a":1,"private_key":null}`, want: `{"a":1}`}, + {name: "a nested object", body: `{"a":{"b":{"client_secret":"s","c":1}}}`, want: `{"a":{"b":{"c":1}}}`}, + {name: "options.client_secret", body: `{"options":{"client_secret":"s","scope":"openid"}}`, want: `{"options":{"scope":"openid"}}`}, + {name: "an array of objects", body: `[{"client_secret":"a","id":1},{"id":2,"signing_keys":[{"cert":"c"}]}]`, want: `[{"id":1},{"id":2}]`}, + {name: "mixed case", body: `{"Client_Secret":"a","SIGNING_KEYS":[],"Encryption_Key":{}}`, want: `{}`}, + {name: "a name written with an escape", body: `{"client\u005fsecret":"a","ok":true}`, want: `{"ok":true}`}, + {name: "a secret with an object value", body: `{"a":1,"signing_keys":{"x":[1,{"y":[]}]},"b":2}`, want: `{"a":1,"b":2}`}, + {name: "a secret with an array value", body: `{"signing_keys":[[1],[{"a":"b"}]],"b":2}`, want: `{"b":2}`}, + {name: "the same secret twice", body: `{"client_secret":"a","x":1,"client_secret":"b"}`, want: `{"x":1}`}, + {name: "a secret as a string value is not a name", body: `{"a":"client_secret"}`, want: `{"a":"client_secret"}`}, + {name: "a secret as an item of an array is not a name", body: `["client_secret",{"client_secret":1}]`, want: `["client_secret",{}]`}, + {name: "a name that only contains a secret name", body: `{"client_secret_expires_at":0,"x_client_secret":1}`, want: `{"client_secret_expires_at":0,"x_client_secret":1}`}, + {name: "scalars and nulls", body: `{"a":true,"b":false,"c":null,"d":"x","e":[]}`, want: `{"a":true,"b":false,"c":null,"d":"x","e":[]}`}, + {name: "numbers as they were", body: `{"a":12345678901234567890,"b":1.10,"c":1e400,"d":-0.0}`, want: `{"a":12345678901234567890,"b":1.10,"c":1e400,"d":-0.0}`}, + {name: "whitespace", body: " {\n\t\"a\" : [ 1 , 2 ] ,\n \"client_secret\" : \"s\" }\n", want: `{"a":[1,2]}`}, + {name: "text with escapes", body: `{"a":"line\nbreak \"quoted\" é "}`, want: "{\"a\":\"line\\nbreak \\\"quoted\\\" é \\u003cb\\u003e\"}"}, + {name: "a top level string", body: `"client_secret"`, want: `"client_secret"`}, + {name: "a top level number", body: `42`, want: `42`}, + {name: "a top level array", body: `[]`, want: `[]`}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := stripSecrets([]byte(test.body), policy.isSecret) + + require.NoError(t, err) + assert.Equal(t, test.want, string(got)) + }) + } +} + +func TestStripSecrets_RejectsWhatItCannotRead(t *testing.T) { + policy := testPolicy(t) + + tests := []struct { + name string + body string + }{ + {name: "text", body: `not json`}, + {name: "a truncated object", body: `{"a":1`}, + {name: "a truncated secret value", body: `{"client_secret":{"a":[1,`}, + {name: "a missing colon", body: `{"a" 1}`}, + {name: "a trailing comma", body: `{"a":1,}`}, + {name: "a second value", body: `{"a":1}{"client_secret":"s"}`}, + {name: "a second value after whitespace", body: "{\"a\":1}\n[\"client_secret\"]"}, + {name: "data after a value", body: `{"a":1} x`}, + {name: "a closing bracket after a value", body: `{"a":1}}`}, + {name: "only whitespace", body: " \n"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := stripSecrets([]byte(test.body), policy.isSecret) + + require.Error(t, err) + assert.Nil(t, got) + }) + } +} + +func TestStripSecrets_DoesNotRecurse(t *testing.T) { + policy := testPolicy(t) + + // A body can be as deep as encoding/json allows, and it is not read deeper. + const depth = maxJSONDepth + + deep := func(levels int) string { return strings.Repeat("[", levels) + strings.Repeat("]", levels) } + nested := func(levels int) string { + return strings.Repeat(`{"a":`, levels-1) + `{"client_secret":"s"}` + strings.Repeat("}", levels-1) + } + skipped := func(levels int) string { return `{"client_secret":` + deep(levels-1) + `,"a":1}` } + + t.Run("the deepest body that is allowed", func(t *testing.T) { + got, err := stripSecrets([]byte(deep(depth)), policy.isSecret) + require.NoError(t, err) + assert.Equal(t, deep(depth), string(got)) + + got, err = stripSecrets([]byte(nested(depth)), policy.isSecret) + require.NoError(t, err) + assert.Equal(t, strings.Repeat(`{"a":`, depth-1)+`{}`+strings.Repeat("}", depth-1), string(got)) + + got, err = stripSecrets([]byte(skipped(depth)), policy.isSecret) + require.NoError(t, err) + assert.Equal(t, `{"a":1}`, string(got)) + }) + + t.Run("a body that is deeper", func(t *testing.T) { + for _, levels := range []int{depth + 1, 100000} { + for name, body := range map[string]string{"arrays": deep(levels), "objects": nested(levels), "a skipped secret": skipped(levels)} { + got, err := stripSecrets([]byte(body), policy.isSecret) + + require.ErrorIs(t, err, errTooDeep, "%d levels of %s", levels, name) + assert.Nil(t, got) + } + } + }) +} diff --git a/internal/plugins/proxy/summary.go b/internal/plugins/proxy/summary.go new file mode 100644 index 000000000..805cb582e --- /dev/null +++ b/internal/plugins/proxy/summary.go @@ -0,0 +1,167 @@ +package proxy + +import ( + "fmt" + "slices" + "strings" + "sync" +) + +// The limits on what the proxy remembers and says about denied calls. +const ( + maxRememberedDenials = 20 + maxListedDenials = 3 +) + +// denial is a call that the proxy denied, with how often. +type denial struct { + // The call as text that is safe to print. + what string + + // The scope is the one that would have allowed the call, or empty. + scope string + + count int +} + +// denialLog records the calls that were denied so that the caller can say so +// after the plugin ran. Only the first maxRememberedDenials different calls are +// kept, and the rest are only counted. A call that was denied for a missing scope +// is kept in preference to a call that was not. +type denialLog struct { + mu sync.Mutex + + // The kept denials are the different ones, in the order they first happened. + kept []denial + + // The total is how many calls the proxy denied. + total int + + // The refused calls are those that Auth0 refused with a 403, which means the + // session lacks a scope that the plugin declared. + refused int +} + +// deny records a call that the proxy denied. The scope is the one that would +// have allowed it, or empty when the reason is not a missing scope. +func (l *denialLog) deny(scope, method, path string) { + what := safeText(method) + " " + safeText(path) + if scope != "" { + what = safeText(scope) + " for " + what + } + + l.mu.Lock() + defer l.mu.Unlock() + + l.total++ + + for n := range l.kept { + if l.kept[n].what == what { + l.kept[n].count++ + + return + } + } + + entry := denial{what: what, scope: scope, count: 1} + + if len(l.kept) < maxRememberedDenials { + l.kept = append(l.kept, entry) + + return + } + + // A scope that is not kept yet takes the place of a denial that the summary + // would not list first, so that a plugin cannot push the missing scope out of + // the summary by making many other calls first. + if scope == "" || slices.ContainsFunc(l.kept, func(d denial) bool { return d.scope == scope }) { + return + } + + for n := len(l.kept) - 1; n >= 0; n-- { + if l.kept[n].scope == "" || slices.ContainsFunc(l.kept[:n], func(d denial) bool { return d.scope == l.kept[n].scope }) { + l.kept[n] = entry + + return + } + } +} + +// listed returns the denials that the summary names: the first one of every +// scope, then the ones without a scope, at most maxListedDenials. The scope is +// what the user has to add to plugin.json, so it comes before anything else. +func (l *denialLog) listed() []denial { + var scoped, unscoped []denial + + seen := make(map[string]struct{}) + + for _, d := range l.kept { + if d.scope == "" { + unscoped = append(unscoped, d) + + continue + } + + if _, ok := seen[d.scope]; !ok { + seen[d.scope] = struct{}{} + scoped = append(scoped, d) + } + } + + scoped = append(scoped, unscoped...) + + return scoped[:min(len(scoped), maxListedDenials)] +} + +// refuse records a call that Auth0 refused with a 403. +func (l *denialLog) refuse() { + l.mu.Lock() + defer l.mu.Unlock() + + l.refused++ +} + +// summary returns one line that says what was denied, or an empty string when +// nothing was. +func (l *denialLog) summary(pluginID string) string { + l.mu.Lock() + defer l.mu.Unlock() + + var parts []string + + if l.total > 0 { + listed := l.listed() + covered := 0 + + names := make([]string, 0, len(listed)+1) + for _, d := range listed { + names = append(names, d.what) + covered += d.count + } + + if other := l.total - covered; other > 0 { + names = append(names, fmt.Sprintf("%d other", other)) + } + + parts = append(parts, fmt.Sprintf("%s denied (%s)", plural(l.total, "API call was", "API calls were"), strings.Join(names, ", "))) + } + + if l.refused > 0 { + parts = append(parts, fmt.Sprintf("Auth0 refused %s because your session lacks a scope", plural(l.refused, "call", "calls"))) + } + + if len(parts) == 0 { + return "" + } + + return fmt.Sprintf("Plugin %s: %s. Review permissions.scopes in plugin.json.", safeText(pluginID), strings.Join(parts, " and ")) +} + +// plural returns the count with the singular or the plural form of a noun. +func plural(count int, singular, many string) string { + if count == 1 { + return "1 " + singular + } + + return fmt.Sprintf("%d %s", count, many) +} diff --git a/internal/plugins/proxy/summary_test.go b/internal/plugins/proxy/summary_test.go new file mode 100644 index 000000000..b4756721b --- /dev/null +++ b/internal/plugins/proxy/summary_test.go @@ -0,0 +1,153 @@ +package proxy + +import ( + "fmt" + "strings" + "testing" + "unicode" + + "github.com/stretchr/testify/assert" +) + +func TestDenialLog_summary(t *testing.T) { + tests := []struct { + name string + do func(l *denialLog) + want string + }{ + {name: "nothing was denied", do: func(*denialLog) {}}, + { + name: "one call", + do: func(l *denialLog) { l.deny("read:users", "GET", "/api/v2/users") }, + want: "Plugin checkmate: 1 API call was denied (read:users for GET /api/v2/users). Review permissions.scopes in plugin.json.", + }, + { + name: "the same call twice", + do: func(l *denialLog) { + l.deny("read:users", "GET", "/api/v2/users") + l.deny("read:users", "GET", "/api/v2/users") + }, + want: "Plugin checkmate: 2 API calls were denied (read:users for GET /api/v2/users). Review permissions.scopes in plugin.json.", + }, + { + name: "a call that is denied for another reason than a scope", + do: func(l *denialLog) { l.deny("", "GET", "/oauth/token") }, + want: "Plugin checkmate: 1 API call was denied (GET /oauth/token). Review permissions.scopes in plugin.json.", + }, + { + name: "some different calls", + do: func(l *denialLog) { + l.deny("read:users", "GET", "/api/v2/users") + l.deny("", "DELETE", "/api/v2/clients/a") + l.deny("read:users", "GET", "/api/v2/users") + }, + want: "Plugin checkmate: 3 API calls were denied (read:users for GET /api/v2/users, DELETE /api/v2/clients/a). Review permissions.scopes in plugin.json.", + }, + { + name: "only Auth0 refused", + do: func(l *denialLog) { l.refuse() }, + want: "Plugin checkmate: Auth0 refused 1 call because your session lacks a scope. Review permissions.scopes in plugin.json.", + }, + { + name: "both", + do: func(l *denialLog) { + l.deny("read:users", "GET", "/api/v2/users") + l.refuse() + l.refuse() + }, + want: "Plugin checkmate: 1 API call was denied (read:users for GET /api/v2/users) and Auth0 refused 2 calls because your session lacks a scope. Review permissions.scopes in plugin.json.", + }, + { + name: "calls without a scope first do not push the missing scope out", + do: func(l *denialLog) { + l.deny("", "DELETE", "/api/v2/clients/a") + l.deny("", "GET", "/oauth/token") + l.deny("", "GET", "/userinfo") + + for range 5 { + l.deny("read:users", "GET", "/api/v2/users") + } + }, + want: "Plugin checkmate: 8 API calls were denied (read:users for GET /api/v2/users, DELETE /api/v2/clients/a, GET /oauth/token, 1 other). Review permissions.scopes in plugin.json.", + }, + { + name: "every missing scope is named before the calls without one", + do: func(l *denialLog) { + l.deny("", "GET", "/oauth/token") + l.deny("read:users", "GET", "/api/v2/users") + l.deny("read:users", "GET", "/api/v2/users/a") + l.deny("read:clients", "GET", "/api/v2/clients") + l.deny("read:roles", "GET", "/api/v2/roles") + l.deny("read:logs", "GET", "/api/v2/logs") + }, + want: "Plugin checkmate: 6 API calls were denied (read:users for GET /api/v2/users, read:clients for GET /api/v2/clients, read:roles for GET /api/v2/roles, 3 other). Review permissions.scopes in plugin.json.", + }, + { + name: "many different calls", + do: func(l *denialLog) { + for n := range 30 { + l.deny("read:users", "GET", fmt.Sprintf("/api/v2/users/%d", n)) + } + }, + want: "Plugin checkmate: 30 API calls were denied (read:users for GET /api/v2/users/0, 29 other). Review permissions.scopes in plugin.json.", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var log denialLog + test.do(&log) + + assert.Equal(t, test.want, log.summary("checkmate")) + }) + } +} + +func TestDenialLog_keepsAtMostTwentyDifferentCalls(t *testing.T) { + var log denialLog + + for n := range 100 { + log.deny("", "GET", fmt.Sprintf("/api/v2/%d", n)) + } + + assert.Len(t, log.kept, 20) + assert.Equal(t, 100, log.total) + assert.Contains(t, log.summary("checkmate"), "100 API calls were denied") +} + +func TestDenialLog_keepsAMissingScopeWhenMemoryIsFull(t *testing.T) { + var log denialLog + + for n := range 50 { + log.deny("", "GET", fmt.Sprintf("/api/v2/%d", n)) + } + + log.deny("read:users", "GET", "/api/v2/users") + log.deny("read:users", "GET", "/api/v2/users/a") + + assert.Len(t, log.kept, 20) + assert.Contains(t, log.summary("checkmate"), "(read:users for GET /api/v2/users, ") + + // A second scope is kept too, and more calls for a scope that is kept are + // only counted. + for n := range 20 { + log.deny("read:logs", "GET", fmt.Sprintf("/api/v2/logs/%d", n)) + } + + assert.Len(t, log.kept, 20) + assert.Contains(t, log.summary("checkmate"), "read:users for GET /api/v2/users") + assert.Contains(t, log.summary("checkmate"), "read:logs for GET /api/v2/logs/0") +} + +func TestDenialLog_summaryHasNoControlCharacters(t *testing.T) { + var log denialLog + + log.deny("read:users", "GET\x1b[31m", "/api/v2/\x1b]0;title\x07\u202e\u0085\n"+strings.Repeat("a", 500)) + log.refuse() + + summary := log.summary("check\x1bmate\n") + + assert.NotEmpty(t, summary) + assert.Equal(t, -1, strings.IndexFunc(summary, func(r rune) bool { return unicode.IsControl(r) || r > unicode.MaxASCII })) + assert.Less(t, len(summary), 600) +} diff --git a/internal/plugins/proxy/testdata/scope-table.json b/internal/plugins/proxy/testdata/scope-table.json new file mode 100644 index 000000000..698efc279 --- /dev/null +++ b/internal/plugins/proxy/testdata/scope-table.json @@ -0,0 +1,24 @@ +{ + "scopeTable": { + "formatVersion": 1, + "rules": [ + { "method": "GET", "path": "/api/v2/clients", "anyOf": ["read:clients"] }, + { "method": "POST", "path": "/api/v2/clients", "anyOf": ["create:clients"] }, + { "method": "GET", "path": "/api/v2/clients/{id}", "anyOf": ["read:clients", "read:client_keys", "read:client_credentials", "read:client_summary"] }, + { "method": "PATCH", "path": "/api/v2/clients/{id}", "anyOf": ["update:clients"] }, + { "method": "DELETE", "path": "/api/v2/clients/{id}", "anyOf": ["delete:clients"] }, + { "method": "GET", "path": "/api/v2/actions/actions/{actionId}/versions/{id}", "anyOf": ["read:actions"] }, + { "method": "GET", "path": "/api/v2/connections", "anyOf": ["read:connections"] }, + { "method": "GET", "path": "/api/v2/connections/{id}", "anyOf": ["read:connections"] }, + { "method": "GET", "path": "/api/v2/users", "anyOf": ["read:users"] }, + { "method": "GET", "path": "/api/v2/users/{id}", "anyOf": ["read:users"] }, + { "method": "GET", "path": "/api/v2/users/{id}/roles", "anyOf": ["read:roles"] }, + { "method": "GET", "path": "/api/v2/tenants/settings", "anyOf": ["read:tenant_settings"] }, + { "method": "GET", "path": "/api/v2/stats/active-users", "anyOf": ["read:stats"] } + ] + }, + "secretFields": { + "formatVersion": 1, + "names": ["client_secret", "signing_keys", "encryption_key", "private_key"] + } +} diff --git a/internal/plugins/proxy/tls.go b/internal/plugins/proxy/tls.go new file mode 100644 index 000000000..7f767dd7f --- /dev/null +++ b/internal/plugins/proxy/tls.go @@ -0,0 +1,60 @@ +package proxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" + "time" +) + +// The lifetime of the certificate of a run. +const ( + certBackdate = 5 * time.Minute + certLifetime = 24 * time.Hour +) + +// newCertificate makes the certificate that the proxy serves with. It is +// self-signed, valid for 127.0.0.1 only, and made for this run: the key is kept +// in memory and is gone when the process ends. It returns the certificate and +// its PEM encoding, which the plugin is told to trust. +func newCertificate(now time.Time) (tls.Certificate, []byte, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("failed to generate the proxy key: %w", err) + } + + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 127)) + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("failed to generate the certificate serial number: %w", err) + } + + template := &x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "auth0 plugin proxy"}, + NotBefore: now.Add(-certBackdate), + NotAfter: now.Add(certLifetime), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLen: 0, + MaxPathLenZero: true, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}, + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + return tls.Certificate{}, nil, fmt.Errorf("failed to create the proxy certificate: %w", err) + } + + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}, + pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), + nil +} diff --git a/internal/plugins/proxy/tls_test.go b/internal/plugins/proxy/tls_test.go new file mode 100644 index 000000000..4ff5a48fb --- /dev/null +++ b/internal/plugins/proxy/tls_test.go @@ -0,0 +1,57 @@ +package proxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/x509" + "encoding/pem" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewCertificate(t *testing.T) { + now := time.Date(2030, time.January, 1, 12, 0, 0, 0, time.UTC) + + cert, certPEM, err := newCertificate(now) + require.NoError(t, err) + + block, rest := pem.Decode(certPEM) + require.NotNil(t, block) + assert.Empty(t, rest) + assert.Equal(t, "CERTIFICATE", block.Type) + + parsed, err := x509.ParseCertificate(block.Bytes) + require.NoError(t, err) + assert.Equal(t, cert.Certificate[0], block.Bytes) + + key, ok := parsed.PublicKey.(*ecdsa.PublicKey) + require.True(t, ok, "the key is an ECDSA key") + assert.Equal(t, elliptic.P256(), key.Curve) + + assert.True(t, parsed.IsCA) + assert.Equal(t, []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, parsed.ExtKeyUsage) + assert.Empty(t, parsed.DNSNames) + require.Len(t, parsed.IPAddresses, 1) + assert.True(t, parsed.IPAddresses[0].Equal(net.IPv4(127, 0, 0, 1))) + assert.Equal(t, now.Add(-5*time.Minute), parsed.NotBefore) + assert.Equal(t, now.Add(24*time.Hour), parsed.NotAfter) + + pool := x509.NewCertPool() + pool.AddCert(parsed) + + verify := func(host string, at time.Time) error { + _, err := parsed.Verify(x509.VerifyOptions{DNSName: host, Roots: pool, CurrentTime: at}) + + return err + } + + assert.NoError(t, verify("127.0.0.1", now)) + assert.Error(t, verify("localhost", now), "only the loopback address is covered") + assert.Error(t, verify("example.com", now)) + assert.Error(t, verify("127.0.0.1", now.Add(25*time.Hour)), "it expires") + assert.Error(t, verify("127.0.0.1", now.Add(-6*time.Minute)), "it is not valid before the allowed clock skew") +} diff --git a/internal/plugins/registry/index.go b/internal/plugins/registry/index.go index a5c900818..83907099d 100644 --- a/internal/plugins/registry/index.go +++ b/internal/plugins/registry/index.go @@ -16,6 +16,8 @@ import ( "strings" "time" + "github.com/santhosh-tekuri/jsonschema/v6" + "github.com/auth0/auth0-cli/internal/plugins" ) @@ -43,10 +45,39 @@ type Index struct { Deprecations []Deprecation `json:"deprecated,omitempty"` Policy Policy `json:"policy,omitzero"` - // ScopeTable and SecretFields are kept as they are in the index until the - // code that uses them lands (DXCDT-2606, DXCDT-2614). - ScopeTable json.RawMessage `json:"scopeTable,omitempty"` - SecretFields json.RawMessage `json:"secretFields,omitempty"` + // ScopeTable and SecretFields are the data the plugin proxy enforces. They + // are zero when the registry publishes none, and the proxy refuses to run a + // plugin then. + ScopeTable ScopeTable `json:"scopeTable,omitzero"` + SecretFields SecretFields `json:"secretFields,omitzero"` +} + +// ScopeTable lists the Management API calls that the plugin proxy knows a scope +// for. A call that no rule matches is denied. +type ScopeTable struct { + FormatVersion int `json:"formatVersion"` + Rules []ScopeRule `json:"rules"` +} + +// ScopeRule says which scopes allow one Management API call. +type ScopeRule struct { + // Method is the HTTP method, one of GET, POST, PUT, PATCH and DELETE. + Method string `json:"method"` + + // Path is the path template, such as /api/v2/clients/{id}. A segment in + // braces matches any one segment. + Path string `json:"path"` + + // AnyOf lists the scopes that allow the call. A plugin that declared at least + // one of them may make it. + AnyOf []string `json:"anyOf"` +} + +// SecretFields lists the names of the fields that the plugin proxy removes from +// every response before the plugin sees it. +type SecretFields struct { + FormatVersion int `json:"formatVersion"` + Names []string `json:"names"` } // Plugin is a plugin listed in the index. The versions of a plugin are either @@ -324,6 +355,16 @@ func (i *Index) semanticErrors() plugins.ProblemList { } } + // The sections are optional in the index, and the zero value stands for a + // section that is not there. A section that is there has format version 1. + if i.ScopeTable.FormatVersion != 0 { + found = append(found, i.ScopeTable.problems()...) + } + + if i.SecretFields.FormatVersion != 0 { + found = append(found, i.SecretFields.problems()...) + } + if timeout := i.Policy.DefaultTimeout; timeout != "" { if err := plugins.CheckTimeout(timeout); err != nil { found.Addf("policy.defaultTimeout: %v", err) @@ -333,6 +374,116 @@ func (i *Index) semanticErrors() plugins.ProblemList { return found } +// Validate checks the scope table with the schema of the index and with the +// checks that the schema cannot express, which are the checks that ParseIndex +// makes. It returns an error that wraps ErrInvalidIndex and names the JSON path +// of every problem, as seen from the index. Code that gets a table from +// anywhere but ParseIndex must call it before it relies on the table. +func (t ScopeTable) Validate() error { + if problems := t.problems(); len(problems) > 0 { + return newInvalidError("scopeTable", problems) + } + + return nil +} + +// problems returns the problems found by the schema or, when the schema has +// none, by the checks that it cannot express. +func (t ScopeTable) problems() plugins.ProblemList { + found, err := schemaProblems("scopeTable", func(s schemas) *jsonschema.Schema { return s.scopeTable }, t) + if err != nil { + return plugins.ProblemList{err.Error()} + } + + if len(found) > 0 { + return found + } + + return t.extraProblems() +} + +// extraProblems runs the checks on the scope table that JSON Schema cannot +// express: no dot segments, and no two rules for the same call. +func (t ScopeTable) extraProblems() plugins.ProblemList { + var found plugins.ProblemList + + // Rules that only differ in the names of their parameters match the same + // calls, so they count as the same rule. + shapes := make(map[string]string, len(t.Rules)) + + for n, rule := range t.Rules { + rulePath := fmt.Sprintf("scopeTable.rules[%d]", n) + + segments := strings.Split(strings.TrimPrefix(rule.Path, "/api/v2/"), "/") + for m, segment := range segments { + if segment == "." || segment == ".." { + found.Addf("%s.path: segment %d must not be %q", rulePath, m+1, segment) + } + + if strings.HasPrefix(segment, "{") { + segments[m] = "{}" + } + } + + shape := rule.Method + " " + strings.Join(segments, "/") + if first, duplicate := shapes[shape]; duplicate { + found.Addf("%s: duplicate rule for %s %s, already defined by %s", rulePath, rule.Method, rule.Path, first) + } + + shapes[shape] = rulePath + } + + return found +} + +// Validate checks the secret fields with the schema of the index and with the +// checks that the schema cannot express, which are the checks that ParseIndex +// makes. It returns an error that wraps ErrInvalidIndex and names the JSON path +// of every problem, as seen from the index. +func (f SecretFields) Validate() error { + if problems := f.problems(); len(problems) > 0 { + return newInvalidError("secretFields", problems) + } + + return nil +} + +// problems returns the problems found by the schema or, when the schema has +// none, by the checks that it cannot express. +func (f SecretFields) problems() plugins.ProblemList { + found, err := schemaProblems("secretFields", func(s schemas) *jsonschema.Schema { return s.secretFields }, f) + if err != nil { + return plugins.ProblemList{err.Error()} + } + + if len(found) > 0 { + return found + } + + return f.extraProblems() +} + +// extraProblems runs the checks on the secret field names that JSON Schema +// cannot express: names are compared without regard to case, so two names that +// only differ in case are duplicates. +func (f SecretFields) extraProblems() plugins.ProblemList { + var found plugins.ProblemList + + names := make(map[string]string, len(f.Names)) + + for n, name := range f.Names { + namePath := fmt.Sprintf("secretFields.names[%d]", n) + + if first, duplicate := names[strings.ToLower(name)]; duplicate { + found.Addf("%s: duplicate name %q, already used by %s", namePath, name, first) + } + + names[strings.ToLower(name)] = namePath + } + + return found +} + // checkVersions runs the checks that JSON Schema cannot express on the version // records found at path. func checkVersions(path string, versions []VersionRecord) plugins.ProblemList { diff --git a/internal/plugins/registry/index_test.go b/internal/plugins/registry/index_test.go index 06113c874..d471398e5 100644 --- a/internal/plugins/registry/index_test.go +++ b/internal/plugins/registry/index_test.go @@ -420,18 +420,173 @@ func TestParseIndex_Format(t *testing.T) { assert.Equal(t, 12, index.Policy.CheckIntervalHours) }) - t.Run("keeps the scope table and the secret fields as they are", func(t *testing.T) { - index, err := parseMutated(t, func(index map[string]any) { - index["scopeTable"] = map[string]any{"checkmate": []any{"read:clients"}} - index["secretFields"] = []any{"client_secret"} - }) + t.Run("round trips the scope table and the secret fields", func(t *testing.T) { + table := registry.ScopeTable{FormatVersion: 1, Rules: []registry.ScopeRule{ + {Method: "GET", Path: "/api/v2/clients/{id}", AnyOf: []string{"read:clients", "read:client_keys"}}, + }} + fields := registry.SecretFields{FormatVersion: 1, Names: []string{"client_secret"}} + + index, err := registry.ParseIndex(testBuilder().SetScopeTable(table).SetSecretFields(fields).Build()) + + require.NoError(t, err) + assert.Equal(t, table, index.ScopeTable) + assert.Equal(t, fields, index.SecretFields) + }) + + t.Run("leaves the scope table and the secret fields zero when the index has none", func(t *testing.T) { + index, err := registry.ParseIndex(testBuilder().Build()) require.NoError(t, err) - assert.JSONEq(t, `{"checkmate":["read:clients"]}`, string(index.ScopeTable)) - assert.JSONEq(t, `["client_secret"]`, string(index.SecretFields)) + assert.Zero(t, index.ScopeTable) + assert.Zero(t, index.SecretFields) }) } +func TestParseIndex_ScopeTableAndSecretFields(t *testing.T) { + rule := func(method, path string, anyOf ...any) map[string]any { + return map[string]any{"method": method, "path": path, "anyOf": anyOf} + } + table := func(rules ...any) map[string]any { + return map[string]any{"formatVersion": 1, "rules": append([]any{}, rules...)} + } + fields := func(names ...any) map[string]any { + return map[string]any{"formatVersion": 1, "names": names} + } + segments := func(n int) string { return "/api/v2" + strings.Repeat("/a", n) } + generate := func(n int, item func(i string) any) []any { + list := make([]any, 0, n) + for i := range n { + list = append(list, item(strconv.Itoa(i))) + } + + return list + } + scopes := func(n int) []any { return generate(n, func(i string) any { return "read:s" + i }) } + names := func(n int) []any { return generate(n, func(i string) any { return "n" + i }) } + many := func(n int) []any { + return generate(n, func(i string) any { return rule("GET", "/api/v2/r"+i, "read:clients") }) + } + + valid := []struct { + name string + mutate func(index map[string]any) + }{ + {name: "a parameter in camel case", mutate: setValue(table(rule("GET", "/api/v2/actions/actions/{actionId}/versions/{id}", "read:actions")), "scopeTable")}, + {name: "a rule with a parameter", mutate: setValue(table(rule("GET", "/api/v2/clients/{id}", "read:clients")), "scopeTable")}, + {name: "every method", mutate: setValue(table( + rule("GET", "/api/v2/a", "read:a"), rule("POST", "/api/v2/a", "create:a"), rule("PUT", "/api/v2/a", "update:a"), + rule("PATCH", "/api/v2/a", "update:a"), rule("DELETE", "/api/v2/a", "delete:a"), + ), "scopeTable")}, + {name: "16 segments", mutate: setValue(table(rule("GET", segments(16), "read:a")), "scopeTable")}, + {name: "8 alternatives", mutate: setValue(table(rule("GET", "/api/v2/a", scopes(8)...)), "scopeTable")}, + {name: "5000 rules", mutate: setValue(table(many(5000)...), "scopeTable")}, + {name: "a table without rules", mutate: setValue(table(), "scopeTable")}, + {name: "a name of 64 characters", mutate: setValue(fields(strings.Repeat("a", 64)), "secretFields")}, + {name: "500 names", mutate: setValue(fields(names(500)...), "secretFields")}, + } + + for _, test := range valid { + t.Run("accepts "+test.name, func(t *testing.T) { + _, err := parseMutated(t, test.mutate) + assert.NoError(t, err) + }) + } + + tests := []struct { + name string + mutate func(index map[string]any) + wantErrs []string + }{ + {name: "a newer scope table format", mutate: setValue(map[string]any{"formatVersion": 2, "rules": []any{}}, "scopeTable"), wantErrs: []string{"scopeTable.formatVersion:"}}, + {name: "a scope table without rules", mutate: setValue(map[string]any{"formatVersion": 1}, "scopeTable"), wantErrs: []string{"scopeTable.rules: required field is missing"}}, + {name: "an unknown field in the scope table", mutate: setValue(map[string]any{"formatVersion": 1, "rules": []any{}, "extra": 1}, "scopeTable"), wantErrs: []string{"scopeTable.extra: unknown field"}}, + {name: "an unknown field in a rule", mutate: setValue(table(map[string]any{"method": "GET", "path": "/api/v2/a", "anyOf": []any{"read:a"}, "extra": 1}), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].extra: unknown field"}}, + {name: "a rule field written in another case", mutate: setValue(table(map[string]any{"Method": "GET", "method": "GET", "path": "/api/v2/a", "anyOf": []any{"read:a"}}), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].Method: unknown field"}}, + {name: "a method in lowercase", mutate: setValue(table(rule("get", "/api/v2/a", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].method:"}}, + {name: "a method that is not allowed", mutate: setValue(table(rule("HEAD", "/api/v2/a", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].method:"}}, + {name: "a path outside the Management API", mutate: setValue(table(rule("GET", "/oauth/token", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path:"}}, + {name: "the prefix in another case", mutate: setValue(table(rule("GET", "/API/v2/a", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path:"}}, + {name: "a path with no resource", mutate: setValue(table(rule("GET", "/api/v2", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path:"}}, + {name: "a trailing slash", mutate: setValue(table(rule("GET", "/api/v2/a/", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path:"}}, + {name: "an empty segment", mutate: setValue(table(rule("GET", "/api/v2/a//b", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path:"}}, + {name: "17 segments", mutate: setValue(table(rule("GET", segments(17), "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path:"}}, + {name: "a dot segment", mutate: setValue(table(rule("GET", "/api/v2/a/./b", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path: segment 2 must not be"}}, + {name: "a dot dot segment", mutate: setValue(table(rule("GET", "/api/v2/a/../b", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path: segment 2 must not be"}}, + {name: "an encoded segment", mutate: setValue(table(rule("GET", "/api/v2/a%2fb", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path:"}}, + {name: "a parameter that starts with a digit", mutate: setValue(table(rule("GET", "/api/v2/a/{1id}", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path:"}}, + {name: "a parameter with a dash", mutate: setValue(table(rule("GET", "/api/v2/a/{client-id}", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path:"}}, + {name: "a parameter mixed with text", mutate: setValue(table(rule("GET", "/api/v2/a/x{id}", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].path:"}}, + {name: "a rule without alternatives", mutate: setValue(table(rule("GET", "/api/v2/a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].anyOf:"}}, + {name: "a rule with an empty list of alternatives", mutate: setValue(table(map[string]any{"method": "GET", "path": "/api/v2/a", "anyOf": []any{}}), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].anyOf:"}}, + {name: "9 alternatives", mutate: setValue(table(rule("GET", "/api/v2/a", scopes(9)...)), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].anyOf:"}}, + {name: "a scope that is not a scope", mutate: setValue(table(rule("GET", "/api/v2/a", "read-clients")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].anyOf[0]:"}}, + {name: "the same alternative twice", mutate: setValue(table(rule("GET", "/api/v2/a", "read:a", "read:a")), "scopeTable"), wantErrs: []string{"scopeTable.rules[0].anyOf:"}}, + {name: "5001 rules", mutate: setValue(table(many(5001)...), "scopeTable"), wantErrs: []string{"scopeTable.rules:"}}, + { + name: "the same rule twice", + mutate: setValue(table(rule("GET", "/api/v2/a/{id}", "read:a"), rule("GET", "/api/v2/a/{id}", "read:b")), "scopeTable"), + wantErrs: []string{"scopeTable.rules[1]: duplicate rule for GET /api/v2/a/{id}, already defined by scopeTable.rules[0]"}, + }, + { + name: "rules that only differ in the name of a parameter", + mutate: setValue(table(rule("GET", "/api/v2/a/{id}", "read:a"), rule("GET", "/api/v2/a/{client_id}", "read:b")), "scopeTable"), + wantErrs: []string{"scopeTable.rules[1]: duplicate rule"}, + }, + {name: "secret fields without names", mutate: setValue(map[string]any{"formatVersion": 1}, "secretFields"), wantErrs: []string{"secretFields.names: required field is missing"}}, + {name: "an empty list of names", mutate: setValue(fields(), "secretFields"), wantErrs: []string{"secretFields.names:"}}, + {name: "a newer secret fields format", mutate: setValue(map[string]any{"formatVersion": 2, "names": []any{"a"}}, "secretFields"), wantErrs: []string{"secretFields.formatVersion:"}}, + {name: "an unknown field in the secret fields", mutate: setValue(map[string]any{"formatVersion": 1, "names": []any{"a"}, "extra": 1}, "secretFields"), wantErrs: []string{"secretFields.extra: unknown field"}}, + {name: "a name of 65 characters", mutate: setValue(fields(strings.Repeat("a", 65)), "secretFields"), wantErrs: []string{"secretFields.names[0]:"}}, + {name: "an empty name", mutate: setValue(fields(""), "secretFields"), wantErrs: []string{"secretFields.names[0]:"}}, + {name: "a name with a dot, which no single key can match", mutate: setValue(fields("options.client_secret"), "secretFields"), wantErrs: []string{"secretFields.names[0]:"}}, + {name: "a name with a space", mutate: setValue(fields("client secret"), "secretFields"), wantErrs: []string{"secretFields.names[0]:"}}, + {name: "501 names", mutate: setValue(fields(names(501)...), "secretFields"), wantErrs: []string{"secretFields.names:"}}, + {name: "names that only differ in case", mutate: setValue(fields("client_secret", "Client_Secret"), "secretFields"), wantErrs: []string{`secretFields.names[1]: duplicate name "Client_Secret", already used by secretFields.names[0]`}}, + {name: "names that only differ in case, the capitalized one first", mutate: setValue(fields("Client_Secret", "client_secret"), "secretFields"), wantErrs: []string{`secretFields.names[1]: duplicate name "client_secret", already used by secretFields.names[0]`}}, + {name: "a null scope table", mutate: setValue(nil, "scopeTable"), wantErrs: []string{"scopeTable:"}}, + } + + for _, test := range tests { + t.Run("rejects "+test.name, func(t *testing.T) { + index, err := parseMutated(t, test.mutate) + + assert.Nil(t, index) + requireInvalid(t, err, test.wantErrs...) + }) + } +} + +func TestValidate_ScopeTableAndSecretFields(t *testing.T) { + good := registry.ScopeRule{Method: "GET", Path: "/api/v2/clients/{clientId}", AnyOf: []string{"read:clients"}} + table := func(rules ...registry.ScopeRule) registry.ScopeTable { + return registry.ScopeTable{FormatVersion: 1, Rules: rules} + } + + assert.NoError(t, table(good).Validate()) + assert.NoError(t, registry.SecretFields{FormatVersion: 1, Names: []string{"client_secret", "private_key"}}.Validate()) + + tests := []struct { + name string + err error + wantPart string + }{ + {name: "an empty scope", err: table(registry.ScopeRule{Method: "GET", Path: "/api/v2/a", AnyOf: []string{""}}).Validate(), wantPart: "scopeTable.rules[0].anyOf[0]:"}, + {name: "no scope", err: table(registry.ScopeRule{Method: "GET", Path: "/api/v2/a"}).Validate(), wantPart: "scopeTable.rules[0].anyOf:"}, + {name: "the same rule twice", err: table(good, good).Validate(), wantPart: "scopeTable.rules[1]: duplicate rule"}, + {name: "a dot dot segment", err: table(registry.ScopeRule{Method: "GET", Path: "/api/v2/a/../b", AnyOf: []string{"read:a"}}).Validate(), wantPart: "scopeTable.rules[0].path: segment 2 must not be"}, + {name: "a table of another format", err: registry.ScopeTable{FormatVersion: 2, Rules: []registry.ScopeRule{good}}.Validate(), wantPart: "scopeTable.formatVersion:"}, + {name: "a name with a dot", err: registry.SecretFields{FormatVersion: 1, Names: []string{"a.b"}}.Validate(), wantPart: "secretFields.names[0]:"}, + {name: "names that differ in case", err: registry.SecretFields{FormatVersion: 1, Names: []string{"a", "A"}}.Validate(), wantPart: "secretFields.names[1]: duplicate name"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.ErrorIs(t, test.err, registry.ErrInvalidIndex) + assert.Contains(t, test.err.Error(), test.wantPart) + }) + } +} + func TestParseIndex_SemanticFailures(t *testing.T) { record := func(version string, change func(record *registry.VersionRecord)) registry.VersionRecord { r := registrytest.NewVersion(version) diff --git a/internal/plugins/registry/registrytest/builder.go b/internal/plugins/registry/registrytest/builder.go index b08ee2241..241eba3bb 100644 --- a/internal/plugins/registry/registrytest/builder.go +++ b/internal/plugins/registry/registrytest/builder.go @@ -110,6 +110,20 @@ func (b *IndexBuilder) SetPolicy(policy registry.Policy) *IndexBuilder { return b } +// SetScopeTable sets the scope table of the index. +func (b *IndexBuilder) SetScopeTable(table registry.ScopeTable) *IndexBuilder { + b.index.ScopeTable = table + + return b +} + +// SetSecretFields sets the secret fields of the index. +func (b *IndexBuilder) SetSecretFields(fields registry.SecretFields) *IndexBuilder { + b.index.SecretFields = fields + + return b +} + // AddPlugin adds a plugin whose versions are listed in the index. func (b *IndexBuilder) AddPlugin(plugin registry.Plugin) *IndexBuilder { b.index.Plugins = append(b.index.Plugins, plugin) diff --git a/internal/plugins/registry/schema.go b/internal/plugins/registry/schema.go index b9d6ee1fe..78f9403d6 100644 --- a/internal/plugins/registry/schema.go +++ b/internal/plugins/registry/schema.go @@ -7,6 +7,7 @@ import ( "fmt" "reflect" "slices" + "strings" "sync" "github.com/santhosh-tekuri/jsonschema/v6" @@ -35,6 +36,8 @@ func IndexSchema() []byte { type schemas struct { index *jsonschema.Schema versionsFile *jsonschema.Schema + scopeTable *jsonschema.Schema + secretFields *jsonschema.Schema } // compileSchemas compiles the embedded schema on first use and reuses the @@ -60,7 +63,17 @@ var compileSchemas = sync.OnceValues(func() (schemas, error) { return schemas{}, fmt.Errorf("failed to compile the registry plugin file schema: %w", err) } - return schemas{index: index, versionsFile: versionsFile}, nil + scopeTable, err := compiler.Compile(schemaURL + "#/$defs/scopeTable") + if err != nil { + return schemas{}, fmt.Errorf("failed to compile the scope table schema: %w", err) + } + + secretFields, err := compiler.Compile(schemaURL + "#/$defs/secretFields") + if err != nil { + return schemas{}, fmt.Errorf("failed to compile the secret fields schema: %w", err) + } + + return schemas{index: index, versionsFile: versionsFile, scopeTable: scopeTable, secretFields: secretFields}, nil }) // decode validates data against schema and decodes it into a T. What names the @@ -97,3 +110,40 @@ func decode[T any](schema *jsonschema.Schema, data []byte, what string) (T, erro return decoded, nil } + +// schemaProblems validates a section of the index, which is the value of the +// field called name, against its schema and returns the problems with their +// paths as they are in the index. It reports no problem for a section that +// passes. +func schemaProblems(name string, schema func(schemas) *jsonschema.Schema, section any) ([]string, error) { + compiled, err := compileSchemas() + if err != nil { + return nil, err + } + + data, err := json.Marshal(section) + if err != nil { + return nil, fmt.Errorf("failed to encode %s: %w", name, err) + } + + instance, err := jsonschema.UnmarshalJSON(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("failed to read %s: %w", name, err) + } + + err = schema(compiled).Validate(instance) + if err == nil { + return nil, nil + } + + problems := plugins.SchemaProblems(err) + if len(problems) == 0 { + return nil, fmt.Errorf("failed to validate %s: %w", name, err) + } + + for n, problem := range problems { + problems[n] = name + "." + strings.TrimPrefix(problem, "(root)") + } + + return problems, nil +} diff --git a/internal/plugins/registry/schema/index.schema.json b/internal/plugins/registry/schema/index.schema.json index d6a099e0b..0987970a8 100644 --- a/internal/plugins/registry/schema/index.schema.json +++ b/internal/plugins/registry/schema/index.schema.json @@ -87,12 +87,8 @@ } } }, - "scopeTable": { - "description": "Opaque to this schema." - }, - "secretFields": { - "description": "Opaque to this schema." - } + "scopeTable": { "$ref": "#/$defs/scopeTable" }, + "secretFields": { "$ref": "#/$defs/secretFields" } }, "$defs": { "httpsURL": { @@ -136,6 +132,67 @@ "windows-x64" ] }, + "scopeTable": { + "description": "The Management API calls that the plugin proxy allows, and the scopes that allow each. A call that no rule matches is denied. Unknown fields are rejected, as this is security data.", + "type": "object", + "additionalProperties": false, + "required": ["formatVersion", "rules"], + "properties": { + "formatVersion": { "type": "integer", "const": 1 }, + "rules": { + "type": "array", + "maxItems": 5000, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["method", "path", "anyOf"], + "properties": { + "method": { + "type": "string", + "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] + }, + "path": { + "description": "Path template that starts with /api/v2/ and has 1 to 16 more segments. A segment is a literal or a parameter in braces, such as {id}, which matches any one segment. There is no trailing slash.", + "type": "string", + "maxLength": 1024, + "pattern": "^/api/v2(/([A-Za-z0-9._~-]+|\\{[A-Za-z][A-Za-z0-9_]*\\})){1,16}$" + }, + "anyOf": { + "description": "Scopes of which a plugin must have declared at least one to make the call.", + "type": "array", + "minItems": 1, + "maxItems": 8, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z]+:[a-z0-9_]+$" + } + } + } + } + } + } + }, + "secretFields": { + "description": "Names of the fields that the plugin proxy removes from every response, at any depth and without regard to case. Unknown fields are rejected, as this is security data.", + "type": "object", + "additionalProperties": false, + "required": ["formatVersion", "names"], + "properties": { + "formatVersion": { "type": "integer", "const": 1 }, + "names": { + "type": "array", + "minItems": 1, + "maxItems": 500, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[A-Za-z0-9_-]+$" + } + } + } + }, "plugin": { "type": "object", "required": ["id", "description", "owner", "latest"],