diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 5c634ca..46d957e 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -41,7 +41,7 @@ func (h *Handlers) HandleAppNew() http.HandlerFunc { } // HandleAppCreate handles app creation. -func (h *Handlers) HandleAppCreate() http.HandlerFunc { +func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // validation adds necessary length tmpl := templates.GetParsed() return func(writer http.ResponseWriter, request *http.Request) { @@ -71,6 +71,14 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { return } + nameErr := validateAppName(name) + if nameErr != nil { + data["Error"] = "Invalid app name: " + nameErr.Error() + _ = tmpl.ExecuteTemplate(writer, "app_new.html", data) + + return + } + if branch == "" { branch = "main" } @@ -194,7 +202,7 @@ func (h *Handlers) HandleAppEdit() http.HandlerFunc { } // HandleAppUpdate handles app updates. -func (h *Handlers) HandleAppUpdate() http.HandlerFunc { +func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // validation adds necessary length tmpl := templates.GetParsed() return func(writer http.ResponseWriter, request *http.Request) { @@ -214,7 +222,20 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { return } - application.Name = request.FormValue("name") + newName := request.FormValue("name") + + nameErr := validateAppName(newName) + if nameErr != nil { + data := h.addGlobals(map[string]any{ + "App": application, + "Error": "Invalid app name: " + nameErr.Error(), + }, request) + _ = tmpl.ExecuteTemplate(writer, "app_edit.html", data) + + return + } + + application.Name = newName application.RepoURL = request.FormValue("repo_url") application.Branch = request.FormValue("branch") application.DockerfilePath = request.FormValue("dockerfile_path") diff --git a/internal/handlers/app_name_validation.go b/internal/handlers/app_name_validation.go new file mode 100644 index 0000000..d1d1abb --- /dev/null +++ b/internal/handlers/app_name_validation.go @@ -0,0 +1,44 @@ +package handlers + +import ( + "errors" + "regexp" + "strconv" +) + +const ( + // appNameMinLength is the minimum allowed length for an app name. + appNameMinLength = 2 + // appNameMaxLength is the maximum allowed length for an app name. + appNameMaxLength = 63 +) + +// validAppNameRe matches names containing only lowercase alphanumeric characters and +// hyphens, starting and ending with an alphanumeric character. +var validAppNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*[a-z0-9]$`) + +// validateAppName checks that the given app name is safe for use in Docker +// container names, image tags, and file system paths. +var ( + errAppNameLength = errors.New( + "app name must be between " + + strconv.Itoa(appNameMinLength) + " and " + + strconv.Itoa(appNameMaxLength) + " characters", + ) + errAppNamePattern = errors.New( + "app name must contain only lowercase letters, numbers, " + + "and hyphens, and must start and end with a letter or number", + ) +) + +func validateAppName(name string) error { + if len(name) < appNameMinLength || len(name) > appNameMaxLength { + return errAppNameLength + } + + if !validAppNameRe.MatchString(name) { + return errAppNamePattern + } + + return nil +} diff --git a/internal/handlers/app_name_validation_test.go b/internal/handlers/app_name_validation_test.go new file mode 100644 index 0000000..2811116 --- /dev/null +++ b/internal/handlers/app_name_validation_test.go @@ -0,0 +1,48 @@ +package handlers //nolint:testpackage // testing unexported validateAppName + +import ( + "testing" +) + +func TestValidateAppName(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantErr bool + }{ + {"valid simple", "myapp", false}, + {"valid with hyphen", "my-app", false}, + {"valid with numbers", "app123", false}, + {"valid two chars", "ab", false}, + {"valid complex", "my-cool-app-v2", false}, + {"valid all numbers", "123", false}, + {"empty", "", true}, + {"single char", "a", true}, + {"too long", "a" + string(make([]byte, 63)), true}, + {"exactly 63 chars", "a23456789012345678901234567890123456789012345678901234567890123", false}, + {"64 chars", "a234567890123456789012345678901234567890123456789012345678901234", true}, + {"uppercase", "MyApp", true}, + {"spaces", "my app", true}, + {"starts with hyphen", "-myapp", true}, + {"ends with hyphen", "myapp-", true}, + {"underscore", "my_app", true}, + {"dot", "my.app", true}, + {"slash", "my/app", true}, + {"path traversal", "../etc/passwd", true}, + {"special chars", "app@name!", true}, + {"unicode", "appñame", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := validateAppName(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("validateAppName(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr) + } + }) + } +}