0
0
mirror of https://github.com/go-gitea/gitea.git synced 2026-06-22 01:27:16 +02:00

reject non auth keys

This commit is contained in:
pomidorry 2026-06-15 22:24:34 +03:00
parent b57a6507e3
commit eb87aa3ab8
7 changed files with 109 additions and 51 deletions

View File

@ -61,20 +61,26 @@ type Command struct {
// operations (migration / mirror with a generated keypair). ssh runs
// non-interactively (BatchMode) so the worker never hangs on an unknown host,
// and the configured host-key policy is applied.
func managedSSHCommand() string {
func managedSSHCommand(identityFile string) string {
var cmd string
mode := setting.Migrations.SSHHostKeyChecking
if mode == "no" {
return "ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=" + util.ShellEscape(os.DevNull)
}
cmd := "ssh -o BatchMode=yes -o StrictHostKeyChecking=" + mode
// Persist accepted host keys in a Gitea-managed file so a later key change
// is detected (TOFU); fall back to ssh's default known_hosts if unset.
if setting.AppDataPath != "" {
knownHosts := filepath.Join(setting.AppDataPath, "home", ".ssh", "known_hosts")
if err := os.MkdirAll(filepath.Dir(knownHosts), 0o700); err == nil {
cmd += " -o UserKnownHostsFile=" + util.ShellEscape(knownHosts)
cmd = "ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=" + util.ShellEscape(os.DevNull)
} else {
cmd = "ssh -o BatchMode=yes -o StrictHostKeyChecking=" + mode
// Persist accepted host keys in a Gitea-managed file so a later key change
// is detected (TOFU); fall back to ssh's default known_hosts if unset.
if setting.AppDataPath != "" {
knownHosts := filepath.Join(setting.AppDataPath, "home", ".ssh", "known_hosts")
if err := os.MkdirAll(filepath.Dir(knownHosts), 0o700); err == nil {
cmd += " -o UserKnownHostsFile=" + util.ShellEscape(knownHosts)
}
}
}
// pin auth to the managed key so ssh ignores the OS user's $HOME/.ssh identities
if identityFile != "" {
cmd += " -o IdentitiesOnly=yes -i " + util.ShellEscape(identityFile)
}
return cmd
}
@ -242,6 +248,9 @@ type runOpts struct {
// If provided, SSH_AUTH_SOCK environment variable will be set
SSHAuthSock string
// SSHIdentityFile is the managed public key file used to pin ssh authentication
SSHIdentityFile string
PipelineFunc func(Context) error
}
@ -305,6 +314,11 @@ func (c *Command) WithSSHAuthSock(sshAuthSock string) *Command {
return c
}
func (c *Command) WithSSHIdentityFile(identityFile string) *Command {
c.opts.SSHIdentityFile = identityFile
return c
}
func (c *Command) makeStdoutStderr(w *io.Writer) (PipeReader, func()) {
pr, pw, err := os.Pipe()
if err != nil {
@ -475,7 +489,7 @@ func (c *Command) Start(ctx context.Context) (retErr error) {
if c.opts.SSHAuthSock != "" {
c.cmd.Env = append(c.cmd.Env, "SSH_AUTH_SOCK="+c.opts.SSHAuthSock)
c.cmd.Env = append(c.cmd.Env, "GIT_SSH_COMMAND="+managedSSHCommand())
c.cmd.Env = append(c.cmd.Env, "GIT_SSH_COMMAND="+managedSSHCommand(c.opts.SSHIdentityFile))
}
c.cmd.Dir = c.opts.Dir

View File

@ -30,7 +30,7 @@ func TestManagedSSHCommand(t *testing.T) {
knownHosts := filepath.Join(dataPath, "home", ".ssh", "known_hosts")
expected := "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=" + util.ShellEscape(knownHosts)
assert.Equal(t, expected, managedSSHCommand())
assert.Equal(t, expected, managedSSHCommand(""))
// the known_hosts directory must be created so ssh can write to it
info, err := os.Stat(filepath.Dir(knownHosts))
@ -45,7 +45,7 @@ func TestManagedSSHCommand(t *testing.T) {
knownHosts := filepath.Join(dataPath, "home", ".ssh", "known_hosts")
expected := "ssh -o BatchMode=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=" + util.ShellEscape(knownHosts)
assert.Equal(t, expected, managedSSHCommand())
assert.Equal(t, expected, managedSSHCommand(""))
})
t.Run("no disables verification with /dev/null known_hosts", func(t *testing.T) {
@ -53,21 +53,28 @@ func TestManagedSSHCommand(t *testing.T) {
setting.Migrations.SSHHostKeyChecking = "no"
expected := "ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=" + util.ShellEscape(os.DevNull)
assert.Equal(t, expected, managedSSHCommand())
assert.Equal(t, expected, managedSSHCommand(""))
})
t.Run("empty AppDataPath falls back without UserKnownHostsFile", func(t *testing.T) {
setting.AppDataPath = ""
setting.Migrations.SSHHostKeyChecking = "accept-new"
assert.Equal(t, "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", managedSSHCommand())
assert.Equal(t, "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new", managedSSHCommand(""))
})
t.Run("BatchMode is always set so the worker never hangs", func(t *testing.T) {
setting.AppDataPath = t.TempDir()
for _, mode := range []string{"accept-new", "yes", "no"} {
setting.Migrations.SSHHostKeyChecking = mode
assert.Contains(t, managedSSHCommand(), "ssh -o BatchMode=yes ", "mode %q must run ssh non-interactively", mode)
assert.Contains(t, managedSSHCommand(""), "ssh -o BatchMode=yes ", "mode %q must run ssh non-interactively", mode)
}
})
t.Run("identityFile pins authentication to the managed key", func(t *testing.T) {
setting.AppDataPath = t.TempDir()
setting.Migrations.SSHHostKeyChecking = "no"
keyFile := filepath.Join(t.TempDir(), "id.pub")
assert.Contains(t, managedSSHCommand(keyFile), "-o IdentitiesOnly=yes -i "+util.ShellEscape(keyFile))
})
}

View File

@ -99,19 +99,20 @@ func (repo *Repository) IsEmpty() (bool, error) {
// CloneRepoOptions options when clone a repository
type CloneRepoOptions struct {
Timeout time.Duration
Mirror bool
Bare bool
Quiet bool
Branch string
Shared bool
NoCheckout bool
Depth int
Filter string
SkipTLSVerify bool
SSHAuthSock string
SingleBranch bool
Env []string
Timeout time.Duration
Mirror bool
Bare bool
Quiet bool
Branch string
Shared bool
NoCheckout bool
Depth int
Filter string
SkipTLSVerify bool
SSHAuthSock string
SSHIdentityFile string
SingleBranch bool
Env []string
}
// Clone clones original repository to target path.
@ -172,20 +173,22 @@ func Clone(ctx context.Context, from, to string, opts CloneRepoOptions) error {
WithTimeout(opts.Timeout).
WithEnv(envs).
WithSSHAuthSock(opts.SSHAuthSock).
WithSSHIdentityFile(opts.SSHIdentityFile).
RunWithStderr(ctx)
}
// PushOptions options when push to remote
type PushOptions struct {
Remote string
LocalRefName string
Branch string
Force bool
ForceWithLease string
Mirror bool
Env []string
Timeout time.Duration
SSHAuthSock string
Remote string
LocalRefName string
Branch string
Force bool
ForceWithLease string
Mirror bool
Env []string
Timeout time.Duration
SSHAuthSock string
SSHIdentityFile string
}
// Push pushs local commits to given remote branch.
@ -211,7 +214,7 @@ func Push(ctx context.Context, repoPath string, opts PushOptions) error {
}
cmd.AddDashesAndList(remoteBranchArgs...)
stdout, stderr, err := cmd.WithEnv(opts.Env).WithTimeout(opts.Timeout).WithDir(repoPath).WithSSHAuthSock(opts.SSHAuthSock).RunStdString(ctx)
stdout, stderr, err := cmd.WithEnv(opts.Env).WithTimeout(opts.Timeout).WithDir(repoPath).WithSSHAuthSock(opts.SSHAuthSock).WithSSHIdentityFile(opts.SSHIdentityFile).RunStdString(ctx)
if err != nil {
if strings.Contains(stderr, "non-fast-forward") {
return &ErrPushOutOfDate{StdOut: stdout, StdErr: stderr, Err: err}

View File

@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"os"
repo_model "gitea.dev/models/repo"
user_model "gitea.dev/models/user"
@ -47,10 +48,10 @@ func GetSSHKeypairForRepository(ctx context.Context, repo *repo_model.Repository
// If sshKeyOwnerID is non-zero, the keypair of that owner is used instead of
// the repository owner's (used when migrating to an org and the user wants
// to authenticate with their personal managed key).
func SetupManagedSSHAgent(ctx context.Context, repo *repo_model.Repository, remoteURL string, sshKeyOwnerID int64) (sshAuthSock string, cleanup func(), err error) {
func SetupManagedSSHAgent(ctx context.Context, repo *repo_model.Repository, remoteURL string, sshKeyOwnerID int64) (sshAuthSock, sshIdentityFile string, cleanup func(), err error) {
noop := func() {}
if !IsSSHURL(remoteURL) {
return "", noop, nil
return "", "", noop, nil
}
ownerID := repo.OwnerID
@ -59,22 +60,53 @@ func SetupManagedSSHAgent(ctx context.Context, repo *repo_model.Repository, remo
}
keypair, err := GetOrCreateSSHKeypair(ctx, ownerID)
if err != nil {
return "", noop, fmt.Errorf("failed to get SSH keypair for owner %d: %w", ownerID, err)
return "", "", noop, fmt.Errorf("failed to get SSH keypair for owner %d: %w", ownerID, err)
}
if keypair == nil {
return "", noop, nil
return "", "", noop, nil
}
privateKey, err := keypair.GetDecryptedPrivateKey()
if err != nil {
return "", noop, fmt.Errorf("failed to decrypt SSH private key: %w", err)
return "", "", noop, fmt.Errorf("failed to decrypt SSH private key: %w", err)
}
socketPath, agentCleanup, err := CreateTemporaryAgent(privateKey)
if err != nil {
return "", noop, fmt.Errorf("failed to create SSH agent: %w", err)
return "", "", noop, fmt.Errorf("failed to create SSH agent: %w", err)
}
identityFile, keyCleanup, err := writeManagedPublicKey(keypair.PublicKey)
if err != nil {
agentCleanup()
return "", "", noop, fmt.Errorf("failed to write managed public key: %w", err)
}
cleanup = func() {
keyCleanup()
agentCleanup()
}
log.Debug("SSH agent ready for %s (socket: %s)", repo.FullName(), socketPath)
return socketPath, agentCleanup, nil
return socketPath, identityFile, cleanup, nil
}
// writeManagedPublicKey writes the managed public key to a temporary file so the
// git SSH command can pin authentication to it via "-i". The returned cleanup
// removes the file.
func writeManagedPublicKey(publicKey string) (path string, cleanup func(), err error) {
f, err := os.CreateTemp("", "gitea-managed-ssh-*.pub")
if err != nil {
return "", nil, err
}
if _, err = f.WriteString(publicKey); err != nil {
f.Close()
os.Remove(f.Name())
return "", nil, err
}
if err = f.Close(); err != nil {
os.Remove(f.Name())
return "", nil, err
}
return f.Name(), func() { os.Remove(f.Name()) }, nil
}

View File

@ -119,7 +119,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
timeout := time.Duration(setting.Git.Timeout.Mirror) * time.Second
// Setup SSH authentication if needed
sshAuthSock, cleanup, sshErr := ssh_module.SetupManagedSSHAgent(ctx, m.Repo, remoteURL.String(), 0)
sshAuthSock, sshIdentityFile, cleanup, sshErr := ssh_module.SetupManagedSSHAgent(ctx, m.Repo, remoteURL.String(), 0)
if sshErr != nil {
log.Error("SyncMirrors [repo: %-v]: SSH setup error %v", m.Repo, sshErr)
return nil, false
@ -132,7 +132,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
if m.EnablePrune {
cmd.AddArguments("--prune")
}
return cmd.AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs).WithSSHAuthSock(sshAuthSock)
return cmd.AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs).WithSSHAuthSock(sshAuthSock).WithSSHIdentityFile(sshIdentityFile)
}
var err error
@ -209,7 +209,7 @@ func runSync(ctx context.Context, m *repo_model.Mirror) ([]*repo_module.SyncResu
cmdRemoteUpdatePrune := func() *gitcmd.Command {
return gitcmd.NewCommand("remote", "update", "--prune").
AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs).WithSSHAuthSock(sshAuthSock)
AddDynamicArguments(m.GetRemoteName()).WithTimeout(timeout).WithEnv(envs).WithSSHAuthSock(sshAuthSock).WithSSHIdentityFile(sshIdentityFile)
}
if repo_service.HasWiki(ctx, m.Repo) {

View File

@ -168,13 +168,14 @@ func runPushSync(ctx context.Context, m *repo_model.PushMirror) error {
}
// Setup SSH authentication
sshAuthSock, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL.String(), 0)
sshAuthSock, sshIdentityFile, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL.String(), 0)
if err != nil {
log.Error("Failed to set up SSH agent for push mirror %s: %v", repo.FullName(), err)
return util.SanitizeErrorCredentialURLs(err)
}
defer cleanup()
pushOpts.SSHAuthSock = sshAuthSock
pushOpts.SSHIdentityFile = sshIdentityFile
if err := gitrepo.PushToExternal(ctx, storageRepo, pushOpts); err != nil {
log.Error("Error pushing %s mirror[%d] remote %s: %v", storageRepo.RelativePath(), m.ID, m.RemoteName, err)

View File

@ -29,13 +29,14 @@ import (
)
func cloneExternalRepoWithSSHAuth(ctx context.Context, repo *repo_model.Repository, remoteURL string, storageRepo gitrepo.Repository, cloneOpts git.CloneRepoOptions, sshKeyOwnerID int64) error {
sshAuthSock, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL, sshKeyOwnerID)
sshAuthSock, sshIdentityFile, cleanup, err := ssh_module.SetupManagedSSHAgent(ctx, repo, remoteURL, sshKeyOwnerID)
if err != nil {
return err
}
defer cleanup()
cloneOpts.SSHAuthSock = sshAuthSock
cloneOpts.SSHIdentityFile = sshIdentityFile
return gitrepo.CloneExternalRepo(ctx, remoteURL, storageRepo, cloneOpts)
}