diff --git a/go.mod b/go.mod index 701be2e..2865ccc 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( go.opentelemetry.io/otel/metric v1.45.0 go.opentelemetry.io/otel/sdk/metric v1.45.0 golang.org/x/crypto v0.54.0 + golang.org/x/sys v0.47.0 ) require ( @@ -47,7 +48,6 @@ require ( golang.org/x/mod v0.38.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/tools v0.48.0 // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 68c66a4..77cd693 100644 --- a/go.sum +++ b/go.sum @@ -67,8 +67,6 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sower-proxy/conns v0.1.1 h1:9VPVW+sFc/y/IvXbUQTbXNSPF3I+1IG5mbrQTt5bdTE= github.com/sower-proxy/conns v0.1.1/go.mod h1:p3ZM/8MolzW4TN0LaiPJnwnhhif3y9sBi2vQEWHvwhg= -github.com/sower-proxy/deferlog/v2 v2.1.4 h1:Vo/iVWGw3Su625RP2RIy4mE5FlfRdfrgP7kwOWNOyec= -github.com/sower-proxy/deferlog/v2 v2.1.4/go.mod h1:mdLrL/bxiTNhxeRLtRgKAecQddugI0iJ0ArC3yDwVSw= github.com/sower-proxy/deferlog/v2 v2.1.5 h1:3MF21JmCbIOI7ROWqwfBDDX4Y6g3EkDp4p7TZxJFyyg= github.com/sower-proxy/deferlog/v2 v2.1.5/go.mod h1:mdLrL/bxiTNhxeRLtRgKAecQddugI0iJ0ArC3yDwVSw= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= diff --git a/pkg/dhcp/dhcp.go b/pkg/dhcp/dhcp.go index 68a88e7..e98d8a7 100644 --- a/pkg/dhcp/dhcp.go +++ b/pkg/dhcp/dhcp.go @@ -1,6 +1,7 @@ package dhcp import ( + "bytes" "crypto/rand" "fmt" "net" @@ -40,19 +41,43 @@ func GetDNSServer() ([]string, error) { } } defer conn.Close() - conn.SetDeadline(time.Now().Add(3 * time.Second)) + // Broadcasting to 255.255.255.255 requires SO_BROADCAST on most stacks. + if c, ok := conn.(*net.UDPConn); ok { + if raw, err := c.SyscallConn(); err == nil { + _ = raw.Control(func(fd uintptr) { + _ = setBroadcast(int(fd)) + }) + } + } + _ = conn.SetDeadline(time.Now().Add(3 * time.Second)) if _, err := conn.WriteTo([]byte(pack), broadcastAddr); err != nil { return nil, fmt.Errorf("write broadcast: %w", err) } + // The socket may receive packets from other DHCP transactions (the + // system dhclient shares port 68 via SO_REUSEPORT) or spoofed LAN + // traffic; accept only the OFFER that belongs to this transaction. buf := make([]byte, 1500 /*MTU*/) - n, _, err := conn.ReadFrom(buf) - if err != nil { - return nil, fmt.Errorf("read dhcp offer: %w", err) + for { + n, _, err := conn.ReadFrom(buf) + if err != nil { + return nil, fmt.Errorf("read dhcp offer: %w", err) + } + pack = dhcp4.Packet(buf[:n]) + if !bytes.Equal(pack.XId(), xid) { + continue // another transaction's packet + } + if pack.OpCode() != dhcp4.BootReply { + continue + } + opts := pack.ParseOptions() + if mt := opts[dhcp4.OptionDHCPMessageType]; len(mt) == 0 || mt[0] != byte(dhcp4.Offer) { + continue + } + break } - pack = dhcp4.Packet(buf[:n]) dnsBytes := pack.ParseOptions()[dhcp4.OptionDomainNameServer] if len(dnsBytes) < 4 || len(dnsBytes)%4 != 0 { return nil, errors.New("invalid DNS setting in upstream network device") diff --git a/pkg/dhcp/set_broadcast_unix.go b/pkg/dhcp/set_broadcast_unix.go new file mode 100644 index 0000000..c1c446e --- /dev/null +++ b/pkg/dhcp/set_broadcast_unix.go @@ -0,0 +1,11 @@ +//go:build !windows + +package dhcp + +import "golang.org/x/sys/unix" + +// setBroadcast enables SO_BROADCAST so the DHCP discover broadcast to +// 255.255.255.255 is delivered on stacks that require the explicit option. +func setBroadcast(fd int) error { + return unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_BROADCAST, 1) +} diff --git a/pkg/dhcp/set_broadcast_windows.go b/pkg/dhcp/set_broadcast_windows.go new file mode 100644 index 0000000..db36d9f --- /dev/null +++ b/pkg/dhcp/set_broadcast_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package dhcp + +import "golang.org/x/sys/windows" + +// setBroadcast enables SO_BROADCAST; Winsock requires it for broadcasts. +func setBroadcast(fd int) error { + return windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_BROADCAST, 1) +} diff --git a/pkg/suffixtree/suffix_tree.go b/pkg/suffixtree/suffix_tree.go index 75bb7d6..fc27949 100644 --- a/pkg/suffixtree/suffix_tree.go +++ b/pkg/suffixtree/suffix_tree.go @@ -57,7 +57,9 @@ func (n *node) string(prefix, indent string) (out string) { } func (n *Node) trim(item string) string { - return strings.ToLower(strings.TrimSuffix(item, n.sep)) + // Trim every trailing separator so a rule with repeated dots (dirty + // remote rule files) cannot build an unreachable empty-label subtree. + return strings.ToLower(strings.TrimRight(item, n.sep)) } func (n *Node) Add(item string) { diff --git a/pkg/upstreamtls/upstreamtls.go b/pkg/upstreamtls/upstreamtls.go index 17d4774..458eaf7 100644 --- a/pkg/upstreamtls/upstreamtls.go +++ b/pkg/upstreamtls/upstreamtls.go @@ -16,6 +16,10 @@ type Options struct { InsecureSkipVerify bool } +// defaultHandshakeTimeout bounds the uTLS handshake when the dialer carries +// no timeout of its own. +const defaultHandshakeTimeout = 10 * time.Second + func ValidateClientHello(value string) error { _, err := clientHelloID(value) return err @@ -39,13 +43,17 @@ func Dial(dialer *net.Dialer, network, addr string, options Options) (net.Conn, return nil, err } - if timeout := dialer.Timeout; timeout > 0 { - if err := rawConn.SetDeadline(time.Now().Add(timeout)); err != nil { - rawConn.Close() - return nil, fmt.Errorf("set tls deadline: %w", err) - } - defer rawConn.SetDeadline(time.Time{}) + timeout := dialer.Timeout + if timeout <= 0 { + // A zero-timeout dialer must not handshake forever; fall back to a + // bounded default so the connection cannot hang open indefinitely. + timeout = defaultHandshakeTimeout } + if err := rawConn.SetDeadline(time.Now().Add(timeout)); err != nil { + rawConn.Close() + return nil, fmt.Errorf("set tls deadline: %w", err) + } + defer rawConn.SetDeadline(time.Time{}) serverName := options.ServerName if serverName == "" { diff --git a/transport/socks5/socks5.go b/transport/socks5/socks5.go index 447f4ac..8d21633 100644 --- a/transport/socks5/socks5.go +++ b/transport/socks5/socks5.go @@ -57,6 +57,11 @@ func (s *Socks5) ReadRequest(conn net.Conn) (net.Addr, error) { return nil, fmt.Errorf("read auth request: %w", err) } if !auth.IsValid() { + // RFC 1928 requires a METHOD=0xFF failure response so the peer + // does not hang waiting for a selection it will never get. + if err := binary.Write(conn, binary.BigEndian, authResp{VER: 5, METHOD: 0xFF}); err != nil { + return nil, fmt.Errorf("write auth failure: %w", err) + } return nil, errors.New("no acceptable auth method") } diff --git a/transport/sower/sower.go b/transport/sower/sower.go index c29ef39..3d4c087 100644 --- a/transport/sower/sower.go +++ b/transport/sower/sower.go @@ -2,6 +2,7 @@ package sower import ( "bytes" + "crypto/hmac" "crypto/sha256" "encoding/binary" "fmt" @@ -62,10 +63,25 @@ func (s *Sower) Unwrap(conn net.Conn) (net.Addr, error) { return nil, fmt.Errorf("invalid command: %d", h.Cmd) } - if h.Checksum != sumChecksum(h.TgtAddr, s.password) { + if h.Checksum != sumChecksum(h.Cmd, h.Port, h.TgtAddr, s.password) { return nil, errors.New("auth fail") } + // Reject hosts that could corrupt dialing or logging: empty targets and + // control characters are never legitimate. + idx := bytes.IndexByte(h.TgtAddr[:], 0) + if idx < 0 { + idx = len(h.TgtAddr) + } + if idx == 0 { + return nil, errors.New("empty target host") + } + for _, c := range h.TgtAddr[:idx] { + if c < 0x20 || c == 0x7f { + return nil, errors.New("target host contains control characters") + } + } + return h, nil } @@ -76,11 +92,12 @@ func (s *Sower) Wrap(conn net.Conn, tgtHost string, tgtPort uint16) error { tgtAddr := [maxDomainLength]byte{} copy(tgtAddr[:len(tgtHost)], tgtHost) + cmd := byte(0x80) buf := bytes.NewBuffer(make([]byte, 0, headSize)) if err := binary.Write(buf, binary.BigEndian, &Head{ - Cmd: 0x80, - Checksum: sumChecksum(tgtAddr, s.password), + Cmd: cmd, + Checksum: sumChecksum(cmd, tgtPort, tgtAddr, s.password), Port: tgtPort, TgtAddr: tgtAddr, }); err != nil { @@ -92,15 +109,17 @@ func (s *Sower) Wrap(conn net.Conn, tgtHost string, tgtPort uint16) error { return nil } -func sumChecksum(target [maxDomainLength]byte, password []byte) uint64 { - h := sha256.New() - h.Write(target[:]) - h.Write(password) - var checksum [sha256.Size]byte - h.Sum(checksum[:0]) - // Take a fixed 64-bit window of the digest. binary.Uvarint would only - // consume bytes until the first continuation bit is clear (typically the - // first byte, ~50% of the time), collapsing the checksum to 7 bits of - // entropy and making the auth check brute-forceable. - return binary.BigEndian.Uint64(checksum[:8]) +// sumChecksum authenticates the whole frame: command, port, and target are +// all covered, so a man-in-the-middle on a plaintext link cannot retarget a +// captured frame to a different port without breaking the check. HMAC-SHA256 +// keeps the password out of the hashed message (length-extension resistant) +// and off the wire in a non-invertible form. +func sumChecksum(cmd byte, port uint16, target [maxDomainLength]byte, password []byte) uint64 { + mac := hmac.New(sha256.New, password) + mac.Write([]byte{cmd}) + var portBuf [2]byte + binary.BigEndian.PutUint16(portBuf[:], port) + mac.Write(portBuf[:]) + mac.Write(target[:]) + return binary.BigEndian.Uint64(mac.Sum(nil)[:8]) } diff --git a/transport/sower/sower_test.go b/transport/sower/sower_test.go index 4bf0ca9..69a58d5 100644 --- a/transport/sower/sower_test.go +++ b/transport/sower/sower_test.go @@ -47,7 +47,7 @@ func TestUnwrapReadsFullHeader(t *testing.T) { buf := bytes.NewBuffer(nil) if err := binary.Write(buf, binary.BigEndian, &Head{ Cmd: 0x80, - Checksum: sumChecksum(target, []byte("123")), + Checksum: sumChecksum(0x80, 443, target, []byte("123")), Port: 443, TgtAddr: target, }); err != nil {