mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-27 14:59:09 +01:00
247 lines
6.9 KiB
Go
247 lines
6.9 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"math"
|
|
"sync/atomic"
|
|
|
|
"github.com/gliderlabs/ssh"
|
|
log "github.com/sirupsen/logrus"
|
|
cryptossh "golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
const (
|
|
// maxSessionsPerConn bounds concurrent session channels on one SSH connection.
|
|
maxSessionsPerConn = 64
|
|
|
|
// maxDirectTCPIPPerConn bounds concurrent direct-tcpip channels, dialing or
|
|
// relaying, on one SSH connection.
|
|
maxDirectTCPIPPerConn = 512
|
|
|
|
// maxEnvRequests and maxEnvPayload bound the environment a single session can
|
|
// accumulate before its command starts.
|
|
maxEnvRequests = 128
|
|
maxEnvPayload = 8 * 1024
|
|
|
|
// maxQueuedRequests bounds the requests held for a session whose request loop is
|
|
// not consuming them. A client that exceeds it has its channel closed.
|
|
maxQueuedRequests = 256
|
|
)
|
|
|
|
type (
|
|
sessionCountKey struct{}
|
|
directTCPIPCountKey struct{}
|
|
)
|
|
|
|
// sessionChannelHandler serves "session" channels like ssh.DefaultSessionHandler, with
|
|
// the per-connection session count capped, the request stream bounded, and any panic
|
|
// from the session request loop contained to its channel.
|
|
func (s *Server) sessionChannelHandler(srv *ssh.Server, conn *cryptossh.ServerConn, newChan cryptossh.NewChannel, ctx ssh.Context) {
|
|
release, ok := acquireConnSlot(ctx, sessionCountKey{}, maxSessionsPerConn)
|
|
if !ok {
|
|
if err := newChan.Reject(cryptossh.ResourceShortage, "too many sessions"); err != nil {
|
|
log.Debugf("reject session channel: %v", err)
|
|
}
|
|
return
|
|
}
|
|
defer release()
|
|
|
|
guarded := &guardedSessionChannel{NewChannel: newChan}
|
|
defer func() {
|
|
r := recover()
|
|
if r == nil {
|
|
return
|
|
}
|
|
log.Errorf("SSH session request loop panicked from %s: %v", ctx.RemoteAddr(), r)
|
|
guarded.closeChannel()
|
|
}()
|
|
|
|
ssh.DefaultSessionHandler(srv, conn, guarded, ctx)
|
|
}
|
|
|
|
// acquireConnSlot takes one of limit slots counted under key for the connection that
|
|
// ctx belongs to. The returned func releases the slot.
|
|
func acquireConnSlot(ctx ssh.Context, key any, limit int32) (func(), bool) {
|
|
counter := connCounter(ctx, key)
|
|
if counter.Add(1) > limit {
|
|
counter.Add(-1)
|
|
return nil, false
|
|
}
|
|
return func() { counter.Add(-1) }, true
|
|
}
|
|
|
|
func connCounter(ctx ssh.Context, key any) *atomic.Int32 {
|
|
ctx.Lock()
|
|
defer ctx.Unlock()
|
|
|
|
if c, ok := ctx.Value(key).(*atomic.Int32); ok {
|
|
return c
|
|
}
|
|
c := new(atomic.Int32)
|
|
ctx.SetValue(key, c)
|
|
return c
|
|
}
|
|
|
|
// guardedSessionChannel hands the session loop a filtered request stream.
|
|
type guardedSessionChannel struct {
|
|
cryptossh.NewChannel
|
|
ch atomic.Pointer[cryptossh.Channel]
|
|
}
|
|
|
|
// Accept accepts the channel and returns its requests after filtering.
|
|
func (g *guardedSessionChannel) Accept() (cryptossh.Channel, <-chan *cryptossh.Request, error) {
|
|
ch, reqs, err := g.NewChannel.Accept()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
g.ch.Store(&ch)
|
|
return ch, filterSessionRequests(ch, reqs), nil
|
|
}
|
|
|
|
func (g *guardedSessionChannel) closeChannel() {
|
|
if ch := g.ch.Load(); ch != nil {
|
|
_ = (*ch).Close()
|
|
}
|
|
}
|
|
|
|
// refusedRequestType is the type given to a request the filter refuses. The session
|
|
// loop answers unknown request types with a failure reply, so a refused request is
|
|
// answered by the loop itself and in its position among the replies, which the
|
|
// protocol requires since replies carry no request identifier.
|
|
const refusedRequestType = "refused@netbird.io"
|
|
|
|
// requestFilter passes session channel requests to the session loop while keeping
|
|
// what the loop can accumulate or block on bounded:
|
|
// - a malformed pty-req is refused instead of reaching the loop's parser
|
|
// - env requests beyond maxEnvRequests, or with an oversized payload, are refused
|
|
// - window-change requests are refused until the command starts and coalesced
|
|
// after, as the loop blocks sending on a one-slot channel that only the command
|
|
// handler drains
|
|
// - the queue behind a stalled loop is capped, after which the channel is closed
|
|
type requestFilter struct {
|
|
ch cryptossh.Channel
|
|
queue []*cryptossh.Request
|
|
lastWinch *cryptossh.Request
|
|
started bool
|
|
envCount int
|
|
}
|
|
|
|
// filterSessionRequests returns the requests of in as the session loop should see them.
|
|
func filterSessionRequests(ch cryptossh.Channel, in <-chan *cryptossh.Request) <-chan *cryptossh.Request {
|
|
out := make(chan *cryptossh.Request)
|
|
f := &requestFilter{ch: ch}
|
|
go func() {
|
|
defer close(out)
|
|
f.run(in, out)
|
|
}()
|
|
return out
|
|
}
|
|
|
|
func (f *requestFilter) run(in <-chan *cryptossh.Request, out chan<- *cryptossh.Request) {
|
|
for {
|
|
var (
|
|
sendCh chan<- *cryptossh.Request
|
|
next *cryptossh.Request
|
|
)
|
|
if len(f.queue) > 0 {
|
|
sendCh = out
|
|
next = f.queue[0]
|
|
}
|
|
|
|
select {
|
|
case req, ok := <-in:
|
|
if !ok {
|
|
return
|
|
}
|
|
if !f.enqueue(req) {
|
|
return
|
|
}
|
|
case sendCh <- next:
|
|
f.dropHead()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *requestFilter) dropHead() {
|
|
if f.queue[0] == f.lastWinch {
|
|
f.lastWinch = nil
|
|
}
|
|
f.queue = f.queue[1:]
|
|
}
|
|
|
|
// enqueue classifies req and queues it. It returns false after closing the channel
|
|
// because the queue is full.
|
|
func (f *requestFilter) enqueue(req *cryptossh.Request) bool {
|
|
if len(f.queue) >= maxQueuedRequests {
|
|
log.Warnf("SSH session request queue full, closing channel")
|
|
_ = f.ch.Close()
|
|
return false
|
|
}
|
|
|
|
switch req.Type {
|
|
case "pty-req":
|
|
if !validPtyReqPayload(req.Payload) {
|
|
refuseRequest(req)
|
|
}
|
|
case "env":
|
|
f.envCount++
|
|
if f.envCount > maxEnvRequests || len(req.Payload) > maxEnvPayload {
|
|
refuseRequest(req)
|
|
}
|
|
case "shell", "exec", "subsystem":
|
|
f.started = true
|
|
case "window-change":
|
|
f.coalesceWindowChange(req)
|
|
}
|
|
f.queue = append(f.queue, req)
|
|
return true
|
|
}
|
|
|
|
func (f *requestFilter) coalesceWindowChange(req *cryptossh.Request) {
|
|
if !f.started || !validWinchPayload(req.Payload) {
|
|
refuseRequest(req)
|
|
return
|
|
}
|
|
if f.lastWinch != nil {
|
|
refuseRequest(f.lastWinch)
|
|
}
|
|
f.lastWinch = req
|
|
}
|
|
|
|
// validWinchPayload reports whether p parses as a window-change payload whose columns and
|
|
// rows are non-zero and fit the uint16 the PTY takes. A malformed or unusable replacement
|
|
// must not displace a valid queued resize.
|
|
func validWinchPayload(p []byte) bool {
|
|
var win struct{ Columns, Rows, Width, Height uint32 }
|
|
if err := cryptossh.Unmarshal(p, &win); err != nil {
|
|
return false
|
|
}
|
|
return usablePtyDimension(win.Columns) && usablePtyDimension(win.Rows)
|
|
}
|
|
|
|
func usablePtyDimension(v uint32) bool {
|
|
return v > 0 && v <= math.MaxUint16
|
|
}
|
|
|
|
func refuseRequest(req *cryptossh.Request) {
|
|
req.Type = refusedRequestType
|
|
req.Payload = nil
|
|
}
|
|
|
|
// validPtyReqPayload reports whether p holds a term string followed by the two
|
|
// uint32 window dimensions, without overflowing on a hostile string length. The
|
|
// dimensions must fit the Windows int16 ConPty coordinate, which is narrower than the
|
|
// Unix uint16 winsize; zero is allowed and defaulted by the Unix path.
|
|
func validPtyReqPayload(p []byte) bool {
|
|
if len(p) < 4 {
|
|
return false
|
|
}
|
|
termLen := uint64(binary.BigEndian.Uint32(p))
|
|
if uint64(len(p)) < 4+termLen+8 {
|
|
return false
|
|
}
|
|
dims := p[4+termLen:]
|
|
return binary.BigEndian.Uint32(dims) <= math.MaxInt16 &&
|
|
binary.BigEndian.Uint32(dims[4:]) <= math.MaxInt16
|
|
}
|