handleData subtracts the length of every data packet from the receive window, but extended data with a type code other than SSH2_EXTENDED_DATA_STDERR is then silently discarded and, since no API exists to read it, its window credit was never returned: adjustWindow only runs as a side effect of reading a stream. A peer sending extended data with an unknown type code could therefore permanently shrink the channel window, down to a complete stall of the channel. Credit the discarded bytes back through adjustWindow. adjustWindow returns io.EOF if the local side has already sent a channel close; ignore it like ReadExtended does, since an error returned here would terminate the mux read loop and tear down the whole connection. For comparison, OpenSSH rejects extended data with unexpected type codes outright and never buffers it, so the dropped bytes are simply never credited back to the misbehaving peer. Fixes golang/go#80333 Change-Id: Ia311ce3ad008e7c89ce9752f1937c91374a4e74f Reviewed-on: https://go-review.googlesource.com/c/crypto/+/802901 Reviewed-by: Mark Freeman <markfreeman@google.com> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com> Reviewed-by: Filippo Valsorda <filippo@golang.org> Reviewed-by: Cherry Mui <cherryyz@google.com>
251 lines
7.4 KiB
Go
251 lines
7.4 KiB
Go
// Copyright 2025 The Go Authors. All rights reserved.
|
|
// Use of this source code is governed by a BSD-style
|
|
// license that can be found in the LICENSE file.
|
|
|
|
package ssh
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"io"
|
|
"math/bits"
|
|
"testing"
|
|
"time"
|
|
"unsafe"
|
|
)
|
|
|
|
func TestMinPayloadSize(t *testing.T) {
|
|
// 4 GiB (2^32). Declared as a var (not a const) so that int(bigPayload)
|
|
// is a runtime conversion: a constant conversion would fail to compile
|
|
// on 32-bit platforms with "constant 4294967296 overflows int". On
|
|
// 32-bit the value truncates to 0 at runtime, but the is64Bit cases
|
|
// that reference it are skipped by the runtime check below.
|
|
var bigPayload int64 = 1 << 32
|
|
|
|
tests := []struct {
|
|
name string
|
|
maxPayload uint32
|
|
dataLen int
|
|
want uint32
|
|
is64Bit bool // Flag to run only on 64-bit architectures
|
|
}{
|
|
{
|
|
name: "Normal Case - Data fits in payload",
|
|
maxPayload: 32768,
|
|
dataLen: 1000,
|
|
want: 1000,
|
|
},
|
|
{
|
|
name: "Normal Case - Data larger than payload",
|
|
maxPayload: 32768,
|
|
dataLen: 50000,
|
|
want: 32768,
|
|
},
|
|
{
|
|
name: "Boundary Case - Data zero",
|
|
maxPayload: 32768,
|
|
dataLen: 0,
|
|
want: 0,
|
|
},
|
|
{
|
|
name: "Overflow Case - Data is exactly 4GB (1<<32)",
|
|
maxPayload: 32768,
|
|
dataLen: int(bigPayload),
|
|
want: 32768,
|
|
is64Bit: true,
|
|
},
|
|
{
|
|
name: "Overflow Case - Data is 4GB + small amount",
|
|
maxPayload: 32768,
|
|
dataLen: int(bigPayload + 100),
|
|
want: 32768,
|
|
is64Bit: true,
|
|
},
|
|
}
|
|
|
|
is64Bit := bits.UintSize == 64
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
if tt.is64Bit && !is64Bit {
|
|
t.Skip("Skipping test requiring 64-bit int")
|
|
}
|
|
got := minPayloadSize(tt.maxPayload, tt.dataLen)
|
|
if got != tt.want {
|
|
t.Errorf("minPayloadSize(%d, %d) = %d; want %d", tt.maxPayload, tt.dataLen, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWriteExtendedNoInfiniteLoopOnLargeWrite is an end-to-end regression
|
|
// test for the integer-overflow bug in WriteExtended. Before the fix, a
|
|
// write whose len(data) was a multiple of 2^32 caused minPayloadSize to
|
|
// return 0; WriteExtended then spun forever, reserving 0 bytes per
|
|
// iteration and never advancing the data slice.
|
|
//
|
|
// We exercise the real WriteExtended path with a slice whose declared
|
|
// length is exactly 2^32. Allocating 4 GiB is unnecessary: each iteration
|
|
// only reads up to maxRemotePayload bytes from the head of the slice, and
|
|
// the loop blocks in remoteWin.reserve() once the channel window is
|
|
// exhausted — before the slice base advances past the underlying buffer.
|
|
//
|
|
// With the fix, the loop blocks in reserve(); we detect that via
|
|
// waitWriterBlocked(), then close the window to let WriteExtended return.
|
|
// With the bug, the loop never blocks and the test times out.
|
|
//
|
|
//go:nocheckptr
|
|
func TestWriteExtendedNoInfiniteLoopOnLargeWrite(t *testing.T) {
|
|
if bits.UintSize < 64 {
|
|
t.Skip("test requires 64-bit int to construct a slice with len >= 2^32")
|
|
}
|
|
|
|
reader, writer, mux := channelPair(t)
|
|
defer reader.Close()
|
|
defer writer.Close()
|
|
defer mux.Close()
|
|
|
|
// Sized to hold the full pre-update remote window so that no iteration
|
|
// reads past the backing buffer before reserve() blocks.
|
|
backing := make([]byte, channelWindowSize)
|
|
var bigLen int64 = 1 << 32
|
|
bigSlice := unsafe.Slice(&backing[0], int(bigLen))
|
|
|
|
done := make(chan int, 1)
|
|
go func() {
|
|
n, _ := writer.Write(bigSlice)
|
|
done <- n
|
|
}()
|
|
|
|
blocked := make(chan struct{})
|
|
go func() {
|
|
writer.remoteWin.waitWriterBlocked()
|
|
close(blocked)
|
|
}()
|
|
|
|
select {
|
|
case <-blocked:
|
|
// Good — the loop made progress and is now blocked in reserve().
|
|
// Close the window to let WriteExtended return.
|
|
writer.remoteWin.close()
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("WriteExtended did not block in reserve within 2s — minPayloadSize likely returned 0 (integer overflow regression)")
|
|
}
|
|
|
|
select {
|
|
case n := <-done:
|
|
if n == 0 {
|
|
t.Fatalf("WriteExtended returned n=0; expected progress")
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("WriteExtended did not return after closing the window")
|
|
}
|
|
}
|
|
|
|
func TestDiscardedExtendedDataReturnsWindowCredit(t *testing.T) {
|
|
client, _, serverChans := forwardingPair(t)
|
|
|
|
clientCh, reqs, err := client.OpenChannel("test", nil)
|
|
if err != nil {
|
|
t.Fatalf("OpenChannel: %v", err)
|
|
}
|
|
go DiscardRequests(reqs)
|
|
defer clientCh.Close()
|
|
|
|
serverCh := (<-serverChans).(*channel)
|
|
defer serverCh.Close()
|
|
|
|
// Write more than the default channel receive window (2 MiB) of
|
|
// extended data with a type code other than stderr. The peer
|
|
// discards such data: if it did not return the window credit the
|
|
// write would block forever once the window is exhausted.
|
|
const payload = 4 * 1024 * 1024
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
buf := make([]byte, 32*1024)
|
|
remaining := payload
|
|
for remaining > 0 {
|
|
n := min(len(buf), remaining)
|
|
nw, err := serverCh.WriteExtended(buf[:n], 2)
|
|
if err != nil {
|
|
done <- err
|
|
return
|
|
}
|
|
remaining -= nw
|
|
}
|
|
done <- nil
|
|
}()
|
|
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("write of discarded extended data: %v", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("write of discarded extended data blocked: window credit is not returned")
|
|
}
|
|
|
|
// The main stream must still be usable.
|
|
want := []byte("main stream after extended data")
|
|
if _, err := serverCh.Write(want); err != nil {
|
|
t.Fatalf("write to main stream: %v", err)
|
|
}
|
|
got := make([]byte, len(want))
|
|
if _, err := io.ReadFull(clientCh, got); err != nil {
|
|
t.Fatalf("read from main stream: %v", err)
|
|
}
|
|
if string(got) != string(want) {
|
|
t.Fatalf("read %q from main stream, want %q", got, want)
|
|
}
|
|
|
|
// Reading the main stream payload above orders the test after all
|
|
// preceding packets: everything received has now been either read or
|
|
// discarded, so the full window credit must have been accounted.
|
|
// Anything less indicates a partial-credit leak that the liveness
|
|
// check above would not detect.
|
|
cc := clientCh.(*channel)
|
|
cc.windowMu.Lock()
|
|
window := cc.myWindow + cc.myConsumed
|
|
cc.windowMu.Unlock()
|
|
if window != channelWindowSize {
|
|
t.Errorf("myWindow+myConsumed = %d, want %d", window, channelWindowSize)
|
|
}
|
|
}
|
|
|
|
func TestDiscardedExtendedDataAfterClose(t *testing.T) {
|
|
client, _, serverChans := forwardingPair(t)
|
|
|
|
clientCh, reqs, err := client.OpenChannel("test", nil)
|
|
if err != nil {
|
|
t.Fatalf("OpenChannel: %v", err)
|
|
}
|
|
go DiscardRequests(reqs)
|
|
serverCh := <-serverChans
|
|
defer serverCh.Close()
|
|
|
|
ch := clientCh.(*channel)
|
|
// Close the channel locally: sentClose is now set, but the channel
|
|
// stays in the mux chanList until the peer's close arrives, so
|
|
// in-flight data packets are still dispatched to handleData.
|
|
if err := ch.Close(); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
// Deliver extended data packets with an unknown type code, enough to
|
|
// cross the adjustWindow send threshold of 3*maxIncomingPayload.
|
|
// The window adjust message cannot be sent after the close and
|
|
// adjustWindow fails with io.EOF: handleData must swallow that
|
|
// error, since any error it returns terminates the mux read loop
|
|
// and tears down every channel on the connection.
|
|
const packetLen = 32 * 1024
|
|
packet := make([]byte, 13+packetLen)
|
|
packet[0] = msgChannelExtendedData
|
|
binary.BigEndian.PutUint32(packet[1:], ch.localId)
|
|
binary.BigEndian.PutUint32(packet[5:], 2)
|
|
binary.BigEndian.PutUint32(packet[9:], packetLen)
|
|
for range 5 {
|
|
if err := ch.handleData(packet); err != nil {
|
|
t.Fatalf("handleData after local close: %v", err)
|
|
}
|
|
}
|
|
}
|