diff --git a/CHANGELOG.md b/CHANGELOG.md index 381570f..6b9999e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to teploy are documented here. Format follows [Keep a Change ## [Unreleased] +### Fixed + +- Native repository proof for a CI checkout (`trigger` with a GitHub or + GitLab repository ID) now compares host and path only. A checkout over + `ssh://git@gitlab.lan:2222/g/r` matches the API's + `http://gitlab.lan:8080/g/r` instead of being refused; the API authority + stays pinned by `TEPLOY_TRIGGER_API_BASE`. + ## [0.1.45] - 2026-10-20 ### Upgrade notes diff --git a/internal/cli/trigger_identity.go b/internal/cli/trigger_identity.go index ab96a19..cd2e9a7 100644 --- a/internal/cli/trigger_identity.go +++ b/internal/cli/trigger_identity.go @@ -56,6 +56,17 @@ func canonicalRepository(raw string) (string, error) { } return u.String(), nil } +// sameRepositoryPath compares two canonical repositories by host and path. +// The forge API reports its HTTP clone URL, while a checkout may use SSH on +// a different port (self-hosted forges almost always do); the transport +// and port are not the repository. The API itself is authority-pinned, so +// only the locator spelling is relaxed here. +func sameRepositoryPath(a, b string) bool { + ua, ea := url.Parse(a) + ub, eb := url.Parse(b) + return ea == nil && eb == nil && ua.Hostname() != "" && + ua.Hostname() == ub.Hostname() && ua.Path == ub.Path +} func checkoutRepository(dir string) (string, error) { out, err := exec.Command("git", "-C", dir, "config", "--get", "remote.origin.url").Output() if err != nil { @@ -158,7 +169,7 @@ func verifyRepositoryMapping(ctx context.Context, identity, clone string, transp locator = metadata.HTTPURL } actual, err := canonicalRepository(locator) - if err != nil || actual != clone { + if err != nil || !sameRepositoryPath(actual, clone) { return fmt.Errorf("authenticated API repository does not map to checkout origin") } return nil diff --git a/internal/cli/trigger_identity_transport_test.go b/internal/cli/trigger_identity_transport_test.go new file mode 100644 index 0000000..f300fb2 --- /dev/null +++ b/internal/cli/trigger_identity_transport_test.go @@ -0,0 +1,43 @@ +package cli + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRepositoryMappingIgnoresCheckoutTransport(t *testing.T) { + api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/projects/5" || r.Header.Get("PRIVATE-TOKEN") != "tok" { + http.NotFound(w, r) + return + } + w.Write([]byte(`{"id":5,"http_url_to_repo":"http://gitlab.lan:8080/group/app.git"}`)) + })) + defer api.Close() + t.Setenv("TEPLOY_TRIGGER_API_BASE", api.URL) + t.Setenv("TEPLOY_TRIGGER_FORGE", "gitlab") + t.Setenv("TEPLOY_TRIGGER_ALLOW_HTTP", "1") + t.Setenv("TEPLOY_TRIGGER_API_TOKEN", "tok") + identity, _ := json.Marshal([]string{"gitlab", api.URL, "id:5"}) + + check := func(remote string) error { + clone, err := canonicalRepository(remote) + if err != nil { + t.Fatal(err) + } + return verifyRepositoryMapping(context.Background(), string(identity), clone, http.DefaultTransport) + } + // The checkout uses SSH on the forge's SSH port; the API reports HTTP. + if err := check("ssh://git@gitlab.lan:2222/group/app.git"); err != nil { + t.Fatalf("ssh checkout of the same repository refused: %v", err) + } + if err := check("ssh://git@gitlab.lan:2222/group/other.git"); err == nil { + t.Fatal("checkout of another repository accepted") + } + if err := check("ssh://git@elsewhere.lan:2222/group/app.git"); err == nil { + t.Fatal("checkout on another host accepted") + } +}