diff --git a/frontend/frontend.go b/frontend/frontend.go index 256e54169..5b1b45103 100644 --- a/frontend/frontend.go +++ b/frontend/frontend.go @@ -80,6 +80,7 @@ func NewFrontend(logger *slog.Logger, listener net.Listener, emitter metrics.Emi MiddlewareBody, MiddlewareLowercase, MiddlewareSystemData, + MiddlewareValidateStatic, metricsMiddleware.Metrics(), ) diff --git a/frontend/middleware_validatestatic.go b/frontend/middleware_validatestatic.go new file mode 100644 index 000000000..a50bd8594 --- /dev/null +++ b/frontend/middleware_validatestatic.go @@ -0,0 +1,48 @@ +package main + +// Copyright (c) Microsoft Corporation. +// Licensed under the Apache License 2.0. + +import ( + "net/http" + "regexp" + + uuid "github.com/google/uuid" + + "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-zA-Z0-9_()-][a-zA-Z0-9_().-]{0,87}[a-zA-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) + resourceName := r.PathValue(PathSegmentResourceName) + + if subId != "" { + if uuid.Validate(subId) != nil { + 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.CloudErrorInvalidResourceGroupName, "", "Resource group '%s' is invalid.", resourceGroupName) + return + } + } + + if resourceName != "" { + if !rxResourceName.MatchString(resourceName) { + arm.WriteError(w, http.StatusBadRequest, arm.CloudErrorInvalidResourceName, "", "The Resource '%s/%s' under resource group '%s' is invalid.", api.ResourceType, resourceName, resourceGroupName) + return + } + } + + next(w, r) +} diff --git a/frontend/middleware_validatestatic_test.go b/frontend/middleware_validatestatic_test.go new file mode 100644 index 000000000..58c9094b7 --- /dev/null +++ b/frontend/middleware_validatestatic_test.go @@ -0,0 +1,107 @@ +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 + + 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 name", + path: "/resourcegroup/providers/microsoft.redhatopenshift/hcpopenshiftcluster/$", + resourceGroupName: "resourcegroup", + resourceType: "hcpOpenShiftClusters", + resourceName: "$", + expectedStatusCode: http.StatusBadRequest, + expectedBody: "The Resource 'Microsoft.RedHatOpenShift/hcpOpenShiftClusters/$' under resource group 'resourcegroup' is 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(PathSegmentResourceName, tc.resourceName) + + // 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..af51413ac 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,8 @@ 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/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 +79,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..04bb78157 100644 --- a/internal/api/arm/error.go +++ b/internal/api/arm/error.go @@ -20,6 +20,11 @@ const ( CloudErrorCodeUnsupportedMediaType = "UnsupportedMediaType" CloudErrorCodeNotFound = "NotFound" CloudErrorInvalidSubscriptionState = "InvalidSubscriptionState" + CloudErrorCodeResourceNotFound = "ResourceNotFound" + CloudErrorCodeResourceGroupNotFound = "ResourceGroupNotFound" + CloudErrorCodeInvalidSubscriptionID = "InvalidSubscriptionID" + CloudErrorInvalidResourceName = "InvalidResourceName" + CloudErrorInvalidResourceGroupName = "InvalidResourceGroupName" ) // CloudError represents a complete resource provider error. diff --git a/internal/api/utils.go b/internal/api/utils.go index 0c5faac3d..1010632ba 100644 --- a/internal/api/utils.go +++ b/internal/api/utils.go @@ -1,6 +1,8 @@ package api -import "slices" +import ( + "slices" +) // Copyright (c) Microsoft Corporation. // Licensed under the Apache License 2.0.