Compare commits

...

2 Commits

Author SHA1 Message Date
Jiayuan Zhang
ef0ee819a6 fix(projects): reject scp-like URLs with '@' after ':' to avoid panic
isValidGitRepoURL indexed '@' and ':' independently, then sliced
s[at+1 : colon]. For inputs without '://' where '@' appears after the
first ':' (e.g. `host:org/repo@branch`), `at+1 > colon` triggered a
slice-bounds panic instead of a 400. Guard the slice and treat such
inputs as malformed.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-14 23:00:31 +08:00
Jiayuan Zhang
65cc376f11 fix(projects): accept SSH repo URLs for github_repo resources (#2484)
The project resource validator rejected anything that wasn't http(s), so
workspace repos configured with an SSH remote (ssh:// or the scp-like
`git@host:owner/repo.git` shorthand) could not be attached to a project.
Both forms are valid git remotes and the daemon hands the URL straight to
`git clone`, so the API has no reason to require https specifically.

Relax the validator to accept http/https/ssh/git schemes and the scp-like
shorthand, while still rejecting pasted garbage (no scheme, missing host,
missing path, ftp://, file://, etc.).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-12 22:48:55 +08:00
2 changed files with 141 additions and 2 deletions

View File

@@ -84,8 +84,8 @@ func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
if payload.URL == "" {
return nil, errors.New("github_repo: url is required")
}
if u, err := url.Parse(payload.URL); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, errors.New("github_repo: url must be a valid http(s) URL")
if !isValidGitRepoURL(payload.URL) {
return nil, errors.New("github_repo: url must be a valid http(s) or ssh git URL")
}
payload.DefaultBranchHint = strings.TrimSpace(payload.DefaultBranchHint)
out, err := json.Marshal(payload)
@@ -95,6 +95,49 @@ func validateGithubRepoRef(ref json.RawMessage) (json.RawMessage, error) {
return out, nil
}
// isValidGitRepoURL accepts the three forms a user can paste from GitHub's
// "Code" menu: https://, ssh:// (with explicit scheme), and the scp-like
// shorthand `git@host:owner/repo.git`. The check is intentionally lax — we are
// guarding against pasted garbage like "not-a-url", not enforcing a strict
// grammar — because the actual fetch happens client-side via `git clone` and
// the user gets a clearer error from git than from us.
func isValidGitRepoURL(s string) bool {
if u, err := url.Parse(s); err == nil && u.Host != "" {
switch u.Scheme {
case "http", "https", "ssh", "git":
return true
}
}
// scp-like ssh shorthand: [user@]host:path with a non-empty host and path,
// and no spaces. Reject anything that looks like a URL with a scheme
// (those should go through url.Parse above).
if strings.Contains(s, " ") || strings.Contains(s, "://") {
return false
}
colon := strings.Index(s, ":")
if colon <= 0 || colon == len(s)-1 {
return false
}
// In scp-like ssh shorthand `[user@]host:path`, `@` is only meaningful
// as a user separator before the first ':'. If '@' appears at or after
// the colon it is not the user separator — reject as malformed rather
// than guess (and avoid a slice-bounds panic from blindly slicing).
at := strings.Index(s, "@")
if at >= colon {
return false
}
hostStart := 0
if at >= 0 {
hostStart = at + 1
}
host := s[hostStart:colon]
path := s[colon+1:]
if host == "" || path == "" {
return false
}
return true
}
// loadProjectForResource resolves the project, enforces workspace ownership,
// and returns its DB row. Used by all project_resource handlers.
func (h *Handler) loadProjectForResource(w http.ResponseWriter, r *http.Request, projectIDParam string) (db.Project, bool) {

View File

@@ -135,6 +135,102 @@ func TestProjectResourceLifecycle(t *testing.T) {
}
}
// TestProjectResourceAcceptsSSHRepoURLs covers GitHub issue #2484: SSH and
// scp-like git URLs must be accepted alongside https URLs, because workspace
// repos configured with an SSH remote previously got rejected when attached
// to a project.
func TestProjectResourceAcceptsSSHRepoURLs(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{
"title": "SSH repo URL acceptance",
})
testHandler.CreateProject(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProject: %d %s", w.Code, w.Body.String())
}
var project ProjectResponse
if err := json.NewDecoder(w.Body).Decode(&project); err != nil {
t.Fatalf("decode CreateProject: %v", err)
}
defer func() {
r := newRequest("DELETE", "/api/projects/"+project.ID, nil)
r = withURLParam(r, "id", project.ID)
testHandler.DeleteProject(httptest.NewRecorder(), r)
}()
cases := []struct {
name string
url string
}{
{"scp-like", "git@github.com:multica-ai/multica.git"},
{"ssh-scheme", "ssh://git@github.com/multica-ai/multica.git"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects/"+project.ID+"/resources", map[string]any{
"resource_type": "github_repo",
"resource_ref": map[string]any{"url": tc.url},
})
req = withURLParam(req, "id", project.ID)
testHandler.CreateProjectResource(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateProjectResource(%s): expected 201, got %d: %s", tc.url, w.Code, w.Body.String())
}
var created ProjectResourceResponse
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode: %v", err)
}
var ref struct {
URL string `json:"url"`
}
if err := json.Unmarshal(created.ResourceRef, &ref); err != nil {
t.Fatalf("decode resource_ref: %v", err)
}
if ref.URL != tc.url {
t.Errorf("ref.url = %q, want %q", ref.URL, tc.url)
}
})
}
}
func TestIsValidGitRepoURL(t *testing.T) {
good := []string{
"https://github.com/multica-ai/multica",
"https://github.com/multica-ai/multica.git",
"http://github.example.com/x/y",
"ssh://git@github.com/multica-ai/multica.git",
"ssh://git@github.com:22/multica-ai/multica.git",
"git@github.com:multica-ai/multica.git",
"git@gitlab.example.com:group/sub/repo.git",
}
bad := []string{
"",
"not-a-url",
"github.com/multica-ai/multica", // no scheme, no scp-style colon
"https://", // empty host
"git@github.com", // missing :path
"git@:foo/bar", // missing host
"git@github.com:", // missing path
"ftp://example.com/repo", // unsupported scheme
"file:///tmp/repo", // unsupported scheme
"some random text with spaces",
"github.com:org/repo@branch", // '@' after ':' belongs to the path, not user
"foo:bar@baz", // '@' after ':' with no scheme
":foo/bar", // leading ':' with no host
}
for _, s := range good {
if !isValidGitRepoURL(s) {
t.Errorf("isValidGitRepoURL(%q) = false, want true", s)
}
}
for _, s := range bad {
if isValidGitRepoURL(s) {
t.Errorf("isValidGitRepoURL(%q) = true, want false", s)
}
}
}
func TestCreateProjectAttachesResources(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/projects?workspace_id="+testWorkspaceID, map[string]any{