refactor: replace gliderlabs/ssh with golang.org/x/crypto/ssh (#38837)

Migrate away from this thin ssh wrapper module while adding more test
coverage.

Removes `sessionPartial`, which hand-copied the layout of a private
`gliderlabs/ssh` struct and reinterpreted a pointer to it via
`reflect.UnsafePointer` to reach the permissions of the authenticated
connection. The layout is unchecked, so an upstream field reorder would
mismatch silently.

The builtin server only needs the session channel with `exec` and
`shell`. Serving those on `x/crypto` drops the hack and the dependency,
since `PublicKeyCallback` returns permissions per key and `x/crypto`
assigns them only after verifying the signature.

Two benign behavior changes:

1. Internal session handler errors report exit status 1 rather than 0,
so a client no longer reads a failure as success.
1. An unusable host key is fatal at startup instead of being replaced by
an ephemeral one that would trigger an error at the client.
This commit is contained in:
silverwind
2026-08-09 11:32:50 +00:00
committed by GitHub
parent 79535f4e01
commit ecbef41c06
7 changed files with 260 additions and 174 deletions
-10
View File
@@ -144,11 +144,6 @@
"path": "github.com/andybalholm/cascadia/LICENSE", "path": "github.com/andybalholm/cascadia/LICENSE",
"licenseText": "Copyright (c) 2011 Andy Balholm. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n" "licenseText": "Copyright (c) 2011 Andy Balholm. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
}, },
{
"name": "github.com/anmitsu/go-shlex",
"path": "github.com/anmitsu/go-shlex/LICENSE",
"licenseText": "Copyright (c) anmitsu \u003canmitsu.s@gmail.com\u003e\n\nPermission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n"
},
{ {
"name": "github.com/aws/aws-sdk-go-v2", "name": "github.com/aws/aws-sdk-go-v2",
"path": "github.com/aws/aws-sdk-go-v2/LICENSE.txt", "path": "github.com/aws/aws-sdk-go-v2/LICENSE.txt",
@@ -484,11 +479,6 @@
"path": "github.com/git-lfs/pktline/LICENSE.md", "path": "github.com/git-lfs/pktline/LICENSE.md",
"licenseText": "MIT License\n\nCopyright (c) 2014- GitHub, Inc. and Git LFS contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nNote that Git LFS uses components from other Go modules (included in `vendor/`)\nwhich are under different licenses. See those LICENSE files for details.\n" "licenseText": "MIT License\n\nCopyright (c) 2014- GitHub, Inc. and Git LFS contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\nNote that Git LFS uses components from other Go modules (included in `vendor/`)\nwhich are under different licenses. See those LICENSE files for details.\n"
}, },
{
"name": "github.com/gliderlabs/ssh",
"path": "github.com/gliderlabs/ssh/LICENSE",
"licenseText": "Copyright (c) 2016 Glider Labs. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n * Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n * Redistributions in binary form must reproduce the above\ncopyright notice, this list of conditions and the following disclaimer\nin the documentation and/or other materials provided with the\ndistribution.\n * Neither the name of Glider Labs nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS\n\"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT\nLIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR\nA PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nOWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT\nLIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,\nDATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY\nTHEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT\n(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
},
{ {
"name": "github.com/go-asn1-ber/asn1-ber", "name": "github.com/go-asn1-ber/asn1-ber",
"path": "github.com/go-asn1-ber/asn1-ber/LICENSE", "path": "github.com/go-asn1-ber/asn1-ber/LICENSE",
-2
View File
@@ -44,7 +44,6 @@ require (
github.com/felixge/fgprof v0.9.5 github.com/felixge/fgprof v0.9.5
github.com/fsnotify/fsnotify v1.10.1 github.com/fsnotify/fsnotify v1.10.1
github.com/getkin/kin-openapi v0.145.0 github.com/getkin/kin-openapi v0.145.0
github.com/gliderlabs/ssh v0.3.8
github.com/go-chi/chi/v5 v5.3.1 github.com/go-chi/chi/v5 v5.3.1
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/go-co-op/gocron/v2 v2.22.0 github.com/go-co-op/gocron/v2 v2.22.0
@@ -133,7 +132,6 @@ require (
github.com/STARRY-S/zip v0.2.3 // indirect github.com/STARRY-S/zip v0.2.3 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect github.com/andybalholm/brotli v1.2.1 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/aws/aws-sdk-go-v2 v1.43.1 // indirect github.com/aws/aws-sdk-go-v2 v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.32 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.32 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.32 // indirect
+6
View File
@@ -11,11 +11,17 @@ import (
"strconv" "strconv"
"strings" "strings"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/util" "gitea.dev/modules/util"
) )
// builtinUnused informs our cleanup routine that we will not be using a ssh port
func builtinUnused() {
graceful.GetManager().InformCleanup()
}
func Init() error { func Init() error {
if setting.SSH.Disabled { if setting.SSH.Disabled {
builtinUnused() builtinUnused()
+144
View File
@@ -0,0 +1,144 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ssh
import (
"context"
"errors"
"net"
"syscall"
"time"
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
gossh "golang.org/x/crypto/ssh"
)
// sshServer rejects everything but the "session" channel and its "exec" and "shell" requests,
// because pty, subsystems and forwarding are of no use to a git client.
type sshServer struct {
addr string
hostSigners []gossh.Signer
config gossh.Config
}
type sshSession struct {
gossh.Channel
conn *gossh.ServerConn
ctx context.Context
rawCmd string
env []string
}
func (srv *sshServer) newServerConfig(ctx context.Context) *gossh.ServerConfig {
config := &gossh.ServerConfig{
Config: srv.config,
PublicKeyCallback: func(conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) {
return publicKeyHandler(ctx, conn, key)
},
}
for _, signer := range srv.hostSigners {
config.AddHostKey(signer) // keeps only the newest key per algorithm
}
return config
}
func listen(srv *sshServer) {
gracefulServer := graceful.NewServer("tcp", srv.addr, "SSH")
gracefulServer.PerWriteTimeout = setting.SSH.PerWriteTimeout
gracefulServer.PerWritePerKbTimeout = setting.SSH.PerWritePerKbTimeout
err := gracefulServer.ListenAndServe(srv.serve, setting.SSH.UseProxyProtocol)
if err != nil {
select {
case <-graceful.GetManager().IsShutdown():
log.Error("Failed to start SSH server: %v", err)
default:
log.Fatal("Failed to start SSH server: %v", err)
}
}
log.Info("SSH Listener: %s Closed", srv.addr)
}
// serve is a graceful.ServeFunction
func (srv *sshServer) serve(listener net.Listener) error {
var acceptDelay time.Duration
for {
conn, err := listener.Accept()
if err != nil {
// out of file descriptors or an aborted handshake, both recover on their own
if !errors.Is(err, syscall.EMFILE) && !errors.Is(err, syscall.ENFILE) && !errors.Is(err, syscall.ECONNABORTED) {
return err
}
acceptDelay = min(max(2*acceptDelay, 5*time.Millisecond), time.Second)
log.Warn("SSH: Accept failed, retrying in %s: %v", acceptDelay, err)
time.Sleep(acceptDelay)
continue
}
acceptDelay = 0
go srv.handleConn(conn)
}
}
func (srv *sshServer) handleConn(netConn net.Conn) {
ctx, cancel := context.WithCancel(graceful.GetManager().HammerContext())
defer cancel()
defer netConn.Close()
conn, chans, reqs, err := gossh.NewServerConn(netConn, srv.newServerConfig(ctx))
if err != nil {
sshConnectionFailed(netConn, err)
return
}
go gossh.DiscardRequests(reqs)
for newChan := range chans {
if newChan.ChannelType() != "session" {
_ = newChan.Reject(gossh.UnknownChannelType, "unsupported channel type")
continue
}
go handleSessionChannel(ctx, conn, newChan)
}
}
func handleSessionChannel(ctx context.Context, conn *gossh.ServerConn, newChan gossh.NewChannel) {
channel, reqs, err := newChan.Accept()
if err != nil {
log.Error("SSH: Accept session channel: %v", err)
return
}
defer channel.Close()
session := &sshSession{Channel: channel, conn: conn, ctx: ctx}
for req := range reqs {
switch req.Type {
case "env":
var env struct{ Key, Value string }
if gossh.Unmarshal(req.Payload, &env) != nil {
_ = req.Reply(false, nil)
continue
}
session.env = append(session.env, env.Key+"="+env.Value)
_ = req.Reply(true, nil)
case "exec", "shell":
var payload struct{ Value string } // a "shell" carries no payload, it runs "gitea serv" without a command
if req.Type == "exec" && gossh.Unmarshal(req.Payload, &payload) != nil {
_ = req.Reply(false, nil)
continue
}
session.rawCmd = payload.Value
_ = req.Reply(true, nil)
go gossh.DiscardRequests(reqs) // the client keeps sending while the command runs
status := struct{ Status uint32 }{uint32(sessionHandler(session))}
if _, err := channel.SendRequest("exit-status", false, gossh.Marshal(&status)); err != nil {
log.Error("SSH: Send exit-status: %v", err)
}
return
default:
_ = req.Reply(false, nil)
}
}
}
+72 -128
View File
@@ -13,7 +13,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"reflect"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -25,30 +24,11 @@ import (
"gitea.dev/modules/log" "gitea.dev/modules/log"
"gitea.dev/modules/process" "gitea.dev/modules/process"
"gitea.dev/modules/setting" "gitea.dev/modules/setting"
"gitea.dev/modules/util"
"github.com/gliderlabs/ssh"
gossh "golang.org/x/crypto/ssh" gossh "golang.org/x/crypto/ssh"
) )
// The ssh auth overall works like this:
// NewServerConn:
// serverHandshake+serverAuthenticate:
// PublicKeyCallback:
// PublicKeyHandler (our code):
// reset(ctx.Permissions) and set ctx.Permissions.giteaKeyID = keyID
// pubKey.Verify
// return ctx.Permissions // only reaches here, the pub key is really authenticated
// set conn.Permissions from serverAuthenticate
// sessionHandler(conn)
//
// Then sessionHandler should only use the "verified keyID" from the original ssh conn, but not the ctx one.
// Otherwise, if a user provides 2 keys A (a correct one) and B (public key matches but no private key),
// then only A succeeds to authenticate, sessionHandler will see B's keyID
//
// After x/crypto >= 0.31.0 (fix CVE-2024-45337), the PublicKeyCallback will be called again for the verified key,
// it mitigates the misuse for most cases, it's still good for us to make sure we don't rely on that mitigation
// and do not misuse the PublicKeyCallback: we should only use the verified keyID from the verified ssh conn.
const giteaPermissionExtensionKeyID = "gitea-perm-ext-key-id" const giteaPermissionExtensionKeyID = "gitea-perm-ext-key-id"
func getExitStatusFromError(err error) int { func getExitStatusFromError(err error) int {
@@ -75,45 +55,20 @@ func getExitStatusFromError(err error) int {
return waitStatus.ExitStatus() return waitStatus.ExitStatus()
} }
// sessionPartial is the private struct from "gliderlabs/ssh/session.go" func sessionHandler(session *sshSession) int {
// We need to read the original "conn" field from "ssh.Session interface" which contains the "*session pointer" // the conn permissions are the ones of the key which really authenticated, see publicKeyHandler
// https://github.com/gliderlabs/ssh/blob/d137aad99cd6f2d9495bfd98c755bec4e5dffb8c/session.go#L109-L113 keyID := session.conn.Permissions.Extensions[giteaPermissionExtensionKeyID]
// If upstream fixes the problem and/or changes the struct, we need to follow.
// If the struct mismatches, the builtin ssh server will fail during integration tests.
type sessionPartial struct {
sync.Mutex
gossh.Channel
conn *gossh.ServerConn
}
func ptr[T any](intf any) *T { log.Trace("SSH: Payload: %v", session.rawCmd)
// https://pkg.go.dev/unsafe#Pointer
// (1) Conversion of a *T1 to Pointer to *T2.
// Provided that T2 is no larger than T1 and that the two share an equivalent memory layout,
// this conversion allows reinterpreting data of one type as data of another type.
v := reflect.ValueOf(intf)
p := v.UnsafePointer()
return (*T)(p)
}
func sessionHandler(session ssh.Session) {
// here can't use session.Permissions() because it only uses the value from ctx, which might not be the authenticated one.
// so we must use the original ssh conn, which always contains the correct (verified) keyID.
sshSession := ptr[sessionPartial](session)
keyID := sshSession.conn.Permissions.Extensions[giteaPermissionExtensionKeyID]
command := session.RawCommand()
log.Trace("SSH: Payload: %v", command)
args := []string{"--config=" + setting.CustomConf, "serv", "key-" + keyID} args := []string{"--config=" + setting.CustomConf, "serv", "key-" + keyID}
log.Trace("SSH: Arguments: %v", args) log.Trace("SSH: Arguments: %v", args)
ctx, cancel := context.WithCancel(session.Context()) ctx, cancel := context.WithCancel(session.ctx)
defer cancel() defer cancel()
gitProtocol := "" gitProtocol := ""
for _, env := range session.Environ() { for _, env := range session.env {
if strings.HasPrefix(env, "GIT_PROTOCOL=") { if strings.HasPrefix(env, "GIT_PROTOCOL=") {
_, gitProtocol, _ = strings.Cut(env, "=") _, gitProtocol, _ = strings.Cut(env, "=")
break break
@@ -123,7 +78,7 @@ func sessionHandler(session ssh.Session) {
cmd := exec.CommandContext(ctx, setting.AppPath, args...) cmd := exec.CommandContext(ctx, setting.AppPath, args...)
cmd.Env = append( cmd.Env = append(
os.Environ(), os.Environ(),
"SSH_ORIGINAL_COMMAND="+command, "SSH_ORIGINAL_COMMAND="+session.rawCmd,
"SKIP_MINWINSVC=1", "SKIP_MINWINSVC=1",
"GIT_PROTOCOL="+gitProtocol, "GIT_PROTOCOL="+gitProtocol,
) )
@@ -131,21 +86,21 @@ func sessionHandler(session ssh.Session) {
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
log.Error("SSH: StdoutPipe: %v", err) log.Error("SSH: StdoutPipe: %v", err)
return return 1
} }
defer stdout.Close() defer stdout.Close()
stderr, err := cmd.StderrPipe() stderr, err := cmd.StderrPipe()
if err != nil { if err != nil {
log.Error("SSH: StderrPipe: %v", err) log.Error("SSH: StderrPipe: %v", err)
return return 1
} }
defer stderr.Close() defer stderr.Close()
stdin, err := cmd.StdinPipe() stdin, err := cmd.StdinPipe()
if err != nil { if err != nil {
log.Error("SSH: StdinPipe: %v", err) log.Error("SSH: StdinPipe: %v", err)
return return 1
} }
defer stdin.Close() defer stdin.Close()
@@ -155,7 +110,7 @@ func sessionHandler(session ssh.Session) {
if err = cmd.Start(); err != nil { if err = cmd.Start(); err != nil {
log.Error("SSH: Start: %v", err) log.Error("SSH: Start: %v", err)
return return 1
} }
go func() { go func() {
@@ -193,52 +148,45 @@ func sessionHandler(session ssh.Session) {
} }
} }
if err := session.Exit(getExitStatusFromError(err)); err != nil && !errors.Is(err, io.EOF) { return getExitStatusFromError(err)
log.Error("Session failed to exit. %s", err)
}
} }
func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool { func keyPermissions(keyID int64) *gossh.Permissions {
// The publicKeyHandler (PublicKeyCallback) only helps to provide the candidate keys to authenticate, return &gossh.Permissions{Extensions: map[string]string{
// It does NOT really verify here, so we could only record the related information here. giteaPermissionExtensionKeyID: strconv.FormatInt(keyID, 10),
// After authentication (Verify), the "Permissions" will be assigned to the ssh conn, }}
// then we can use it in the "session handler" }
// first, reset the ctx permissions (just like https://github.com/gliderlabs/ssh/pull/243 does)
// it shouldn't be reused across different ssh conn (sessions), each pub key should have its own "Permissions"
ctx.Permissions().Permissions = &gossh.Permissions{}
setPermExt := func(keyID int64) {
ctx.Permissions().Permissions.Extensions = map[string]string{
giteaPermissionExtensionKeyID: strconv.FormatInt(keyID, 10),
}
}
// publicKeyHandler only offers the candidate keys, it does not verify them. x/crypto assigns the
// returned Permissions to the ssh conn once it verified the signature for that key, so a user
// offering keys A (with a private key) and B (without one) authenticates and is served as A.
func publicKeyHandler(ctx context.Context, conn gossh.ConnMetadata, key gossh.PublicKey) (*gossh.Permissions, error) {
if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
log.Debug("Handle Public Key: Fingerprint: %s from %s", gossh.FingerprintSHA256(key), ctx.RemoteAddr()) log.Debug("Handle Public Key: Fingerprint: %s from %s", gossh.FingerprintSHA256(key), conn.RemoteAddr())
} }
if ctx.User() != setting.SSH.BuiltinServerUser { if conn.User() != setting.SSH.BuiltinServerUser {
log.Warn("Invalid SSH username %s - must use %s for all git operations via ssh", ctx.User(), setting.SSH.BuiltinServerUser) log.Warn("Invalid SSH username %s - must use %s for all git operations via ssh", conn.User(), setting.SSH.BuiltinServerUser)
log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) log.Warn("Failed authentication attempt from %s", conn.RemoteAddr())
return false return nil, util.ErrPermissionDenied
} }
// check if we have a certificate // check if we have a certificate
if cert, ok := key.(*gossh.Certificate); ok { if cert, ok := key.(*gossh.Certificate); ok {
if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
log.Debug("Handle Certificate: %s Fingerprint: %s is a certificate", ctx.RemoteAddr(), gossh.FingerprintSHA256(key)) log.Debug("Handle Certificate: %s Fingerprint: %s is a certificate", conn.RemoteAddr(), gossh.FingerprintSHA256(key))
} }
if len(setting.SSH.TrustedUserCAKeys) == 0 { if len(setting.SSH.TrustedUserCAKeys) == 0 {
log.Warn("Certificate Rejected: No trusted certificate authorities for this server") log.Warn("Certificate Rejected: No trusted certificate authorities for this server")
log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) log.Warn("Failed authentication attempt from %s", conn.RemoteAddr())
return false return nil, util.ErrPermissionDenied
} }
if cert.CertType != gossh.UserCert { if cert.CertType != gossh.UserCert {
log.Warn("Certificate Rejected: Not a user certificate") log.Warn("Certificate Rejected: Not a user certificate")
log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) log.Warn("Failed authentication attempt from %s", conn.RemoteAddr())
return false return nil, util.ErrPermissionDenied
} }
// look for the exact principal // look for the exact principal
@@ -247,11 +195,11 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
pkey, err := asymkey_model.SearchPublicKeyByContentExact(ctx, principal) pkey, err := asymkey_model.SearchPublicKeyByContentExact(ctx, principal)
if err != nil { if err != nil {
if asymkey_model.IsErrKeyNotExist(err) { if asymkey_model.IsErrKeyNotExist(err) {
log.Debug("Principal Rejected: %s Unknown Principal: %s", ctx.RemoteAddr(), principal) log.Debug("Principal Rejected: %s Unknown Principal: %s", conn.RemoteAddr(), principal)
continue principalLoop continue principalLoop
} }
log.Error("SearchPublicKeyByContentExact: %v", err) log.Error("SearchPublicKeyByContentExact: %v", err)
return false return nil, util.ErrPermissionDenied
} }
c := &gossh.CertChecker{ c := &gossh.CertChecker{
@@ -270,7 +218,7 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
// check the CA of the cert // check the CA of the cert
if !c.IsUserAuthority(cert.SignatureKey) { if !c.IsUserAuthority(cert.SignatureKey) {
if log.IsDebug() { if log.IsDebug() {
log.Debug("Principal Rejected: %s Untrusted Authority Signature Fingerprint %s for Principal: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(cert.SignatureKey), principal) log.Debug("Principal Rejected: %s Untrusted Authority Signature Fingerprint %s for Principal: %s", conn.RemoteAddr(), gossh.FingerprintSHA256(cert.SignatureKey), principal)
} }
continue principalLoop continue principalLoop
} }
@@ -278,44 +226,42 @@ func publicKeyHandler(ctx ssh.Context, key ssh.PublicKey) bool {
// validate the cert for this principal // validate the cert for this principal
if err := c.CheckCert(principal, cert); err != nil { if err := c.CheckCert(principal, cert); err != nil {
// User is presenting an invalid certificate - STOP any further processing // User is presenting an invalid certificate - STOP any further processing
log.Error("Invalid Certificate KeyID %s with Signature Fingerprint %s presented for Principal: %s from %s", cert.KeyId, gossh.FingerprintSHA256(cert.SignatureKey), principal, ctx.RemoteAddr()) log.Error("Invalid Certificate KeyID %s with Signature Fingerprint %s presented for Principal: %s from %s", cert.KeyId, gossh.FingerprintSHA256(cert.SignatureKey), principal, conn.RemoteAddr())
log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) log.Warn("Failed authentication attempt from %s", conn.RemoteAddr())
return false return nil, util.ErrPermissionDenied
} }
if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
log.Debug("Successfully authenticated: %s Certificate Fingerprint: %s Principal: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(key), principal) log.Debug("Successfully authenticated: %s Certificate Fingerprint: %s Principal: %s", conn.RemoteAddr(), gossh.FingerprintSHA256(key), principal)
} }
setPermExt(pkey.ID) return keyPermissions(pkey.ID), nil
return true
} }
log.Warn("From %s Fingerprint: %s is a certificate, but no valid principals found", ctx.RemoteAddr(), gossh.FingerprintSHA256(key)) log.Warn("From %s Fingerprint: %s is a certificate, but no valid principals found", conn.RemoteAddr(), gossh.FingerprintSHA256(key))
log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) log.Warn("Failed authentication attempt from %s", conn.RemoteAddr())
return false return nil, util.ErrPermissionDenied
} }
if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
log.Debug("Handle Public Key: %s Fingerprint: %s is not a certificate", ctx.RemoteAddr(), gossh.FingerprintSHA256(key)) log.Debug("Handle Public Key: %s Fingerprint: %s is not a certificate", conn.RemoteAddr(), gossh.FingerprintSHA256(key))
} }
pkey, err := asymkey_model.SearchPublicKeyByContent(ctx, strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key)))) pkey, err := asymkey_model.SearchPublicKeyByContent(ctx, strings.TrimSpace(string(gossh.MarshalAuthorizedKey(key))))
if err != nil { if err != nil {
if asymkey_model.IsErrKeyNotExist(err) { if asymkey_model.IsErrKeyNotExist(err) {
log.Warn("Unknown public key: %s from %s", gossh.FingerprintSHA256(key), ctx.RemoteAddr()) log.Warn("Unknown public key: %s from %s", gossh.FingerprintSHA256(key), conn.RemoteAddr())
log.Warn("Failed authentication attempt from %s", ctx.RemoteAddr()) log.Warn("Failed authentication attempt from %s", conn.RemoteAddr())
return false return nil, util.ErrPermissionDenied
} }
log.Error("SearchPublicKeyByContent: %v", err) log.Error("SearchPublicKeyByContent: %v", err)
return false return nil, util.ErrPermissionDenied
} }
if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary if log.IsDebug() { // <- FingerprintSHA256 is kinda expensive so only calculate it if necessary
log.Debug("Successfully authenticated: %s Public Key Fingerprint: %s", ctx.RemoteAddr(), gossh.FingerprintSHA256(key)) log.Debug("Successfully authenticated: %s Public Key Fingerprint: %s", conn.RemoteAddr(), gossh.FingerprintSHA256(key))
} }
setPermExt(pkey.ID) return keyPermissions(pkey.ID), nil
return true
} }
// sshConnectionFailed logs a failed connection // sshConnectionFailed logs a failed connection
@@ -329,25 +275,6 @@ func sshConnectionFailed(conn net.Conn, err error) {
// Listen starts an SSH server listening on given port. // Listen starts an SSH server listening on given port.
func Listen(host string, port int, ciphers, keyExchanges, macs []string) { func Listen(host string, port int, ciphers, keyExchanges, macs []string) {
srv := ssh.Server{
Addr: net.JoinHostPort(host, strconv.Itoa(port)),
PublicKeyHandler: publicKeyHandler,
Handler: sessionHandler,
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
config := &gossh.ServerConfig{}
config.KeyExchanges = keyExchanges
config.MACs = macs
config.Ciphers = ciphers
return config
},
ConnectionFailedCallback: sshConnectionFailed,
// We need to explicitly disable the PtyCallback so text displays
// properly.
PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
return false
},
}
hostKeyFiles := make([]string, 0, len(setting.SSH.ServerHostKeys)) hostKeyFiles := make([]string, 0, len(setting.SSH.ServerHostKeys))
for _, key := range setting.SSH.ServerHostKeys { for _, key := range setting.SSH.ServerHostKeys {
_, err := os.Stat(key) _, err := os.Stat(key)
@@ -372,17 +299,34 @@ func Listen(host string, port int, ciphers, keyExchanges, macs []string) {
} }
} }
var hostSigners []gossh.Signer
for _, keyFile := range hostKeyFiles { for _, keyFile := range hostKeyFiles {
log.Info("Adding SSH host key: %s", keyFile) pemBytes, err := os.ReadFile(keyFile)
err := srv.SetOption(ssh.HostKeyFile(keyFile)) if err == nil {
if err != nil { var signer gossh.Signer
log.Error("Failed to set Host Key. %s", err) if signer, err = gossh.ParsePrivateKey(pemBytes); err == nil {
log.Info("Adding SSH host key: %s", keyFile)
hostSigners = append(hostSigners, signer)
continue
}
} }
log.Error("Failed to load SSH host key %s: %v", keyFile, err)
} }
if len(hostSigners) == 0 {
log.Fatal("No usable SSH host key, tried: %v", hostKeyFiles)
}
srv := &sshServer{
addr: net.JoinHostPort(host, strconv.Itoa(port)),
hostSigners: hostSigners,
config: gossh.Config{Ciphers: ciphers, KeyExchanges: keyExchanges, MACs: macs},
}
go func() { go func() {
_, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Service: Built-in SSH server", process.SystemProcessType, true) _, _, finished := process.GetManager().AddTypedContext(graceful.GetManager().HammerContext(), "Service: Built-in SSH server", process.SystemProcessType, true)
defer finished() defer finished()
listen(&srv) listen(srv)
}() }()
} }
-34
View File
@@ -1,34 +0,0 @@
// Copyright 2019 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package ssh
import (
"gitea.dev/modules/graceful"
"gitea.dev/modules/log"
"gitea.dev/modules/setting"
"github.com/gliderlabs/ssh"
)
func listen(server *ssh.Server) {
gracefulServer := graceful.NewServer("tcp", server.Addr, "SSH")
gracefulServer.PerWriteTimeout = setting.SSH.PerWriteTimeout
gracefulServer.PerWritePerKbTimeout = setting.SSH.PerWritePerKbTimeout
err := gracefulServer.ListenAndServe(server.Serve, setting.SSH.UseProxyProtocol)
if err != nil {
select {
case <-graceful.GetManager().IsShutdown():
log.Error("Failed to start SSH server: %v", err)
default:
log.Fatal("Failed to start SSH server: %v", err)
}
}
log.Info("SSH Listener: %s Closed", server.Addr)
}
// builtinUnused informs our cleanup routine that we will not be using a ssh port
func builtinUnused() {
graceful.GetManager().InformCleanup()
}
+38
View File
@@ -4,19 +4,25 @@
package integration package integration
import ( import (
"bytes"
"fmt" "fmt"
"net"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"testing" "testing"
"time" "time"
auth_model "gitea.dev/models/auth" auth_model "gitea.dev/models/auth"
"gitea.dev/modules/git" "gitea.dev/modules/git"
"gitea.dev/modules/setting"
api "gitea.dev/modules/structs" api "gitea.dev/modules/structs"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
gossh "golang.org/x/crypto/ssh"
) )
func doCheckRepositoryEmptyStatus(ctx APITestContext, isEmpty bool) func(*testing.T) { func doCheckRepositoryEmptyStatus(ctx APITestContext, isEmpty bool) func(*testing.T) {
@@ -42,6 +48,38 @@ func doAddChangesToCheckout(dstPath, filename string) func(*testing.T) {
} }
} }
// TestSSHShellWelcome covers the "shell" request, which carries no command payload unlike "exec"
func TestSSHShellWelcome(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
ctx := NewAPITestContext(t, "user2", "repo1", auth_model.AccessTokenScopeWriteUser)
withKeyFile(t, "welcome-key", func(keyFile string) {
t.Run("CreateUserKey", doAPICreateUserKey(ctx, "welcome-key", keyFile))
privateKey, err := os.ReadFile(keyFile)
require.NoError(t, err)
signer, err := gossh.ParsePrivateKey(privateKey)
require.NoError(t, err)
client, err := gossh.Dial("tcp", net.JoinHostPort(setting.SSH.ListenHost, strconv.Itoa(setting.SSH.ListenPort)), &gossh.ClientConfig{
User: setting.SSH.BuiltinServerUser,
Auth: []gossh.AuthMethod{gossh.PublicKeys(signer)},
HostKeyCallback: gossh.InsecureIgnoreHostKey(),
})
require.NoError(t, err)
defer client.Close()
session, err := client.NewSession()
require.NoError(t, err)
var stderr bytes.Buffer
session.Stderr = &stderr // "gitea serv" writes the welcome with println, which goes to stderr
require.NoError(t, session.Shell())
require.NoError(t, session.Wait()) // fails unless the server reports exit status 0
assert.Contains(t, stderr.String(), "You've successfully authenticated with the key named welcome-key")
})
})
}
func TestPushDeployKeyOnEmptyRepo(t *testing.T) { func TestPushDeployKeyOnEmptyRepo(t *testing.T) {
onGiteaRun(t, testPushDeployKeyOnEmptyRepo) onGiteaRun(t, testPushDeployKeyOnEmptyRepo)
} }