From 511884b2af552a4f5fec4fdaf6d9b23226093a20 Mon Sep 17 00:00:00 2001 From: GyeongHo Kim Date: Sun, 14 Jun 2026 02:19:52 +0900 Subject: [PATCH] fix(login): default SSH host to URL hostname Use the server URL hostname when a login has no explicit SSHHost configured, so an HTTP(S) port from the Gitea URL is not reused as the SSH endpoint. Add shared SSH host normalization and resolution helpers with tests for host and host:port values. Signed-off-by: GyeongHo Kim --- modules/config/login.go | 2 +- modules/config/login_test.go | 32 ++++++++ modules/utils/ssh_host.go | 90 ++++++++++++++++++++++ modules/utils/ssh_host_test.go | 131 +++++++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 modules/utils/ssh_host.go create mode 100644 modules/utils/ssh_host_test.go diff --git a/modules/config/login.go b/modules/config/login.go index 3cd1adb7..2cf63d69 100644 --- a/modules/config/login.go +++ b/modules/config/login.go @@ -528,5 +528,5 @@ func (l *Login) GetSSHHost() string { return "" } - return u.Host + return u.Hostname() } diff --git a/modules/config/login_test.go b/modules/config/login_test.go index 9348c1ae..3bbe5514 100644 --- a/modules/config/login_test.go +++ b/modules/config/login_test.go @@ -108,3 +108,35 @@ func writeTestSSHKey(t *testing.T) (string, string) { return sshKeyPath, ssh.FingerprintSHA256(signer.PublicKey()) } + +func TestLoginGetSSHHost(t *testing.T) { + tests := []struct { + name string + login Login + want string + }{ + { + name: "explicit SSH host", + login: Login{URL: "https://gitea.example.com:3000", SSHHost: "ssh.example.com:2222"}, + want: "ssh.example.com:2222", + }, + { + name: "URL with port", + login: Login{URL: "https://gitea.example.com:3000"}, + want: "gitea.example.com", + }, + { + name: "URL without port", + login: Login{URL: "https://gitea.example.com"}, + want: "gitea.example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.login.GetSSHHost(); got != tt.want { + t.Fatalf("GetSSHHost() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/modules/utils/ssh_host.go b/modules/utils/ssh_host.go new file mode 100644 index 00000000..a930fb20 --- /dev/null +++ b/modules/utils/ssh_host.go @@ -0,0 +1,90 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "fmt" + "net" + "net/url" + "strconv" + "strings" +) + +// NormalizeSSHHost validates an SSH host setting and returns it trimmed. +// The accepted format is host or host:port. IPv6 addresses must be bracketed. +func NormalizeSSHHost(raw string) (string, error) { + sshHost := strings.TrimSpace(raw) + if sshHost == "" { + return "", nil + } + + if strings.ContainsAny(sshHost, "/\\") { + return "", fmt.Errorf("invalid SSH host %q: use host or host:port, not a URL or path", raw) + } + if strings.ContainsAny(sshHost, " \t\r\n") { + return "", fmt.Errorf("invalid SSH host %q: whitespace is not allowed", raw) + } + if strings.Contains(sshHost, "@") { + return "", fmt.Errorf("invalid SSH host %q: use host or host:port without a user", raw) + } + + host := sshHost + port := "" + colonCount := strings.Count(sshHost, ":") + portSpecified := false + + switch { + case strings.HasPrefix(sshHost, "["): + if strings.Contains(sshHost, "]:") { + portSpecified = true + var err error + host, port, err = net.SplitHostPort(sshHost) + if err != nil { + return "", fmt.Errorf("invalid SSH host %q: %w", raw, err) + } + } else if strings.HasSuffix(sshHost, "]") { + host = strings.TrimSuffix(strings.TrimPrefix(sshHost, "["), "]") + } else { + return "", fmt.Errorf("invalid SSH host %q: malformed IPv6 address", raw) + } + case colonCount == 1: + portSpecified = true + parts := strings.SplitN(sshHost, ":", 2) + host = parts[0] + port = parts[1] + case colonCount > 1: + return "", fmt.Errorf("invalid SSH host %q: IPv6 addresses must be bracketed", raw) + } + + if host == "" { + return "", fmt.Errorf("invalid SSH host %q: host is required", raw) + } + if portSpecified { + if port == "" { + return "", fmt.Errorf("invalid SSH host %q: port is required", raw) + } + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 1 || portNumber > 65535 { + return "", fmt.Errorf("invalid SSH host %q: port must be between 1 and 65535", raw) + } + } + + return sshHost, nil +} + +// ResolveSSHHost returns the explicit SSH host when provided, otherwise the +// hostname from the Gitea server URL. +func ResolveSSHHost(serverURL *url.URL, explicitSSHHost string) (string, error) { + sshHost, err := NormalizeSSHHost(explicitSSHHost) + if err != nil { + return "", err + } + if sshHost != "" { + return sshHost, nil + } + if serverURL == nil { + return "", nil + } + return serverURL.Hostname(), nil +} diff --git a/modules/utils/ssh_host_test.go b/modules/utils/ssh_host_test.go new file mode 100644 index 00000000..82e86e4c --- /dev/null +++ b/modules/utils/ssh_host_test.go @@ -0,0 +1,131 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package utils + +import ( + "net/url" + "testing" +) + +func TestNormalizeSSHHost(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + { + name: "empty", + raw: "", + want: "", + }, + { + name: "host", + raw: "gitea.example.com", + want: "gitea.example.com", + }, + { + name: "host with port", + raw: "gitea.example.com:2222", + want: "gitea.example.com:2222", + }, + { + name: "trim spaces", + raw: " gitea.example.com:2222 ", + want: "gitea.example.com:2222", + }, + { + name: "bracketed IPv6 with port", + raw: "[::1]:2222", + want: "[::1]:2222", + }, + { + name: "URL", + raw: "ssh://gitea.example.com:2222", + wantErr: true, + }, + { + name: "path", + raw: "gitea.example.com/owner/repo", + wantErr: true, + }, + { + name: "user", + raw: "git@gitea.example.com", + wantErr: true, + }, + { + name: "invalid port", + raw: "gitea.example.com:ssh", + wantErr: true, + }, + { + name: "missing port", + raw: "gitea.example.com:", + wantErr: true, + }, + { + name: "missing host", + raw: ":2222", + wantErr: true, + }, + { + name: "bare IPv6", + raw: "::1", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NormalizeSSHHost(tt.raw) + if (err != nil) != tt.wantErr { + t.Fatalf("NormalizeSSHHost() error = %v, wantErr %v", err, tt.wantErr) + } + if got != tt.want { + t.Fatalf("NormalizeSSHHost() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveSSHHost(t *testing.T) { + serverURL, err := url.Parse("https://gitea.example.com:3000") + if err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + explicitSSHHost string + want string + }{ + { + name: "uses URL hostname by default", + want: "gitea.example.com", + }, + { + name: "uses explicit host", + explicitSSHHost: "ssh.example.com", + want: "ssh.example.com", + }, + { + name: "uses explicit host with port", + explicitSSHHost: "ssh.example.com:2222", + want: "ssh.example.com:2222", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveSSHHost(serverURL, tt.explicitSSHHost) + if err != nil { + t.Fatalf("ResolveSSHHost() error = %v", err) + } + if got != tt.want { + t.Fatalf("ResolveSSHHost() = %q, want %q", got, tt.want) + } + }) + } +}