mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-28 07:19:08 +01:00
* Bound the SSH session request queue by bytes and stop resizes reaching a loop with no consumer * Give the window change drain loop a body * Avoid an empty case clause in the SSH request filter * Charge only the newest resize to the request queue and classify requests before the byte limit
321 lines
9.0 KiB
Go
321 lines
9.0 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 and maxQueuedBytes bound the requests held for a session whose
|
|
// request loop is not consuming them. A client that exceeds either has its channel
|
|
// closed.
|
|
maxQueuedRequests = 128
|
|
maxQueuedBytes = 256 * 1024
|
|
)
|
|
|
|
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)
|
|
}
|
|
|
|
// drainWindowChanges discards window changes until winCh is closed. It must run after
|
|
// the session's command handler has returned.
|
|
func drainWindowChanges(winCh <-chan ssh.Window) {
|
|
go func() {
|
|
for {
|
|
if _, ok := <-winCh; !ok {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// 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 unless the session's command is a shell or
|
|
// exec, and coalesced after it starts, as the loop blocks sending on a one-slot
|
|
// channel that only a shell or exec handler drains. Subsystems never read it.
|
|
// - request types the loop does not act on are refused, which also drops their payload
|
|
// - the queue behind a stalled loop is capped by count and by payload bytes, after
|
|
// which the channel is closed
|
|
type requestFilter struct {
|
|
ch cryptossh.Channel
|
|
queue []*queuedRequest
|
|
queuedBytes int
|
|
lastWinch *queuedRequest
|
|
envCount int
|
|
|
|
// commandSeen is set by the first shell, exec or subsystem request, which decides
|
|
// the session's command as far as the loop is concerned.
|
|
commandSeen bool
|
|
// started is set when that first command is a shell or exec.
|
|
started bool
|
|
}
|
|
|
|
// queuedRequest remembers the payload size counted against the queue, as a refusal
|
|
// drops the payload and releases that size.
|
|
type queuedRequest struct {
|
|
req *cryptossh.Request
|
|
size 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].req
|
|
}
|
|
|
|
select {
|
|
case req, ok := <-in:
|
|
if !ok {
|
|
return
|
|
}
|
|
if !f.enqueue(req) {
|
|
return
|
|
}
|
|
case sendCh <- next:
|
|
f.dropHead()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (f *requestFilter) dropHead() {
|
|
head := f.queue[0]
|
|
if head == f.lastWinch {
|
|
f.lastWinch = nil
|
|
}
|
|
f.queuedBytes -= head.size
|
|
f.queue[0] = nil
|
|
f.queue = f.queue[1:]
|
|
}
|
|
|
|
// enqueue classifies req and queues it. It returns false after closing the channel
|
|
// because the queue is full. Requests are classified first so a refused request's
|
|
// dropped payload does not count against the limit.
|
|
func (f *requestFilter) enqueue(req *cryptossh.Request) bool {
|
|
q := &queuedRequest{req: req}
|
|
f.classify(q)
|
|
q.size = len(req.Payload)
|
|
|
|
if len(f.queue) >= maxQueuedRequests || f.queuedBytes+q.size > maxQueuedBytes {
|
|
log.Warnf("SSH session request queue full, closing channel")
|
|
if f.ch != nil {
|
|
_ = f.ch.Close()
|
|
}
|
|
return false
|
|
}
|
|
|
|
f.queue = append(f.queue, q)
|
|
f.queuedBytes += q.size
|
|
return true
|
|
}
|
|
|
|
func (f *requestFilter) classify(q *queuedRequest) {
|
|
req := q.req
|
|
switch req.Type {
|
|
case "pty-req":
|
|
if !validPtyReqPayload(req.Payload) {
|
|
f.refuse(q)
|
|
}
|
|
case "env":
|
|
f.envCount++
|
|
if f.envCount > maxEnvRequests || len(req.Payload) > maxEnvPayload {
|
|
f.refuse(q)
|
|
}
|
|
case "shell", "exec", "subsystem":
|
|
f.noteCommand(req.Type)
|
|
case "window-change":
|
|
f.coalesceWindowChange(q)
|
|
default:
|
|
if !loopActsOn(req.Type) {
|
|
f.refuse(q)
|
|
}
|
|
}
|
|
}
|
|
|
|
// refuse drops the payload of q and releases the size it was charged against the queue.
|
|
func (f *requestFilter) refuse(q *queuedRequest) {
|
|
f.queuedBytes -= q.size
|
|
q.size = 0
|
|
refuseRequest(q.req)
|
|
}
|
|
|
|
func (f *requestFilter) noteCommand(reqType string) {
|
|
if f.commandSeen {
|
|
return
|
|
}
|
|
f.commandSeen = true
|
|
f.started = reqType != "subsystem"
|
|
}
|
|
|
|
func (f *requestFilter) coalesceWindowChange(q *queuedRequest) {
|
|
if !f.started || !validWinchPayload(q.req.Payload) {
|
|
f.refuse(q)
|
|
return
|
|
}
|
|
if f.lastWinch != nil {
|
|
f.refuse(f.lastWinch)
|
|
}
|
|
f.lastWinch = q
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// loopActsOn reports whether the session loop does anything with a request type other
|
|
// than the ones the filter classifies itself. The loop answers every other type with a
|
|
// failure, so its payload is not worth queueing.
|
|
func loopActsOn(reqType string) bool {
|
|
switch reqType {
|
|
case "signal", "break", "auth-agent-req@openssh.com":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
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
|
|
}
|