From 2b3ddb5d681fb3c13695aba8115af6e21432dfc6 Mon Sep 17 00:00:00 2001 From: amirmalekian Date: Thu, 9 Jul 2026 19:30:10 +0330 Subject: [PATCH 1/4] fix(protocol): prevent UTF-8 character truncation errors --- internal/protocol/protocol.go | 50 ++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 22 deletions(-) diff --git a/internal/protocol/protocol.go b/internal/protocol/protocol.go index aab5782..8593f69 100644 --- a/internal/protocol/protocol.go +++ b/internal/protocol/protocol.go @@ -135,6 +135,27 @@ func ContentHashOf(msgs []Message) uint32 { return crc32.ChecksumIEEE(data) } +func truncateUTF8Bytes(s string, max int) []byte { + if max <= 0 { + return nil + } + if len(s) <= max { + return []byte(s) + } + end := 0 + for i, r := range s { + _, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size == 0 { + break + } + if i+size > max { + break + } + end = i + size + } + return []byte(s[:end]) +} + // SerializeMetadata encodes metadata into bytes for channel 0 blocks. // Format: marker(3) + timestamp(4) + nextFetch(4) + flags(1) + channelCount(2) + per-channel data // Per-channel: nameLen(1) + name + blocks(2) + lastMsgID(4) + contentHash(4) + chatType(1) + flags(1) @@ -147,7 +168,7 @@ func SerializeMetadata(m *Metadata) []byte { // 3 marker + 4 timestamp + 4 nextFetch + 1 flags + 2 channel count + per-channel data size := MarkerSize + 4 + 4 + 1 + 2 for _, ch := range m.Channels { - size += 1 + len(ch.Name) + 2 + 4 + 4 + 1 + 1 + size += 1 + len(truncateUTF8Bytes(ch.Name, 255)) + 2 + 4 + 4 + 1 + 1 } buf := make([]byte, size) off := 0 @@ -175,10 +196,7 @@ func SerializeMetadata(m *Metadata) []byte { off += 2 for _, ch := range m.Channels { - nameBytes := []byte(ch.Name) - if len(nameBytes) > 255 { - nameBytes = nameBytes[:255] - } + nameBytes := truncateUTF8Bytes(ch.Name, 255) buf[off] = byte(len(nameBytes)) off++ copy(buf[off:], nameBytes) @@ -437,28 +455,16 @@ func CompressMessages(data []byte) []byte { func EncodeTitlesData(titles map[string]string) []byte { size := 2 for name, title := range titles { - n := name - if len(n) > 255 { - n = n[:255] - } - t := title - if len([]byte(t)) > 255 { - t = string([]byte(t)[:255]) - } - size += 1 + len(n) + 1 + len([]byte(t)) + nb := truncateUTF8Bytes(name, 255) + tb := truncateUTF8Bytes(title, 255) + size += 1 + len(nb) + 1 + len(tb) } buf := make([]byte, size) binary.BigEndian.PutUint16(buf, uint16(len(titles))) off := 2 for name, title := range titles { - nb := []byte(name) - if len(nb) > 255 { - nb = nb[:255] - } - tb := []byte(title) - if len(tb) > 255 { - tb = tb[:255] - } + nb := truncateUTF8Bytes(name, 255) + tb := truncateUTF8Bytes(title, 255) buf[off] = byte(len(nb)) off++ copy(buf[off:], nb) From 0c3a42484d730ae6e261e434163783229bfc851a Mon Sep 17 00:00:00 2001 From: amirmalekian Date: Thu, 9 Jul 2026 19:30:42 +0330 Subject: [PATCH 2/4] test(protocol): add regression tests for UTF-8 truncation safety --- internal/protocol/protocol_test.go | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/internal/protocol/protocol_test.go b/internal/protocol/protocol_test.go index 9b9efdd..41e127f 100644 --- a/internal/protocol/protocol_test.go +++ b/internal/protocol/protocol_test.go @@ -3,6 +3,7 @@ package protocol import ( "bytes" "testing" + "unicode/utf8" ) func TestSerializeParseMetadata(t *testing.T) { @@ -35,6 +36,29 @@ func TestSerializeParseMetadata(t *testing.T) { } } +func TestSerializeMetadataTruncatesLongUTF8Names(t *testing.T) { + name := string(bytes.Repeat([]byte("a"), 254)) + "خبر" + parsed, err := ParseMetadata(SerializeMetadata(&Metadata{ + Channels: []ChannelInfo{{Name: name, Blocks: 1}}, + })) + if err != nil { + t.Fatalf("ParseMetadata: %v", err) + } + if len(parsed.Channels) != 1 { + t.Fatalf("channels = %d, want 1", len(parsed.Channels)) + } + got := parsed.Channels[0].Name + if !utf8.ValidString(got) { + t.Fatalf("truncated name is invalid UTF-8: %q", got) + } + if len(got) > 255 { + t.Fatalf("truncated name is %d bytes, want <= 255", len(got)) + } + if got != string(bytes.Repeat([]byte("a"), 254)) { + t.Fatalf("truncated name = %q", got) + } +} + func TestMetadataFlags(t *testing.T) { for _, tc := range []struct { tg, chat bool @@ -50,6 +74,26 @@ func TestMetadataFlags(t *testing.T) { } } +func TestEncodeTitlesDataTruncatesLongUTF8Values(t *testing.T) { + longTitle := string(bytes.Repeat([]byte("b"), 254)) + "世界" + got, err := DecodeTitlesData(EncodeTitlesData(map[string]string{ + "news": longTitle, + })) + if err != nil { + t.Fatalf("DecodeTitlesData: %v", err) + } + title := got["news"] + if !utf8.ValidString(title) { + t.Fatalf("truncated title is invalid UTF-8: %q", title) + } + if len(title) > 255 { + t.Fatalf("truncated title is %d bytes, want <= 255", len(title)) + } + if title != string(bytes.Repeat([]byte("b"), 254)) { + t.Fatalf("truncated title = %q", title) + } +} + func TestSerializeParseMessages(t *testing.T) { original := []Message{ {ID: 100, Timestamp: 1700000000, Text: "Hello world"}, From e2e48b9483fe75280b57f4c654101e1467a9c89b Mon Sep 17 00:00:00 2001 From: amirmalekian Date: Thu, 9 Jul 2026 19:31:13 +0330 Subject: [PATCH 3/4] fix(profile-pics): use UTF-8 safe truncation for usernames --- internal/protocol/profile_pics.go | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/internal/protocol/profile_pics.go b/internal/protocol/profile_pics.go index 00ed0e7..22f6bee 100644 --- a/internal/protocol/profile_pics.go +++ b/internal/protocol/profile_pics.go @@ -86,11 +86,7 @@ func EncodeProfilePicsBundle(b ProfilePicsBundle) []byte { } size := profilePicsHeaderFixed + relayCount + 2 /*entry count*/ for _, e := range b.Entries { - n := len(e.Username) - if n > 255 { - n = 255 - } - size += 1 + n + profilePicEntryFixed + size += 1 + len(truncateUTF8Bytes(e.Username, 255)) + profilePicEntryFixed } buf := make([]byte, size) off := 0 @@ -109,10 +105,7 @@ func EncodeProfilePicsBundle(b ProfilePicsBundle) []byte { binary.BigEndian.PutUint16(buf[off:], uint16(len(b.Entries))) off += 2 for _, e := range b.Entries { - nb := []byte(e.Username) - if len(nb) > 255 { - nb = nb[:255] - } + nb := truncateUTF8Bytes(e.Username, 255) buf[off] = byte(len(nb)) off++ copy(buf[off:], nb) From 025e00aa1a8d1cfcced37f59465785b26c91163e Mon Sep 17 00:00:00 2001 From: amirmalekian Date: Thu, 9 Jul 2026 19:31:38 +0330 Subject: [PATCH 4/4] test(protocol): add regression test for multi-byte UTF-8 truncation --- internal/protocol/profile_pics_test.go | 30 +++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/internal/protocol/profile_pics_test.go b/internal/protocol/profile_pics_test.go index 13a2422..26bd5dd 100644 --- a/internal/protocol/profile_pics_test.go +++ b/internal/protocol/profile_pics_test.go @@ -4,12 +4,13 @@ import ( "hash/crc32" "strings" "testing" + "unicode/utf8" ) func TestEncodeDecodeProfilePicsBundleRoundTrip(t *testing.T) { // Three avatars concatenated into a fake bundle, with offsets/CRCs // computed for real so VerifyEntry doesn't trip on them. - a := []byte("aaaaaaaaaa") // 10 bytes + a := []byte("aaaaaaaaaa") // 10 bytes b := []byte("bbbbbbbbbbbbbb") // 14 bytes c := []byte("ccccc") // 5 bytes bundle := append(append(append([]byte{}, a...), b...), c...) @@ -123,6 +124,33 @@ func TestProfilePicsTruncatesLongUsername(t *testing.T) { } } +func TestProfilePicsTruncatesLongUTF8Username(t *testing.T) { + long := strings.Repeat("u", 254) + "نام" + in := ProfilePicsBundle{ + Header: ProfilePicsBundleHeader{Relays: []bool{true}}, + Entries: []ProfilePicEntry{ + {Username: long, Offset: 0, Size: 100, CRC: 1, MIME: 0}, + }, + } + got, err := DecodeProfilePicsBundle(EncodeProfilePicsBundle(in)) + if err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Entries) != 1 { + t.Fatalf("entries = %d, want 1", len(got.Entries)) + } + username := got.Entries[0].Username + if !utf8.ValidString(username) { + t.Fatalf("truncated username is invalid UTF-8: %q", username) + } + if len(username) > 255 { + t.Fatalf("truncated username is %d bytes, want <= 255", len(username)) + } + if username != strings.Repeat("u", 254) { + t.Fatalf("truncated username = %q", username) + } +} + func TestProfilePicsTruncatedDataReturnsError(t *testing.T) { in := ProfilePicsBundle{ Header: ProfilePicsBundleHeader{Relays: []bool{true}},