fix: validate repo URL format on app creation (closes #88) #91
@ -216,6 +216,15 @@ func (h *Handlers) HandleAPICreateApp() http.HandlerFunc {
|
|||||||
return
|
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{
|
createdApp, createErr := h.appService.CreateApp(request.Context(), app.CreateAppInput{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
RepoURL: req.RepoURL,
|
RepoURL: req.RepoURL,
|
||||||
|
|||||||
@ -77,6 +77,14 @@ func (h *Handlers) HandleAppCreate() http.HandlerFunc { //nolint:funlen // valid
|
|||||||
return
|
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 == "" {
|
if branch == "" {
|
||||||
branch = "main"
|
branch = "main"
|
||||||
}
|
}
|
||||||
@ -225,6 +233,17 @@ func (h *Handlers) HandleAppUpdate() http.HandlerFunc { //nolint:funlen // valid
|
|||||||
return
|
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.Name = newName
|
||||||
application.RepoURL = request.FormValue("repo_url")
|
application.RepoURL = request.FormValue("repo_url")
|
||||||
application.Branch = request.FormValue("branch")
|
application.Branch = request.FormValue("branch")
|
||||||
|
|||||||
67
internal/handlers/repo_url_validation.go
Normal file
67
internal/handlers/repo_url_validation.go
Normal file
@ -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
|
||||||
|
}
|
||||||
56
internal/handlers/repo_url_validation_test.go
Normal file
56
internal/handlers/repo_url_validation_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user