diff --git a/frontend/const.go b/frontend/const.go index d5ee2713f..1af0d7124 100644 --- a/frontend/const.go +++ b/frontend/const.go @@ -8,10 +8,13 @@ const ( APIVersionKey = "api-version" // Wildcard path segment names for request multiplexing, must be lowercase as we lowercase the request URL pattern when registering handlers - PageSegmentLocation = "location" - PathSegmentSubscriptionID = "subscriptionid" - PathSegmentResourceGroupName = "resourcegroupname" - PathSegmentResourceName = "resourcename" - PathSegmentDeploymentName = "deploymentname" - PathSegmentActionName = "actionname" + PageSegmentLocation = "location" + PathSegmentSubscriptionID = "subscriptionid" + PathSegmentResourceGroupName = "resourcegroupname" + PathSegmentResourceName = "resourcename" + PathSegmentDeploymentName = "deploymentname" + PathSegmentActionName = "actionname" + PathSegmentOperationID = "operationId" + PathSegmentResourceType = "resourceType" + PathSegmentResourceProviderNamespace = "resourceProviderNamespace" ) diff --git a/frontend/frontend.go b/frontend/frontend.go index 1105b7f31..cc08e7e77 100644 --- a/frontend/frontend.go +++ b/frontend/frontend.go @@ -75,6 +75,7 @@ func NewFrontend(logger *slog.Logger, listener net.Listener, emitter metrics.Emi MiddlewareBody, MiddlewareLowercase, MiddlewareSystemData, + MiddlewareValidateStatic, metricsMiddleware.Metrics(), ) diff --git a/frontend/go.mod b/frontend/go.mod index 64be95ecd..0a91d507a 100644 --- a/frontend/go.mod +++ b/frontend/go.mod @@ -22,7 +22,7 @@ require ( github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.1 github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.19.0 // indirect diff --git a/frontend/middleware_validatestatic.go b/frontend/middleware_validatestatic.go new file mode 100644 index 000000000..1af6e10fa --- /dev/null +++ b/frontend/middleware_validatestatic.go @@ -0,0 +1,77 @@ +package main + +// Copyright (c) Microsoft Corporation. +// Licensed under the Apache License 2.0. + +import ( + "errors" + "net/http" + "regexp" + "strings" + + "github.com/go-logr/logr" + + "github.com/Azure/ARO-HCP/internal/api" + "github.com/Azure/ARO-HCP/internal/api/arm" +) + +// Referenced in https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/resource-name-rules#microsoftresources +var rxResourceGroupName = regexp.MustCompile(`^[-a-z0-9_().]{0,89}[-a-z0-9_()]$`) +var rxResourceName = regexp.MustCompile(`^[a-zA-Z0-9-]{3,24}$`) + +func MiddlewareValidateStatic(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + + subId := r.PathValue(PathSegmentSubscriptionID) + resourceGroupName := r.PathValue(PathSegmentResourceGroupName) + resourceProviderNamespace := r.PathValue(PathSegmentResourceProviderNamespace) + resourceType := r.PathValue(PathSegmentResourceType) + operationId := r.PathValue(PathSegmentOperationID) + resourceName := r.PathValue(PathSegmentResourceName) + + if r.URL.Path != strings.ToLower(r.URL.Path) { + if log, ok := r.Context().Value(LoggerFromContext).(logr.Logger); ok { + log.Error(errors.New("LowerCase error"), "path was not lower case") + } + arm.WriteInternalServerError(w) + return + } + + if subId != "" { + valid := api.IsValid(subId) + if !valid { + arm.WriteError(w, http.StatusBadRequest, arm.CloudErrorCodeInvalidSubscriptionID, "", "The provided subscription identifier '%s' is malformed or invalid.", subId) + return + } + } + + if resourceGroupName != "" { + if !rxResourceGroupName.MatchString(resourceGroupName) { + arm.WriteError(w, http.StatusBadRequest, arm.CloudErrorCodeResourceGroupNotFound, "", "Resource group '%s' is invalid.", resourceGroupName) + return + } + } + + if resourceProviderNamespace != "" { + if resourceProviderNamespace != strings.ToLower(api.ProviderNamespace) { + arm.WriteError(w, http.StatusBadRequest, arm.CloudErrorCodeInvalidResourceNamespace, "", "The resource namespace '%s' is invalid.", resourceProviderNamespace) + return + } + } + + if resourceName != "" { + if !rxResourceName.MatchString(resourceName) { + arm.WriteError(w, http.StatusBadRequest, arm.CloudErrorCodeResourceNotFound, "", "The Resource '%s/%s/%s' under resource group '%s' is invalid.", resourceProviderNamespace, resourceType, resourceName, resourceGroupName) + return + } + } + + if operationId != "" { + valid := api.IsValid(operationId) + if !valid { + arm.WriteError(w, http.StatusBadRequest, arm.CloudErrorCodeInvalidOperationID, "", "The provided operation identifier '%s' is malformed or invalid.", operationId) + return + } + } + + next(w, r) +} diff --git a/frontend/middleware_validatestatic_test.go b/frontend/middleware_validatestatic_test.go new file mode 100644 index 000000000..40b45c96d --- /dev/null +++ b/frontend/middleware_validatestatic_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/Azure/ARO-HCP/internal/api/arm" +) + +type CloudErrorContainer struct { + Error arm.CloudErrorBody `json:"error"` +} + +func TestMiddlewareValidateStatic(t *testing.T) { + // This will act as the next handler if middleware validation passes + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) // indicate success + }) + + tests := []struct { + name string + path string + subscriptionID string + resourceGroupName string + resourceProviderNamespace string + resourceType string + resourceName string + operationsId string + expectedStatusCode int + expectedBody string + }{ + { + name: "Valid request", + path: "/subscriptions/42d9eac4-d29a-4d6e-9e26-3439758b1491", + subscriptionID: "42d9eac4-d29a-4d6e-9e26-3439758b1491", + expectedStatusCode: http.StatusOK, + }, + { + name: "Invalid subscription ID", + path: "/subscriptions/invalid!sub!id", + subscriptionID: "invalid!sub!id", + expectedStatusCode: http.StatusBadRequest, + expectedBody: "The provided subscription identifier 'invalid!sub!id' is malformed or invalid.", + }, + { + name: "Invalid resource group name", + path: "/resourcegroups/resourcegroup!", + resourceGroupName: "resourcegroup!", + expectedStatusCode: http.StatusBadRequest, + expectedBody: "Resource group 'resourcegroup!' is invalid.", + }, + { + name: "Invalid resource provider namespace", + path: "/providers/invalid", + resourceProviderNamespace: "invalid", + expectedStatusCode: http.StatusBadRequest, + expectedBody: "The resource namespace 'invalid' is invalid.", + }, + { + name: "Invalid resource name", + path: "/resourcegroup/providers/microsoft.redhatopenshift/hcpopenshiftcluster/$", + resourceGroupName: "resourcegroup", + resourceProviderNamespace: "microsoft.redhatopenshift", + resourceType: "hcpopenshiftcluster", + resourceName: "$", + expectedStatusCode: http.StatusBadRequest, + expectedBody: "The Resource 'microsoft.redhatopenshift/hcpopenshiftcluster/$' under resource group 'resourcegroup' is invalid.", + }, + { + name: "Invalid operation id", + path: "/operations/abc", + operationsId: "abc", + expectedStatusCode: http.StatusBadRequest, + expectedBody: "The provided operation identifier 'abc' is malformed or invalid.", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "http://example.com"+tc.path, nil) + + // Use httptest.ResponseRecorder to record the response + w := httptest.NewRecorder() + + req.SetPathValue(PathSegmentSubscriptionID, tc.subscriptionID) + req.SetPathValue(PathSegmentResourceGroupName, tc.resourceGroupName) + req.SetPathValue(PathSegmentResourceProviderNamespace, tc.resourceProviderNamespace) + req.SetPathValue(PathSegmentResourceType, tc.resourceType) + req.SetPathValue(PathSegmentResourceName, tc.resourceName) + req.SetPathValue(PathSegmentOperationID, tc.operationsId) + + // Execute the middleware + MiddlewareValidateStatic(w, req, nextHandler) + + // Check the response status code + if status := w.Code; status != tc.expectedStatusCode { + t.Errorf("handler returned wrong status code: got %v want %v", + status, tc.expectedStatusCode) + } + + if tc.expectedStatusCode != http.StatusOK { + + var resp CloudErrorContainer + body, err := io.ReadAll(http.MaxBytesReader(w, w.Result().Body, 4*megabyte)) + if err != nil { + t.Fatalf("failed to read response body: %v", err) + } + err = json.Unmarshal(body, &resp) + if err != nil { + t.Fatalf("failed to unmarshal response body: %v", err) + } + + // Check if the error message contains the expected text + if !strings.Contains(resp.Error.Message, tc.expectedBody) { + t.Errorf("handler returned unexpected body: got %v want %v", + resp.Error.Message, tc.expectedBody) + } + } + }) + } +} diff --git a/go.work.sum b/go.work.sum index 2e318a58e..90886d4b0 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,3 +1,11 @@ +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.29/go.mod h1:ZtEzC4Jy2JDrZLxvWs8LrBWEBycl1hbT1eknI8MtfAs= +github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY= github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= @@ -25,6 +33,9 @@ github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/ github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= @@ -69,17 +80,40 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= github.com/yuin/goldmark v1.2.1 h1:ruQGxdhGHe7FWOJPT0mKs5+pD2Xs1Bm/kdGlHO04FmM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= diff --git a/internal/api/arm/error.go b/internal/api/arm/error.go index ec2073055..7dccbb0ac 100644 --- a/internal/api/arm/error.go +++ b/internal/api/arm/error.go @@ -11,15 +11,20 @@ import ( // CloudError codes const ( - CloudErrorCodeInternalServerError = "InternalServerError" - CloudErrorCodeInvalidParameter = "InvalidParameter" - CloudErrorCodeInvalidRequestContent = "InvalidRequestContent" - CloudErrorCodeInvalidResource = "InvalidResource" - CloudErrorCodeInvalidResourceType = "InvalidResourceType" - CloudErrorCodeMultipleErrorsOccurred = "MultipleErrorsOccurred" - CloudErrorCodeUnsupportedMediaType = "UnsupportedMediaType" - CloudErrorCodeNotFound = "NotFound" - CloudErrorInvalidSubscriptionState = "InvalidSubscriptionState" + CloudErrorCodeInternalServerError = "InternalServerError" + CloudErrorCodeInvalidParameter = "InvalidParameter" + CloudErrorCodeInvalidRequestContent = "InvalidRequestContent" + CloudErrorCodeInvalidResource = "InvalidResource" + CloudErrorCodeInvalidResourceType = "InvalidResourceType" + CloudErrorCodeMultipleErrorsOccurred = "MultipleErrorsOccurred" + CloudErrorCodeUnsupportedMediaType = "UnsupportedMediaType" + CloudErrorCodeNotFound = "NotFound" + CloudErrorInvalidSubscriptionState = "InvalidSubscriptionState" + CloudErrorCodeInvalidOperationID = "InvalidOperationID" + CloudErrorCodeResourceNotFound = "ResourceNotFound" + CloudErrorCodeInvalidResourceNamespace = "InvalidResourceNamespace" + CloudErrorCodeResourceGroupNotFound = "ResourceGroupNotFound" + CloudErrorCodeInvalidSubscriptionID = "InvalidSubscriptionID" ) // CloudError represents a complete resource provider error. diff --git a/internal/api/utils.go b/internal/api/utils.go index 0c5faac3d..e0fd4eab2 100644 --- a/internal/api/utils.go +++ b/internal/api/utils.go @@ -1,6 +1,10 @@ package api -import "slices" +import ( + "slices" + + uuid "github.com/google/uuid" +) // Copyright (c) Microsoft Corporation. // Licensed under the Apache License 2.0. @@ -33,3 +37,9 @@ func StringPtrSliceToStringSlice(s []*string) []string { } return out } + +// Check that a string conforms to the UUID format +func IsValid(u string) bool { + err := uuid.Validate(u) + return err == nil +}