diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 398b512..047b6d7 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -216,6 +216,15 @@ func (h *Handlers) HandleAPICreateApp() http.HandlerFunc { return } + repoURLErr := validateRepoURL(req.RepoURL) + if repoURLErr != nil { + h.respondJSON(writer, request, + map[string]string{"error": "invalid repository URL: " + repoURLErr.Error()}, + http.StatusBadRequest) + + return + } + createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{ Name: req.Name, RepoURL: req.RepoURL, diff --git a/internal/handlers/app.go b/internal/handlers/app.go index cf60f47..cf86917 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -77,6 +77,14 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid return } + repoURLErr := validateRepoURL(repoURL) + if repoURLErr != nil { + data["Error"] = "Invalid repository URL: " + repoURLErr.Error() + h.renderTemplate(writer, tmpl, "app_new.html", data) + + return + } + if branch == "" { branch = "main" } @@ -225,6 +233,17 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid return } + repoURLErr := validateRepoURL(request.FormValue("repo_url")) + if repoURLErr != nil { + data := h.addGlobals(map[string]any{ + "App": application, + "Error": "Invalid repository URL: " + repoURLErr.Error(), + }, request) + _ = tmpl.ExecuteTemplate(writer, "app_edit.html", data) + + return + } + application.Name = newName application.RepoURL = request.FormValue("repo_url") application.Branch = request.FormValue("branch") diff --git a/internal/handlers/repo_url_validation.go b/internal/handlers/repo_url_validation.go new file mode 100644 index 0000000..0598a93 --- /dev/null +++ b/internal/handlers/repo_url_validation.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "errors" + "net/url" + "regexp" + "strings" +) + +// Repo URL validation errors. +var ( + errRepoURLEmpty = errors.New("repository URL must not be empty") + errRepoURLScheme = errors.New("file:// URLs are not allowed for security reasons") + errRepoURLInvalid = errors.New("repository URL must use https://, http://, ssh://, git://, or git@host:path format") + errRepoURLNoHost = errors.New("repository URL must include a host") + errRepoURLNoPath = errors.New("repository URL must include a path") +) + +// scpLikeRepoRe matches SCP-like git URLs: git@host:path (e.g. git@github.com:user/repo.git). +// Only the "git" user is allowed, as that is the standard for SSH deploy keys. +var scpLikeRepoRe = regexp.MustCompile(`^git@[a-zA-Z0-9._-]+:.+$`) + +// validateRepoURL checks that the given repository URL is valid and uses an allowed scheme. +func validateRepoURL(repoURL string) error { + if strings.TrimSpace(repoURL) == "" { + return errRepoURLEmpty + } + + // Reject path traversal in any URL format + if strings.Contains(repoURL, "..") { + return errRepoURLInvalid + } + + // Check for SCP-like git URLs first (git@host:path) + if scpLikeRepoRe.MatchString(repoURL) { + return nil + } + + // Reject file:// explicitly + if strings.HasPrefix(strings.ToLower(repoURL), "file://") { + return errRepoURLScheme + } + + // Parse as standard URL + parsed, err := url.Parse(repoURL) + if err != nil { + return errRepoURLInvalid + } + + // Must have a recognized scheme + switch strings.ToLower(parsed.Scheme) { + case "https", "http", "ssh", "git": + // OK + default: + return errRepoURLInvalid + } + + if parsed.Host == "" { + return errRepoURLNoHost + } + + if parsed.Path == "" || parsed.Path == "/" { + return errRepoURLNoPath + } + + return nil +} diff --git a/internal/handlers/repo_url_validation_test.go b/internal/handlers/repo_url_validation_test.go new file mode 100644 index 0000000..be7416a --- /dev/null +++ b/internal/handlers/repo_url_validation_test.go @@ -0,0 +1,56 @@ +package handlers + +import "testing" + +func TestValidateRepoURL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + wantErr bool + }{ + // Valid URLs + {name: "https URL", url: "https://github.com/user/repo.git", wantErr: false}, + {name: "http URL", url: "http://github.com/user/repo.git", wantErr: false}, + {name: "ssh URL", url: "ssh://git@github.com/user/repo.git", wantErr: false}, + {name: "git URL", url: "git://github.com/user/repo.git", wantErr: false}, + {name: "SCP-like URL", url: "git@github.com:user/repo.git", wantErr: false}, + {name: "SCP-like with dots", url: "git@git.example.com:org/repo.git", wantErr: false}, + {name: "https without .git", url: "https://github.com/user/repo", wantErr: false}, + {name: "https with port", url: "https://git.example.com:8443/user/repo.git", wantErr: false}, + + // Invalid URLs + {name: "empty string", url: "", wantErr: true}, + {name: "whitespace only", url: " ", wantErr: true}, + {name: "file URL", url: "file:///etc/passwd", wantErr: true}, + {name: "file URL uppercase", url: "FILE:///etc/passwd", wantErr: true}, + {name: "bare path", url: "/some/local/path", wantErr: true}, + {name: "relative path", url: "../repo", wantErr: true}, + {name: "just a word", url: "notaurl", wantErr: true}, + {name: "ftp URL", url: "ftp://example.com/repo.git", wantErr: true}, + {name: "no host https", url: "https:///path", wantErr: true}, + {name: "no path https", url: "https://github.com", wantErr: true}, + {name: "no path https trailing slash", url: "https://github.com/", wantErr: true}, + {name: "SCP-like non-git user", url: "root@github.com:user/repo.git", wantErr: true}, + {name: "SCP-like arbitrary user", url: "admin@github.com:user/repo.git", wantErr: true}, + {name: "path traversal SCP", url: "git@github.com:../../etc/passwd", wantErr: true}, + {name: "path traversal https", url: "https://github.com/user/../../../etc/passwd", wantErr: true}, + {name: "path traversal in middle", url: "https://github.com/user/repo/../secret", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + err := validateRepoURL(tc.url) + if tc.wantErr && err == nil { + t.Errorf("validateRepoURL(%q) = nil, want error", tc.url) + } + + if !tc.wantErr && err != nil { + t.Errorf("validateRepoURL(%q) = %v, want nil", tc.url, err) + } + }) + } +}