reject malformed protocol records and stale admin targets

Reject ACL groups and user-list entries that omit required fields.
Both handlers dereferenced optional protobuf pointers directly, so a
malformed or hostile packet crashed the client.

Read user statistics from the server counters.
The three FromServer fields were guarded by the matching FromClient
pointers, so server-side late, lost, and resync counts were reported
as zero whenever the client-side ones were absent.

Remove stale reverse links when a channel's link set is replaced.
Rebuilding the map left the other channel still pointing back at us,
and the new links were never made reciprocal.

Clamp negative ban durations to zero.
Duration is sent as an unsigned protocol field, so a negative value
became an effectively permanent ban.

Validate manual ban minutes before they reach that field.
Reject non-numeric, negative, and overflowing input in the UI rather
than converting it silently.

Confirm admin targets still exist before running an action.
A user or channel can be removed by the server while an admin prompt
is open, leaving the action pointed at an object no longer in the
connection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Brandon McGinty
2026-08-20 14:21:04 -04:00
co-authored by Claude Opus 5
parent 28b026c90f
commit b67940ddbc
9 changed files with 213 additions and 11 deletions
+24
View File
@@ -0,0 +1,24 @@
package gumble
import (
"testing"
"git.stormux.org/storm/barnard/gumble/gumble/MumbleProto"
"google.golang.org/protobuf/proto"
)
// Regression: an ACL group without its optional name dereferenced nil in the
// TCP handler, allowing malformed server data to crash the client.
func TestACLRejectsGroupWithoutName(t *testing.T) {
id := uint32(1)
packet := &MumbleProto.ACL{ChannelId: &id, Groups: []*MumbleProto.ACL_ChanGroup{{}}}
data, err := proto.MarshalOptions{AllowPartial: true}.Marshal(packet)
if err != nil {
t.Fatal(err)
}
c := &Client{Config: NewConfig(), Channels: make(Channels)}
c.Channels.create(id)
if err := c.handleACL(data); err == nil {
t.Fatal("accepted malformed ACL group")
}
}
+6
View File
@@ -16,6 +16,9 @@ type BanList []*Ban
// Add creates a new ban list entry with the given parameters.
func (b *BanList) Add(address net.IP, mask net.IPMask, reason string, duration time.Duration) *Ban {
if duration < 0 {
duration = 0
}
ban := &Ban{
Address: address,
Mask: mask,
@@ -66,6 +69,9 @@ func (b *Ban) SetReason(reason string) {
// SetDuration changes the duration of the ban.
func (b *Ban) SetDuration(duration time.Duration) {
if duration < 0 {
duration = 0
}
b.Duration = duration
}
+21
View File
@@ -0,0 +1,21 @@
package gumble
import (
"net"
"testing"
"time"
)
// Regression: negative durations were converted to uint32 seconds for the
// protocol, creating an unexpectedly huge ban rather than a safe duration.
func TestBanDurationsNeverRemainNegative(t *testing.T) {
var bans BanList
ban := bans.Add(net.ParseIP("192.0.2.1"), net.CIDRMask(32, 32), "test", -time.Minute)
if ban.Duration != 0 {
t.Fatalf("Add duration = %v", ban.Duration)
}
ban.SetDuration(-time.Second)
if ban.Duration != 0 {
t.Fatalf("SetDuration = %v", ban.Duration)
}
}
@@ -0,0 +1,27 @@
package gumble
import (
"testing"
"git.stormux.org/storm/barnard/gumble/gumble/MumbleProto"
"google.golang.org/protobuf/proto"
)
// Regression: a full channel-link update used to leave the removed peer's
// reverse link behind, making the client report a link that no longer exists.
func TestChannelStateFullLinksRemovesReverseLinks(t *testing.T) {
c := &Client{Config: NewConfig(), Channels: make(Channels)}
a, b, replacement := c.Channels.create(1), c.Channels.create(2), c.Channels.create(3)
a.Links[b.ID], b.Links[a.ID] = b, a
id := a.ID
data, _ := proto.Marshal(&MumbleProto.ChannelState{ChannelId: &id, Links: []uint32{replacement.ID}})
if err := c.handleChannelState(data); err != nil {
t.Fatal(err)
}
if _, ok := b.Links[a.ID]; ok {
t.Fatal("stale reciprocal link remains")
}
if replacement.Links[a.ID] != a {
t.Fatal("replacement reciprocal link missing")
}
}
+17 -6
View File
@@ -394,11 +394,16 @@ func (c *Client) handleChannelState(buffer []byte) error {
channel.Name = *packet.Name
}
if packet.Links != nil {
channel.Links = make(Channels)
// A full replacement must also remove our old reciprocal links.
for oldID, old := range channel.Links {
delete(old.Links, channel.ID)
delete(channel.Links, oldID)
}
event.Type |= ChannelChangeLinks
for _, channelID := range packet.Links {
if c := c.Channels[channelID]; c != nil {
channel.Links[channelID] = c
if linked := c.Channels[channelID]; linked != nil {
channel.Links[channelID] = linked
linked.Links[channel.ID] = channel
}
}
}
@@ -827,6 +832,9 @@ func (c *Client) handleACL(buffer []byte) error {
if packet.Groups != nil {
acl.Groups = make([]*ACLGroup, 0, len(packet.Groups))
for _, group := range packet.Groups {
if group == nil || group.Name == nil {
return errInvalidProtobuf
}
aclGroup := &ACLGroup{
Name: *group.Name,
Inherited: group.GetInherited(),
@@ -1011,6 +1019,9 @@ func (c *Client) handleUserList(buffer []byte) error {
}
for _, user := range packet.Users {
if user == nil || user.UserId == nil {
return errInvalidProtobuf
}
registeredUser := &RegisteredUser{
UserID: *user.UserId,
}
@@ -1171,13 +1182,13 @@ func (c *Client) handleUserStats(buffer []byte) error {
if packet.FromServer.Good != nil {
stats.FromServer.Good = *packet.FromServer.Good
}
if packet.FromClient.Late != nil {
if packet.FromServer.Late != nil {
stats.FromServer.Late = *packet.FromServer.Late
}
if packet.FromClient.Lost != nil {
if packet.FromServer.Lost != nil {
stats.FromServer.Lost = *packet.FromServer.Lost
}
if packet.FromClient.Resync != nil {
if packet.FromServer.Resync != nil {
stats.FromServer.Resync = *packet.FromServer.Resync
}
}
@@ -0,0 +1,26 @@
package gumble
import (
"testing"
"git.stormux.org/storm/barnard/gumble/gumble/MumbleProto"
"google.golang.org/protobuf/proto"
)
// Regression: FromServer loss counters were accidentally copied from
// FromClient, hiding the server-to-client packet-loss condition.
func TestUserStatsUsesFromServerCounters(t *testing.T) {
c := &Client{Config: NewConfig(), Users: make(Users)}
u := c.Users.create(7)
session := uint32(7)
clientLate, serverLate := uint32(1), uint32(9)
data, _ := proto.Marshal(&MumbleProto.UserStats{Session: &session,
FromClient: &MumbleProto.UserStats_Stats{Late: &clientLate},
FromServer: &MumbleProto.UserStats_Stats{Late: &serverLate}})
if err := c.handleUserStats(data); err != nil {
t.Fatal(err)
}
if u.Stats.FromServer.Late != serverLate {
t.Fatalf("got %d, want %d", u.Stats.FromServer.Late, serverLate)
}
}