diff --git a/authn/authenticate.go b/authn/authenticate.go index 3e3e96928..66d1cfdb5 100644 --- a/authn/authenticate.go +++ b/authn/authenticate.go @@ -8,6 +8,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net" "net/http" "net/url" "strconv" @@ -61,22 +62,23 @@ type Groups struct { // Auth holds state, configuration, and utilities needed for authentication. type Auth struct { - logger logger.Logger - cookieName string - secretKey []byte - groupEndpoint string - logoutEndpoint string - fbURL string // fbURL is the domain featurebase is hosted on, used for post logout redirection - oAuthConfig *oauth2.Config - cacheTTL time.Duration // cacheTTL is used to determine if a cached item should be refreshed or not - tokenTTR time.Duration // tokenTTR (time to refresh) is used to determine if a token should be refreshed or not - tokenCache map[string]cachedToken // tokenCache is a map of accessToken -> *oauth2.Token which we can use to refresh the tokens - groupsCache map[string]cachedGroups // groupsCache is a map of accessToken -> group memberships - lastCacheClean time.Time // last cache clean is the time that the cache was last cleaned + logger logger.Logger + cookieName string + secretKey []byte + groupEndpoint string + logoutEndpoint string + fbURL string // fbURL is the domain featurebase is hosted on, used for post logout redirection + oAuthConfig *oauth2.Config + cacheTTL time.Duration // cacheTTL is used to determine if a cached item should be refreshed or not + tokenTTR time.Duration // tokenTTR (time to refresh) is used to determine if a token should be refreshed or not + tokenCache map[string]cachedToken // tokenCache is a map of accessToken -> *oauth2.Token which we can use to refresh the tokens + groupsCache map[string]cachedGroups // groupsCache is a map of accessToken -> group memberships + lastCacheClean time.Time // last cache clean is the time that the cache was last cleaned + allowedNetworks []net.IPNet // list of allowed networks for ingest } // NewAuth instantiates and returns a new Auth struct -func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenURL, groupEndpoint, logout, clientID, clientSecret, secretKey string) (auth *Auth, err error) { +func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenURL, groupEndpoint, logout, clientID, clientSecret, secretKey string, configuredIPs []string) (auth *Auth, err error) { auth = &Auth{ logger: logger, cookieName: CookieName, @@ -103,6 +105,13 @@ func NewAuth(logger logger.Logger, url string, scopes []string, authURL, tokenUR if auth.secretKey, err = decodeHex(secretKey); err != nil { return nil, errors.Wrap(err, "decoding secret key") } + + // convert IPs and add them to allowed networks + err = auth.convertIP(configuredIPs) + if err != nil { + return nil, err + } + return auth, nil } @@ -336,3 +345,39 @@ func decodeHex(hexstr string) ([]byte, error) { } return data, nil } + +func (a *Auth) convertIP(configuredIPs []string) error { + sz := len(configuredIPs) + nets := make([]net.IPNet, sz) + for i, ip := range configuredIPs { + // skip empty strings + if ip == "" { + sz-- + continue + } + // for IPs passed without a subnet, append /32 to only allow 1 IP + // this step is needed because ParseCIDR method assumes a CIDR address + if !strings.Contains(ip, "/") { + ip = ip + "/32" + } + _, subnet, err := net.ParseCIDR(ip) + if err != nil { + return errors.Wrapf(err, "parsing CIDR for %v", ip) + } + nets[i] = *subnet + } + a.allowedNetworks = nets[:sz] + return nil +} + +// if IP is in allowed networks, then return true to grant admin permissions +func (a *Auth) CheckAllowedNetworks(clientIP string) bool { + clientIP = strings.Split(clientIP, ":")[0] + convertedIP := net.ParseIP(clientIP) + for _, network := range a.allowedNetworks { + if network.Contains(convertedIP) { + return true + } + } + return false +} diff --git a/authn/authenticate_internal_test.go b/authn/authenticate_internal_test.go index 67aa6d695..10dd307fb 100644 --- a/authn/authenticate_internal_test.go +++ b/authn/authenticate_internal_test.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net" "net/http" "net/http/httptest" "os" @@ -33,6 +34,7 @@ func NewTestAuth(t *testing.T) *Auth { LogoutURL = "https://login.microsoftonline.com/common/oauth2/v2.0/logout" Scopes = []string{"https://graph.microsoft.com/.default", "offline_access"} Key = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" + configuredIPs = []string{} ) a, err := NewAuth( @@ -46,6 +48,7 @@ func NewTestAuth(t *testing.T) *Auth { ClientID, ClientSecret, Key, + configuredIPs, ) if err != nil { t.Fatalf("building auth object%s", err) @@ -147,6 +150,7 @@ func TestAuth(t *testing.T) { "e9088663-eb08-41d7-8f65-efb5f54bbb71", "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF", "DEADBEEFD", + []string{}, ) if err == nil || !strings.Contains(err.Error(), "decoding secret key") { t.Fatalf("expected error decoding secret key got: %v", err) @@ -648,3 +652,101 @@ func (s *ServerTransportStream) SetTrailer(md metadata.MD) error { _ = md return nil } + +func TestCheckAllowedNetworks(t *testing.T) { + + tests := []struct { + requestIP string + configuredIPs []string + isAdmin bool + }{ + { + requestIP: "10.0.0.1", + configuredIPs: []string{"10.0.0.1"}, + isAdmin: true, + }, + { + requestIP: "10.0.0.3", + configuredIPs: []string{"10.0.0.1", "10.0.0.2"}, + isAdmin: false, + }, + { + requestIP: "10.0.0.2", + configuredIPs: []string{"10.0.0.1/30"}, + isAdmin: true, + }, + // it is possible for the client IP to have a port + { + requestIP: "10.0.0.2:22", + configuredIPs: []string{"10.0.0.1/30"}, + isAdmin: true, + }, + { + requestIP: "10.1.0.3", + configuredIPs: []string{"10.0.0.1/32"}, + isAdmin: false, + }, + { + requestIP: "10.0.0.254", + configuredIPs: []string{"10.0.0.1/24"}, + isAdmin: true, + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("network-%d", i), func(t *testing.T) { + a := NewTestAuth(t) + if err := a.convertIP(test.configuredIPs); err != nil { + t.Fatalf("failed to convert IPs from strings to net.IP: %v", err) + } + got := a.CheckAllowedNetworks(test.requestIP) + if got != test.isAdmin { + t.Fatalf("expected %v, got %v", test.isAdmin, got) + } + }) + } +} + +func TestConvertIP(t *testing.T) { + + tests := []struct { + configuredIPs []string + convertedIPs []net.IPNet + }{ + { + configuredIPs: []string{"10.0.0.1"}, + convertedIPs: []net.IPNet{ + {IP: net.ParseIP("10.0.0.1"), Mask: net.CIDRMask(32, 32)}, + }, + }, + { + configuredIPs: []string{"10.0.0.1/30"}, + convertedIPs: []net.IPNet{ + {IP: net.ParseIP("10.0.0.0"), Mask: net.CIDRMask(30, 32)}, + }, + }, + } + + for i, test := range tests { + t.Run(fmt.Sprintf("network-%d", i), func(t *testing.T) { + a := NewTestAuth(t) + if err := a.convertIP(test.configuredIPs); err != nil { + t.Fatalf("failed to convert IPs from strings to net.IP: %v", err) + } + + if len(a.allowedNetworks) != len(test.convertedIPs) { + t.Fatalf("expected len of %v networks, got %v", len(test.convertedIPs), len(a.allowedNetworks)) + } + + for i := range a.allowedNetworks { + expected, got := test.convertedIPs[i], a.allowedNetworks[i] + if got.IP.String() != expected.IP.String() { + t.Fatalf("for IP, expected %v, got %v", expected.IP, got.IP) + } + if got.Mask.String() != expected.Mask.String() { + t.Fatalf("for mask, expected %v, got %v", expected.Mask.String(), got.Mask.String()) + } + } + }) + } +} diff --git a/ctl/import_test.go b/ctl/import_test.go index 5c4603c89..1aca06ad3 100644 --- a/ctl/import_test.go +++ b/ctl/import_test.go @@ -641,6 +641,7 @@ func TestImport_AuthOn(t *testing.T) { auth.ClientId, auth.ClientSecret, auth.SecretKey, + []string{}, ) if err != nil { t.Fatal(err) diff --git a/ctl/server.go b/ctl/server.go index 20363bced..cb317c6af 100644 --- a/ctl/server.go +++ b/ctl/server.go @@ -108,6 +108,7 @@ func BuildServerFlags(cmd *cobra.Command, srv *server.Command) { flags.StringVar(&srv.Config.Auth.SecretKey, "auth.secret-key", srv.Config.Auth.SecretKey, "Secret key used for auth.") flags.StringVar(&srv.Config.Auth.PermissionsFile, "auth.permissions", srv.Config.Auth.PermissionsFile, "Permissions' file with group authorization.") flags.StringVar(&srv.Config.Auth.QueryLogPath, "auth.query-log-path", srv.Config.Auth.QueryLogPath, "Path to log user queries") + flags.StringSliceVar(&srv.Config.Auth.ConfiguredIPs, "auth.configured-ips", srv.Config.Auth.ConfiguredIPs, "List of configured IPs allowed for ingest") flags.BoolVar(&srv.Config.DataDog.Enable, "datadog.enable", false, "enable continuous profiling with DataDog cloud service, Note you must have DataDog agent installed") flags.StringVar(&srv.Config.DataDog.Service, "datadog.service", "default-service", "The Datadog service name, for example my-web-app") diff --git a/http_handler.go b/http_handler.go index 8d96a9a80..7ba2f5b4e 100644 --- a/http_handler.go +++ b/http_handler.go @@ -93,6 +93,23 @@ var externalPrefixFlag = map[string]bool{ "version": true, } +const ( + // OriginalIPHeader is the original IP for client + // It is used mainly for authenticating on remote nodes + // ForwardedIPHeader gets updated to the node's IP + // when requests are forward to other nodes in the cluster + OriginalIPHeader = "X-Molecula-Original-IP" + + // ForwardedIPHeader is part of the standard header + // it is used to identify the originating IP of a client + ForwardedIPHeader = "X-Forwarded-For" + + // AllowedNetworksGroupName is used for the admin group authorization + // when authentication is completed through checking the client IP + // against the allowed networks + AllowedNetworksGroupName = "allowed-networks" +) + type errorResponse struct { Error string `json:"error"` } @@ -581,35 +598,69 @@ func (h *Handler) chkInternal(handler http.HandlerFunc) http.HandlerFunc { } } +func (h *Handler) chkAllowedNetworks(r *http.Request) (bool, context.Context) { + // for every request, get IP of the request and check against configured IPs + reqIP := GetIP(r) + if reqIP == "" { + return false, r.Context() + } + + // if client IP is in allowed networks + // add it to the context for key X-Molecula-Original-IP + if h.auth.CheckAllowedNetworks(reqIP) { + ctx := context.WithValue(r.Context(), OriginalIPHeader, reqIP) + return true, ctx + } + return false, r.Context() +} + func (h *Handler) chkAuthN(handler http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() if h.auth != nil { - uinfo, err := h.auth.Authenticate(r.Context(), getToken(r)) + // if IP is in allowed networks, then serve the request + allowedNetwork, ctx := h.chkAllowedNetworks(r) + if allowedNetwork { + handler.ServeHTTP(w, r.WithContext(ctx)) + return + } + + uinfo, err := h.auth.Authenticate(ctx, getToken(r)) if err != nil { http.Error(w, errors.Wrap(err, "authenticating").Error(), http.StatusUnauthorized) return } // just in case it got refreshed h.auth.SetCookie(w, uinfo.Token, uinfo.Expiry) + ctx = context.WithValue(ctx, "token", r.Header["Authorization"]) } - ctx := context.WithValue(r.Context(), "token", r.Header["Authorization"]) handler.ServeHTTP(w, r.WithContext(ctx)) } } func (h *Handler) chkAuthZ(handler http.HandlerFunc, perm authz.Permission) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + // if auth isn't turned on, just serve the request if h.auth == nil { handler.ServeHTTP(w, r) return } + // check if IP is in allowed networks, if yes give it admin permissions + allowedNetwork, ctx := h.chkAllowedNetworks(r) + if allowedNetwork { + ctx = context.WithValue(ctx, contextKeyGroupMembership, []string{AllowedNetworksGroupName, h.permissions.Admin}) + handler.ServeHTTP(w, r.WithContext(ctx)) + return + } + // make a copy of the requested permissions lperm := perm // check if the user is authenticated - uinfo, err := h.auth.Authenticate(r.Context(), getToken(r)) + uinfo, err := h.auth.Authenticate(ctx, getToken(r)) if err != nil { http.Error(w, errors.Wrap(err, "authenticating").Error(), http.StatusForbidden) return @@ -618,7 +669,7 @@ func (h *Handler) chkAuthZ(handler http.HandlerFunc, perm authz.Permission) http h.auth.SetCookie(w, uinfo.Token, uinfo.Expiry) // put the user's groups in the context - ctx := context.WithValue(r.Context(), contextKeyGroupMembership, uinfo.Groups) + ctx = context.WithValue(ctx, contextKeyGroupMembership, uinfo.Groups) ctx = context.WithValue(ctx, "token", "Bearer "+uinfo.Token) // unlikely h.permissions will be nil, but we'll check to be safe @@ -630,7 +681,7 @@ func (h *Handler) chkAuthZ(handler http.HandlerFunc, perm authz.Permission) http // figure out what the user is querying for queryString := "" - queryRequest := r.Context().Value(contextKeyQueryRequest) + queryRequest := ctx.Value(contextKeyQueryRequest) if req, ok := queryRequest.(*QueryRequest); ok { queryString = req.Query @@ -701,10 +752,27 @@ func (h *Handler) chkAuthZ(handler http.HandlerFunc, perm authz.Permission) http } func GetIP(r *http.Request) string { - forwarded := r.Header.Get("X-FORWARDED-FOR") - if forwarded != "" { - return forwarded + // check if original IP was set in the request + og := r.Header.Get(OriginalIPHeader) + if og != "" { + return og } + + // check if original IP is in the context + if ogIP, ok := r.Context().Value(OriginalIPHeader).(string); ok && ogIP != "" { + return ogIP + } + + // X-Forwarded-For can have multiple IPs + // the first IP will always be the originating client IP + // the remaining IPs will be for any proxies the request went through + forwarded := r.Header.Get(ForwardedIPHeader) + forwardedList := strings.Split(forwarded, ",") + if forwardedList[0] != "" { + return forwardedList[0] + + } + return r.RemoteAddr } @@ -888,6 +956,40 @@ func headerAcceptRoaringRow(header http.Header) bool { return false } +func (h *Handler) filterSchema(schema []*IndexInfo, g []authn.Group) []*IndexInfo { + if !h.permissions.IsAdmin(g) { + var filtered []*IndexInfo + allowed := h.permissions.GetAuthorizedIndexList(g, authz.Read) + for _, s := range schema { + for _, index := range allowed { + if s.Name == index { + filtered = append(filtered, s) + break + } + } + } + schema = filtered + } + return schema +} + +func (h *Handler) getGroupMembership(r *http.Request) (g []authn.Group) { + // if IP is in allowed networks, then give admin group membership + if h.auth.CheckAllowedNetworks(GetIP(r)) { + g = []authn.Group{ + { + GroupID: h.permissions.Admin, + GroupName: AllowedNetworksGroupName, + }, + } + return g + } + + // check if group membership was already set in request when auth token was obtained + g = r.Context().Value(contextKeyGroupMembership).([]authn.Group) + return g +} + // handleGetSchema handles GET /schema requests. func (h *Handler) handleGetSchema(w http.ResponseWriter, r *http.Request) { if !validHeaderAcceptJSON(r.Header) { @@ -906,24 +1008,12 @@ func (h *Handler) handleGetSchema(w http.ResponseWriter, r *http.Request) { // if auth is turned on, filter response to only include authorized indexes if h.auth != nil { - g := r.Context().Value(contextKeyGroupMembership) + g := h.getGroupMembership(r) if g == nil { http.Error(w, "Forbidden", http.StatusForbidden) return } - if !h.permissions.IsAdmin(g.([]authn.Group)) { - var filtered []*IndexInfo - allowed := h.permissions.GetAuthorizedIndexList(g.([]authn.Group), authz.Read) - for _, s := range schema { - for _, index := range allowed { - if s.Name == index { - filtered = append(filtered, s) - break - } - } - } - schema = filtered - } + schema = h.filterSchema(schema, g) } if err := json.NewEncoder(w).Encode(Schema{Indexes: schema}); err != nil { @@ -952,25 +1042,14 @@ func (h *Handler) handleGetSchemaDetails(w http.ResponseWriter, r *http.Request) // if auth is turned on, filter response to only include authorized indexes if h.auth != nil { - g := r.Context().Value(contextKeyGroupMembership) + g := h.getGroupMembership(r) if g == nil { http.Error(w, "Forbidden", http.StatusForbidden) return } - if !h.permissions.IsAdmin(g.([]authn.Group)) { - var filtered []*IndexInfo - allowed := h.permissions.GetAuthorizedIndexList(g.([]authn.Group), authz.Read) - for _, s := range schema { - for _, index := range allowed { - if s.Name == index { - filtered = append(filtered, s) - break - } - } - } - schema = filtered - } + schema = h.filterSchema(schema, g) } + if err := json.NewEncoder(w).Encode(Schema{Indexes: schema}); err != nil { h.logger.Printf("write schema response error: %s", err) } diff --git a/http_handler_internal_test.go b/http_handler_internal_test.go index f5237deaa..14129bb4b 100644 --- a/http_handler_internal_test.go +++ b/http_handler_internal_test.go @@ -3,6 +3,7 @@ package pilosa import ( "bytes" + "context" "encoding/hex" "encoding/json" "fmt" @@ -188,6 +189,19 @@ func readResponse(w *httptest.ResponseRecorder) ([]byte, error) { return ioutil.ReadAll(res.Body) } +// common variables used for testing auth +var ( + ClientID = "e9088663-eb08-41d7-8f65-efb5f54bbb71" + ClientSecret = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" + AuthorizeURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize" + TokenURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/token" + GroupEndpointURL = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?$count=true" + LogoutURL = "https://login.microsoftonline.com/common/oauth2/v2.0/logout" + Scopes = []string{"https://graph.microsoft.com/.default", "offline_access"} + Key = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" + ConfiguredIPs = []string{} +) + func TestAuthentication(t *testing.T) { type evaluate func(w *httptest.ResponseRecorder, data []byte) type endpoint func(w http.ResponseWriter, r *http.Request) @@ -221,34 +235,10 @@ func TestAuthentication(t *testing.T) { })) defer groupSrv.Close() - var ( - ClientId = "e9088663-eb08-41d7-8f65-efb5f54bbb71" - ClientSecret = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" - AuthorizeURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize" - TokenURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/token" - GroupEndpointURL = groupSrv.URL - LogoutURL = "https://login.microsoftonline.com/common/oauth2/v2.0/logout" - Scopes = []string{"https://graph.microsoft.com/.default", "offline_access"} - SecretKey = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" - ) + GroupEndpointURL = groupSrv.URL + secretKey, _ := hex.DecodeString(Key) - secretKey, _ := hex.DecodeString("DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") - - a, err := authn.NewAuth( - logger.NewStandardLogger(os.Stdout), - "http://localhost:10101/", - Scopes, - AuthorizeURL, - TokenURL, - GroupEndpointURL, - LogoutURL, - ClientId, - ClientSecret, - SecretKey, - ) - if err != nil { - t.Errorf("building auth object%s", err) - } + a := NewTestAuth(t) h := Handler{ logger: logger.NewStandardLogger(os.Stdout), @@ -781,17 +771,6 @@ func TestChkInternal(t *testing.T) { func NewTestAuth(t *testing.T) *authn.Auth { t.Helper() - var ( - ClientID = "e9088663-eb08-41d7-8f65-efb5f54bbb71" - ClientSecret = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" - AuthorizeURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize" - TokenURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/token" - GroupEndpointURL = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?$count=true" - LogoutURL = "https://login.microsoftonline.com/common/oauth2/v2.0/logout" - Scopes = []string{"https://graph.microsoft.com/.default", "offline_access"} - Key = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" - ) - a, err := authn.NewAuth( logger.NewStandardLogger(os.Stdout), "http://localhost:10101/", @@ -803,6 +782,7 @@ func NewTestAuth(t *testing.T) *authn.Auth { ClientID, ClientSecret, Key, + ConfiguredIPs, ) if err != nil { t.Fatalf("building auth object%s", err) @@ -846,3 +826,172 @@ func TestHandleGetDiskUsage(t *testing.T) { t.Fatalf("expected %v, got %v", http.StatusOK, resp.StatusCode) } } + +func TestAuthzAllowedIPs(t *testing.T) { + tests := []struct { + configuredIPs []string + clientIP string + statusCode int + permission authz.Permission + }{ + // client IP is in configured IP list + { + configuredIPs: []string{"10.0.0.0", "10.0.0.1", "10.0.2.0/32"}, + clientIP: "10.0.0.0", + statusCode: http.StatusOK, + permission: authz.Admin, + }, + // client IP is in configured IP list, testing with CIDR address + { + configuredIPs: []string{"10.0.0.0/30"}, + clientIP: "10.0.0.1", + statusCode: http.StatusOK, + permission: authz.Write, + }, + // client IP has multiple IPs in X-Forwarded-For header + // originating IP is in configured IP list + { + configuredIPs: []string{"10.0.0.0", "10.0.0.1/32", "10.0.0.2"}, + clientIP: "10.0.0.2,10.0.0.255", + statusCode: http.StatusOK, + permission: authz.Read, + }, + // client IP has multiple IPs in X-Forwarded-For header + // originating IP is not in configured IP list + { + configuredIPs: []string{"10.0.0.0/30"}, + clientIP: "10.0.0.255,10.0.0.2", + statusCode: http.StatusForbidden, + permission: authz.Read, + }, + // client IP is not in configured IP list + { + configuredIPs: []string{"10.0.0.0", "10.0.0.1", "10.0.2.0/32"}, + clientIP: "10.0.0.3", + statusCode: http.StatusForbidden, + permission: authz.Write, + }, + // client IP is not in configured IP list + // X-Forwarded-For header is an empty string + { + configuredIPs: []string{"10.0.0.0", "10.0.0.1", "10.0.2.0/32"}, + clientIP: "", + statusCode: http.StatusForbidden, + permission: authz.Write, + }, + } + + testingHandler := func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("good")) + } + + permissions1 := `"user-groups": + "dca35310-ecda-4f23-86cd-876aee559900": + "test": "write" +admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` + + for i, test := range tests { + t.Run(fmt.Sprintf("ChkAuthz-%d", i), func(t *testing.T) { + ConfiguredIPs = test.configuredIPs + a := NewTestAuth(t) + + h := Handler{ + logger: logger.NewStandardLogger(os.Stdout), + queryLogger: logger.NewStandardLogger(os.Stdout), + auth: a, + } + + var p authz.GroupPermissions + if err := p.ReadPermissionsFile(strings.NewReader(permissions1)); err != nil { + t.Errorf("Error: %s", err) + } + h.permissions = &p + + r := httptest.NewRequest("GET", "/index/authz-abcd", nil) + r.Header.Set(ForwardedIPHeader, test.clientIP) + w := httptest.NewRecorder() + + handler := h.chkAuthZ(testingHandler, authz.Read) + handler(w, r) + resp := w.Result() + if resp.StatusCode != test.statusCode { + t.Fatalf("expected %v, got %v", test.statusCode, resp.StatusCode) + } + }) + } +} + +func TestAuthnAllowedIPs(t *testing.T) { + IPList := []string{"10.0.0.0", "10.0.0.1", "10.0.0.2"} + ValidForwardedIP := "10.0.0.0, 10.0.0.3, 10.0.0.4" + InvalidForwardedIP := "10.0.0.3, 10.0.0.4" + + tests := []struct { + configuredIPs []string + clientIP string + statusCode int + secretKey string + }{ + // test client IP was in configured IP list - happy path + { + configuredIPs: IPList, + clientIP: IPList[0], + statusCode: http.StatusOK, + }, + // test empty configured IP list + { + configuredIPs: []string{""}, + clientIP: IPList[0], + statusCode: http.StatusUnauthorized, + }, + // test client IP is not in configured IP list + { + configuredIPs: IPList, + clientIP: "10.0.0.4", + statusCode: http.StatusUnauthorized, + }, + // test multiple client IPs in X-forwarded-IP + // originating IP is in configured IP list + { + configuredIPs: IPList, + clientIP: ValidForwardedIP, + statusCode: http.StatusOK, + }, + // test multiple client IPs in X-forwarded-IP + // originating IP is not in configured IP list + { + configuredIPs: IPList, + clientIP: InvalidForwardedIP, + statusCode: http.StatusUnauthorized, + }, + } + + testingHandler := func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("good")) + } + + for i, test := range tests { + t.Run(fmt.Sprintf("ChkAuthn-%d", i), func(t *testing.T) { + ConfiguredIPs = test.configuredIPs + a := NewTestAuth(t) + + h := Handler{ + logger: logger.NewStandardLogger(os.Stdout), + queryLogger: logger.NewStandardLogger(os.Stdout), + auth: a, + } + + r := httptest.NewRequest("GET", "/index/authn-abcd", nil) + r.Header.Set(ForwardedIPHeader, test.clientIP) + r = r.WithContext(context.Background()) + w := httptest.NewRecorder() + + handler := h.chkAuthN(testingHandler) + handler(w, r) + resp := w.Result() + if resp.StatusCode != test.statusCode { + t.Fatalf("expected %v, got %v", test.statusCode, resp.StatusCode) + } + }) + } +} diff --git a/http_handler_test.go b/http_handler_test.go index b246b6d69..7f720ab60 100644 --- a/http_handler_test.go +++ b/http_handler_test.go @@ -5,8 +5,12 @@ import ( "context" "encoding/json" "fmt" + "io/ioutil" "net" + "net/http" gohttp "net/http" + "os" + "path" "reflect" "sort" "strings" @@ -469,3 +473,195 @@ func TestTranslationHandlers(t *testing.T) { } } } + +func TestAuthAllowedNetworks(t *testing.T) { + permissions1 := ` +"user-groups": + "dca35310-ecda-4f23-86cd-876aee55906b": + "test": "read" +admin: "ac97c9e2-346b-42a2-b6da-18bcb61a32fe"` + + tmpDir := t.TempDir() + permissionsPath := path.Join(tmpDir, "test-permissions.yaml") + err := ioutil.WriteFile(permissionsPath, []byte(permissions1), 0666) + if err != nil { + t.Fatalf("failed to write permissions file: %v", err) + } + + queryLogPath := path.Join(tmpDir, "query.log") + _, err = os.Create(queryLogPath) + if err != nil { + t.Fatal(err) + } + + validIP := "10.0.0.2" + + clusterSize := 3 + commandOpts := make([][]server.CommandOption, clusterSize) + configs := make([]*server.Config, clusterSize) + for i := range configs { + conf := server.NewConfig() + configs[i] = conf + conf.TLS.CertificatePath = "./testdata/certs/localhost.crt" + conf.TLS.CertificateKeyPath = "./testdata/certs/localhost.key" + conf.Auth.Enable = true + conf.Auth.ClientId = "e9088663-eb08-41d7-8f65-efb5f54bbb71" + conf.Auth.ClientSecret = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" + conf.Auth.AuthorizeURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize" + conf.Auth.TokenURL = "https://login.microsoftonline.com/4a137d66-d161-4ae4-b1e6-07e9920874b8/oauth2/v2.0/authorize" + conf.Auth.GroupEndpointURL = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?$count=true" + conf.Auth.LogoutURL = "https://login.microsoftonline.com/common/oauth2/v2.0/logout" + conf.Auth.RedirectBaseURL = "https://localhost:10101/" + conf.Auth.QueryLogPath = queryLogPath + conf.Auth.SecretKey = "DEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF" + conf.Auth.PermissionsFile = permissionsPath + conf.Auth.Scopes = []string{"https://graph.microsoft.com/.default", "offline_access"} + conf.Auth.ConfiguredIPs = []string{validIP} + commandOpts[i] = append(commandOpts[i], server.OptCommandConfig(conf)) + } + + c := test.MustRunCluster(t, clusterSize, commandOpts...) + defer c.Close() + + m := c.GetPrimary() + index := "allowed-networks-index" + keyedIndex := "allowed-networks-index-keyed" + field := "field1" + + // needed for key translation + nameBytes, err := json.Marshal([]string{"a", "b", "c"}) + if err != nil { + t.Fatalf("marshalling json: %v", err) + } + names := string(nameBytes) + + schema := ` + { + "index-name": "allowed-networks-index-keyed", + "primary-key-type": "string", + "index-action": "create", + "fields": [ + { + "field-name": "stringset", + "field-type": "string", + "field-options": { + "cache-type": "ranked", + "cache-size": 100000 + } + } + ] + } + ` + + IPTests := []struct { + TestName string + ClientIP string + StatusCode int + }{ + {TestName: "ValidIP", ClientIP: validIP, StatusCode: http.StatusOK}, + {TestName: "InvalidIP", ClientIP: "10.0.1.1", StatusCode: http.StatusForbidden}, + } + + tests := []struct { + testName string + method string + url string + body string + }{ + { + testName: "Post-Index", + method: "POST", + url: fmt.Sprintf("%s/index/%s", m.URL(), index), + body: "", + }, + { + testName: "Post-field", + method: "POST", + url: fmt.Sprintf("%s/index/%s/field/%s", m.URL(), index, field), + body: "", + }, + { + testName: "Get-Schema", + method: "GET", + url: fmt.Sprintf("%s/schema", m.URL()), + body: "", + }, + { + testName: "Post-Schema", + method: "POST", + url: fmt.Sprintf("%s/internal/schema", m.URL()), + body: schema, + }, + { + testName: "Get-Shards", + method: "GET", + url: fmt.Sprintf("%s/internal/index/%s/shards", m.URL(), keyedIndex), + body: "", + }, + { + testName: "Post-CreateKeys", + method: "POST", + url: fmt.Sprintf("%s/internal/translate/index/%s/keys/create", m.URL(), keyedIndex), + body: names, + }, + { + testName: "Get-MemoryUsage", + method: "GET", + url: fmt.Sprintf("%s/internal/mem-usage", m.URL()), + body: "", + }, + { + testName: "Get-Status", + method: "GET", + url: fmt.Sprintf("%s/status", m.URL()), + body: "", + }, + } + + for _, ipTest := range IPTests { + for _, test := range tests { + t.Run(ipTest.TestName+"-"+test.testName, func(t *testing.T) { + var req *gohttp.Request + if test.body != "" { + req, err = gohttp.NewRequest(test.method, test.url, strings.NewReader(test.body)) + if err != nil { + t.Fatal(err) + } + } else { + req, err = gohttp.NewRequest(test.method, test.url, nil) + if err != nil { + t.Fatal(err) + } + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", ipTest.ClientIP) + resp, err := gohttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("failed to send request: %v", err) + } + if resp.StatusCode != ipTest.StatusCode { + t.Fatalf("expected %v response code, got %v, body: %v", ipTest.StatusCode, resp.StatusCode, resp.Body) + } + + if ipTest.StatusCode == 200 { + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatalf("reading resp body :%v", err) + } + + if test.testName == "Post-CreateKeys" { + var results map[string]uint64 + err = json.Unmarshal([]byte(body), &results) + if err != nil { + t.Fatalf("unmarshalling result: %v", err) + } + if len(results) != 3 { + t.Fatalf("finding keys before any were set: expected 3 results, got %d (%q)", len(results), results) + } + } + } + }) + } + } +} diff --git a/internal/clustertests/cluster_test.go b/internal/clustertests/cluster_test.go index 5c69299ff..604161cc4 100644 --- a/internal/clustertests/cluster_test.go +++ b/internal/clustertests/cluster_test.go @@ -64,6 +64,7 @@ func GetAuthToken(t *testing.T) string { ClientID, ClientSecret, Key, + []string{}, ) if err != nil { t.Fatalf("NewAuth: %v", err) diff --git a/internal_client.go b/internal_client.go index a4396282c..c4431de64 100644 --- a/internal_client.go +++ b/internal_client.go @@ -154,6 +154,12 @@ func AddAuthToken(ctx context.Context, req *http.Request) *http.Request { // UserInfo.Token is not prefixed with "Bearer" req.Header.Set("Authorization", "Bearer "+uinfo.(*authn.UserInfo).Token) } + + // set ogIP to request for remote calls + if ogIP, ok := ctx.Value(OriginalIPHeader).(string); ok && ogIP != "" { + req.Header.Set(OriginalIPHeader, ogIP) + } + return req } diff --git a/internal_client_test.go b/internal_client_test.go index 73fe9f07a..0eaae9ce4 100644 --- a/internal_client_test.go +++ b/internal_client_test.go @@ -1603,4 +1603,15 @@ func TestAddAuthToken(t *testing.T) { t.Fatalf("got '%v', expected '%v'", got, tok) } }) + t.Run("originalIP", func(t *testing.T) { + req, err := gohttp.NewRequest("GET", "dontmatternone", strings.NewReader("this doesn't matter")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + ogIP := "10.0.0.1" + pilosa.AddAuthToken(context.WithValue(context.Background(), pilosa.OriginalIPHeader, ogIP), req) + if got := req.Header.Get(pilosa.OriginalIPHeader); got != ogIP { + t.Fatalf("got '%v', expected '%v'", got, ogIP) + } + }) } diff --git a/qa/scripts/auth-smoke/tests/sup218-test.sh b/qa/scripts/auth-smoke/tests/sup218-test.sh index 0a9cd011e..d09a0fce4 100755 --- a/qa/scripts/auth-smoke/tests/sup218-test.sh +++ b/qa/scripts/auth-smoke/tests/sup218-test.sh @@ -16,9 +16,8 @@ done HOST=${HOSTS[2]} -ADMIN_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiYWRtaW4ifQ.I1iCgk1VU7m6e-En4ACTHIs6V2dZpy_8j2blSSo7K3U" # ingest string key data to user index -/data/datagen --source custom --custom-config /data/tests/sup218_datagen.yaml --pilosa.index=user --pilosa.hosts=https://$HOST:10101 --pilosa.batch-size=1000 --auth-token=$ADMIN_TOKEN +/data/datagen --source custom --custom-config /data/tests/sup218_datagen.yaml --pilosa.index=user --pilosa.hosts=https://$HOST:10101 --pilosa.batch-size=1000 ifErr "running datagen on $HOST" # install grpcurl diff --git a/qa/scripts/utilCluster.sh b/qa/scripts/utilCluster.sh index 062cc1e75..90956b3b3 100755 --- a/qa/scripts/utilCluster.sh +++ b/qa/scripts/utilCluster.sh @@ -128,6 +128,7 @@ long-query-time = "10s" secret-key = "98995f0530eeba96da1d0a04311073c0abb7b6abbfb0f5f4ef3629527ff88428" permissions = "/etc/permissions.yml" query-log-path = "/data/featurebase/query.log" + configured-ips = ["${DEPLOYED_INGEST_IPS}"] EOT echo "writing the permissions file" diff --git a/server/config.go b/server/config.go index 718ec0edb..caeb5314b 100644 --- a/server/config.go +++ b/server/config.go @@ -254,6 +254,7 @@ type Auth struct { SecretKey string `toml:"secret-key"` PermissionsFile string `toml:"permissions"` QueryLogPath string `toml:"query-log-path"` + ConfiguredIPs []string `toml:"configured-ips"` } // Namespace returns the namespace to use based on the Future flag. @@ -663,6 +664,39 @@ func (c *Config) ValidateAuth() (errors []error) { errors = append(errors, fmt.Errorf("must provide scope for authentication with IdP - for access and refresh token")) } + if len(c.Auth.ConfiguredIPs) > 0 { + for _, IP := range c.Auth.ConfiguredIPs { + if strings.Contains(IP, ":") { + errors = append(errors, fmt.Errorf("port is not allowed in IP %v for auth.configured-ips", IP)) + continue + } + if IP == "" { + errors = append(errors, fmt.Errorf("empty string for auth.configured-ips")) + continue + } + if strings.Contains(IP, "localhost") { + errors = append(errors, fmt.Errorf("%v is not a valid IP for auth.configured-ips, DNS names are not allowed", IP)) + continue + } + if strings.EqualFold(IP, "0.0.0.0") { + errors = append(errors, fmt.Errorf("%v is not a valid IP for auth.configured-ips", IP)) + continue + } + // validate CIDR addresses + if strings.Contains(IP, "/") { + if _, _, err := net.ParseCIDR(IP); err != nil { + errors = append(errors, fmt.Errorf("%v is not a valid IP for auth.configured-ips: %v", IP, err)) + continue + } + continue + } + // validate IP + if net.ParseIP(IP) == nil { + errors = append(errors, fmt.Errorf("%v is not a valid IP for auth.configured-ips", IP)) + continue + } + } + } return errors } diff --git a/server/config_internal_test.go b/server/config_internal_test.go index 1c377c4a1..2dd044497 100644 --- a/server/config_internal_test.go +++ b/server/config_internal_test.go @@ -281,6 +281,8 @@ func TestConfig_validateAuth(t *testing.T) { errorMesgURL := "invalid URL" errorMesgScope := "must provide scope" errorMesgKey := "invalid key length" + errorMesgIP := "not a valid IP for auth.configured-ips" + errorMesgPort := "port is not allowed in IP" validTestURL := "https://url.com/" validClientID := "clientid" validClientSecret := "clientSecret" @@ -290,6 +292,10 @@ func TestConfig_validateAuth(t *testing.T) { validStringSlice := []string{"https://graph.microsoft.com/.default", "offline_access"} validString := "asdfqwer1234asdfzxcv" var emptySlice []string + validIPList := []string{"10.0.0.1", "10.0.0.0", "10.0.0.0/32"} + invalidIPList := []string{"0.0.0.0", "localhost", "100.0.1", "100.0.0/32"} + emptyStringIP := []string{""} + portIP := []string{"10.0.0.1:10101"} enable := true disable := false @@ -321,6 +327,7 @@ func TestConfig_validateAuth(t *testing.T) { LogoutURL: emptyString, Scopes: validStringSlice, SecretKey: emptyString, + ConfiguredIPs: emptySlice, }, }, { @@ -339,6 +346,7 @@ func TestConfig_validateAuth(t *testing.T) { LogoutURL: validTestURL, Scopes: validStringSlice, SecretKey: validString, + ConfiguredIPs: validIPList, }, }, { @@ -359,6 +367,7 @@ func TestConfig_validateAuth(t *testing.T) { LogoutURL: invalidURL, Scopes: validStringSlice, SecretKey: validKey, + ConfiguredIPs: validIPList, }, }, { @@ -377,6 +386,7 @@ func TestConfig_validateAuth(t *testing.T) { LogoutURL: validTestURL, Scopes: emptySlice, SecretKey: validKey, + ConfiguredIPs: validIPList, }, }, { @@ -394,6 +404,7 @@ func TestConfig_validateAuth(t *testing.T) { Scopes: validStringSlice, SecretKey: validKey, QueryLogPath: "thisIsAPAth", + ConfiguredIPs: validIPList, }, }, { @@ -410,6 +421,67 @@ func TestConfig_validateAuth(t *testing.T) { LogoutURL: validTestURL, Scopes: validStringSlice, SecretKey: emptyString, + ConfiguredIPs: emptySlice, + }, + }, + { + // Auth enabled, all configs are set properly except configuredIPs + []string{ + errorMesgIP, + errorMesgIP, + errorMesgIP, + errorMesgIP, + }, + Auth{ + Enable: enable, + ClientId: validClientID, + ClientSecret: validClientSecret, + AuthorizeURL: validTestURL, + TokenURL: validTestURL, + GroupEndpointURL: validTestURL, + RedirectBaseURL: validTestURL, + LogoutURL: validTestURL, + Scopes: validStringSlice, + SecretKey: validKey, + ConfiguredIPs: invalidIPList, + }, + }, + { + // Auth enabled, all configs are set properly except configuredIPs + []string{ + errorMesgEmpty, + }, + Auth{ + Enable: enable, + ClientId: validClientID, + ClientSecret: validClientSecret, + AuthorizeURL: validTestURL, + TokenURL: validTestURL, + GroupEndpointURL: validTestURL, + RedirectBaseURL: validTestURL, + LogoutURL: validTestURL, + Scopes: validStringSlice, + SecretKey: validKey, + ConfiguredIPs: emptyStringIP, + }, + }, + { + // Auth enabled, all configs are set properly except configuredIPs + []string{ + errorMesgPort, + }, + Auth{ + Enable: enable, + ClientId: validClientID, + ClientSecret: validClientSecret, + AuthorizeURL: validTestURL, + TokenURL: validTestURL, + GroupEndpointURL: validTestURL, + RedirectBaseURL: validTestURL, + LogoutURL: validTestURL, + Scopes: validStringSlice, + SecretKey: validKey, + ConfiguredIPs: portIP, }, }, } diff --git a/server/server.go b/server/server.go index db75c491b..d16e683f3 100644 --- a/server/server.go +++ b/server/server.go @@ -531,7 +531,7 @@ func (m *Command) SetupServer() error { } ac := m.Config.Auth - m.auth, err = authn.NewAuth(m.logger, ac.RedirectBaseURL, ac.Scopes, ac.AuthorizeURL, ac.TokenURL, ac.GroupEndpointURL, ac.LogoutURL, ac.ClientId, ac.ClientSecret, ac.SecretKey) + m.auth, err = authn.NewAuth(m.logger, ac.RedirectBaseURL, ac.Scopes, ac.AuthorizeURL, ac.TokenURL, ac.GroupEndpointURL, ac.LogoutURL, ac.ClientId, ac.ClientSecret, ac.SecretKey, ac.ConfiguredIPs) if err != nil { return errors.Wrap(err, "instantiating authN object") } @@ -544,6 +544,9 @@ func (m *Command) SetupServer() error { m.queryLogger.Infof("Starting Featurebase...") m.queryLogger.Infof("Group with admin level access: %v", p.Admin) m.queryLogger.Infof("Permissions: %+v", p.Permissions) + if len(ac.ConfiguredIPs) > 0 { + m.queryLogger.Infof("Configured IPs for allowed networks: %v", ac.ConfiguredIPs) + } // disable postgres binding if auth is enabled m.Config.Postgres.Bind = "" diff --git a/testdata/certs/localhost.crt b/testdata/certs/localhost.crt new file mode 100644 index 000000000..52d4c59ba --- /dev/null +++ b/testdata/certs/localhost.crt @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE----- +MIIEcDCCAtigAwIBAgIQUcwmfB3r1kcWpRm+pJCNQTANBgkqhkiG9w0BAQsFADCB +mTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTcwNQYDVQQLDC5zb3Vo +YWlsYW5vb3JAc291aGFpbGFzLW1icC5sYW4gKFNvdWhhaWxhIE5vb3IpMT4wPAYD +VQQDDDVta2NlcnQgc291aGFpbGFub29yQHNvdWhhaWxhcy1tYnAubGFuIChTb3Vo +YWlsYSBOb29yKTAeFw0yMjAyMDMxNzAxNTRaFw0yNDA1MDMxNjAxNTRaMGIxJzAl +BgNVBAoTHm1rY2VydCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTE3MDUGA1UECwwu +c291aGFpbGFub29yQHNvdWhhaWxhcy1tYnAubGFuIChTb3VoYWlsYSBOb29yKTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKlW+aTutSSDraStNa3T55eH +qR0F1Fxap7Zefd3TQuhNfZc7x9pyT8Rfd8n1fiL+NVAsrGWCX4kqrq5J3comPEZG +zphoWBlhko9jWWa375xTEp4Bo8lQQ1Z0GgucVk8XkmPPO7z6ZuMb2+LBtY1IkXcP +1nr0m/F1isQw2R5V51eBuNv44h0+CN+f706u5zsBrY4+wXS3l/hXzTEYAN/qryEi +xjTsvlURnZHEKNtC1QPOGJMe/Viqv3HW9vLs9r5b9+9R7mt3nSWXLHJWe5e8P3xY +s1aRWWWyd4F6q+0NYdlGN9SjN3MjaVuXCL76vvGaWmRAlYXm7y/+fIB8OIS/aV8C +AwEAAaNqMGgwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8G +A1UdIwQYMBaAFJzPC26j5NzKZj55kwlZB9ucEdk8MCAGA1UdEQQZMBeCCWxvY2Fs +aG9zdIcEAAAAAIcEwKhWLzANBgkqhkiG9w0BAQsFAAOCAYEAdTR/I1ONyQe/KKYh +R5pEWnAS7gGIW7EHkNoDhm/fv2+yplM7jpI7F6zBFIKIchSEWVSndnEerZ8lTcgG +27fuvmR4B0EpLQ3CK5jWNMMDEgSpX5pRMn6ZOuhRDglt0/+knsxra3rbUORfrl+w +FNAB7e1Q/q4eKVqxAiW0wpQEBnw3ko2/r2t1/x1diReDlb7NFztfwAxXyQKQbQvY +/V6r514cm7s4qqnEudaK8LR8G5wrTBJgRdwzkpiBsi0Kekibp2FUQcRQeUvPqgvn +c2VmJGVlWNJj5fhQO3Tn3NGLECA6jJFWVCMBHNP/DJtZjJQQhXyn4WC5r0g/aFwm +iDX2djhj43GyMT7V1fTirJpWQC4zOMIV54nZ90BULJD76+hmLrBccdZPcJHhpPw/ +WGvhV+IEjxLvr/RPgcZitFwYKKEMgQcGRl6oLo71hD99cEQXhM5bgv7uvYvX4XqG +W1S7Qst6xEkZ5nLMv0dLpd7SRCUXuIyNBUMkuAW7c2NY21lC +-----END CERTIFICATE----- diff --git a/testdata/certs/localhost.key b/testdata/certs/localhost.key new file mode 100644 index 000000000..dd828764a --- /dev/null +++ b/testdata/certs/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCpVvmk7rUkg62k +rTWt0+eXh6kdBdRcWqe2Xn3d00LoTX2XO8fack/EX3fJ9X4i/jVQLKxlgl+JKq6u +Sd3KJjxGRs6YaFgZYZKPY1lmt++cUxKeAaPJUENWdBoLnFZPF5Jjzzu8+mbjG9vi +wbWNSJF3D9Z69JvxdYrEMNkeVedXgbjb+OIdPgjfn+9Oruc7Aa2OPsF0t5f4V80x +GADf6q8hIsY07L5VEZ2RxCjbQtUDzhiTHv1Yqr9x1vby7Pa+W/fvUe5rd50llyxy +VnuXvD98WLNWkVllsneBeqvtDWHZRjfUozdzI2lblwi++r7xmlpkQJWF5u8v/nyA +fDiEv2lfAgMBAAECggEAWxRFp5kgYqhUT9s9oOX1jUHRCqQlCRtVUzhVeGb1eJ6d +KydjIbIUBynV8xEQK+AQarPscZiCq/MCCwn9uYmBJ8dTvRN6NwSM4IRuBFpwDON9 +gvCc2F1MKoa9r3sBzP5/pSFEHyuLBSHpzXekn33lh2VEVdOUwHUZEta3IKLRj/V8 +eFNGLFGaRkTuVnjJpCySy89ANUxH7wXyz9g7S6WIvU0Gri6hh5JV5DZ0BYQWqrXs +obipibRiTtSjBbV3uRABlA6le8ffWR9VsiS1njNRiXDvTl/zZKQjOCQxYJ4hbKHK +BYZuz5mXxQSmZoLkFd3ttIQ98zGJGVR5HI20kt97UQKBgQDHPlB/38QUiLSRLWc0 +dDziXIAlk5X0IkYDdSj0mMi5OaSOVjtCCtzQO8ncuFDJv/bjhFCrNsaLhDrSMP9w +q+X5C6IyFfZoxhFm0Yjf87szJ4Gj3LKwL33y62+4a+FoL8e2rKU2BNhC/hs+hLv+ +GIg0WPUD6ApQqEzZiEy9sPwrlwKBgQDZk/TtmSO2vSsV4sUSdFYiKU/0ngsFw1yh +aAOQV5km3rqQP0bOmHvVmp8ZWWg0mRecEsih344kB1s3g60m6dfTYsfX+PCn4LoQ +bB/b3VfJmq5mHeicydI9vpggHOo4HXRvDFjuXcDuq8WCflR+RfWPDRMtr+fnOtYz +XzT/mv2JeQKBgDOUWDaicRp3wXcL7/nOVaysEaioqltHPCTNActAekYpPAZ1IGYa +dcuajsmLFa8E+R8xM8j/JysbEjcz5A0BE0oDzvt0YBQDoqGhgPOpHz9A7PjEu6WM +xehLNuLhWrskE2mhDCwN2QaOzfLDXf+Lzkbu+I8IVRNIXoy6ElPRK5cFAoGAUcy1 +dapwaeB+1VCXZNPGGjvL6pyMalNDfQ8838R/OMTeASM4/K0JSYpDLGWXrsarwKqz +MWB58QOvAJUJwSqDUN6/YWwfFg6ABXKRG7kAqzPzQ0MVU/TwjPQd/1y/le9E+gb7 +XEp1IYYE0IWsQHkO1ARHZJLQrfdJ1rCBnWNo1IkCgYEAtqgGtOgYb169TjftqDZD +7DdFUI6sweRfeRs91OGbLRpHU7LGia6GSm+CA8newUMJQtgT8EOR+OyDJBZTL1CS +dkk0bmW3Z+C0zBSKhNlEKE5Jg6rDJ2fpC87lWN+6ZbNOZbjiMkV4gPlVFy8fkSCn +5fSf7UT9/FFCrqKPFHPThXE= +-----END PRIVATE KEY-----