From baccc2d7cd1f8550511742f57efa8cdd5ffc5ca3 Mon Sep 17 00:00:00 2001 From: silverwind Date: Sat, 7 Nov 2026 11:15:28 +0000 Subject: [PATCH] fix: truncate strings on character boundaries (#40267) Byte slicing like `t.Description[:255]` can split a multi-byte character and store invalid UTF-8. Team descriptions were also cut to 255 bytes although their `MaxSize(255)` validation allows 255 characters. Team descriptions now truncate by runes. Signup user agents, Actions log lines and access log request IDs use `util.TruncateStringBytes`, which now returns short strings untouched. --------- Co-authored-by: wxiaoguang --- models/user/user.go | 5 +---- modules/actions/log.go | 6 ++---- modules/util/truncate.go | 19 +++++++++++++------ modules/util/truncate_test.go | 9 +++++++++ services/context/access_log.go | 3 ++- services/org/team.go | 4 +--- 6 files changed, 28 insertions(+), 18 deletions(-) diff --git a/models/user/user.go b/models/user/user.go index eddcf69738a..0e6b118c958 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -837,10 +837,7 @@ func createUser(ctx context.Context, u *User, meta *Meta, createdByAdmin bool, o } // trim user agent string to a reasonable length, if necessary - userAgent := strings.TrimSpace(meta.InitialUserAgent) - if len(userAgent) > 255 { - userAgent = userAgent[:255] - } + userAgent := util.TruncateRunes(strings.TrimSpace(meta.InitialUserAgent), 255) if err = SetUserSetting(ctx, u.ID, SignupUserAgent, userAgent); err != nil { return err } diff --git a/modules/actions/log.go b/modules/actions/log.go index 58aff94f025..558efa7baf2 100644 --- a/modules/actions/log.go +++ b/modules/actions/log.go @@ -16,6 +16,7 @@ import ( "gitea.dev/models/dbfs" "gitea.dev/modules/log" "gitea.dev/modules/storage" + "gitea.dev/modules/util" "gitea.dev/modules/zstd" "google.golang.org/protobuf/types/known/timestamppb" @@ -213,10 +214,7 @@ func OpenLogs(ctx context.Context, inStorage bool, filename string) (io.ReadSeek func FormatLog(timestamp time.Time, content string) string { // Content shouldn't contain new line, it will break log indexes, other control chars are safe. content = strings.ReplaceAll(content, "\n", `\n`) - if len(content) > MaxLineSize { - content = content[:MaxLineSize] - } - return fmt.Sprintf("%s %s", timestamp.UTC().Format(timeFormat), content) + return fmt.Sprintf("%s %s", timestamp.UTC().Format(timeFormat), util.TruncateStringBytes(content, MaxLineSize)) } func ParseLog(in string) (time.Time, string, error) { diff --git a/modules/util/truncate.go b/modules/util/truncate.go index faca7593813..c3dff10bbcd 100644 --- a/modules/util/truncate.go +++ b/modules/util/truncate.go @@ -125,22 +125,29 @@ func EllipsisTruncateRunes(str string, limit int) (left, right string) { // TruncateRunes returns a truncated string with given rune limit, // it returns input string if its rune length doesn't exceed the limit. func TruncateRunes(str string, limit int) string { - if utf8.RuneCountInString(str) < limit { + if len(str) <= limit { return str } - return string([]rune(str)[:limit]) + for i := range str { + limit-- + if limit < 0 { + return str[:i] + } + } + return str } // TruncateStringBytes returns a truncated string with given byte limit, // it returns input string if its byte length doesn't exceed the limit. func TruncateStringBytes(str string, limit int) string { - l := 0 + if len(str) <= limit { + return str + } for i, r := range str { - rl := utf8.RuneLen(r) - if l+rl > limit { + limit -= utf8.RuneLen(r) + if limit < 0 { return str[:i] } - l += rl } return str } diff --git a/modules/util/truncate_test.go b/modules/util/truncate_test.go index 6d71f38c0c2..d47a4ae7706 100644 --- a/modules/util/truncate_test.go +++ b/modules/util/truncate_test.go @@ -129,3 +129,12 @@ func TestTruncateRunes(t *testing.T) { assert.Equal(t, "测试", TruncateRunes("测试", 2)) assert.Equal(t, "测试", TruncateRunes("测试", 3)) } + +func TestTruncateStringBytes(t *testing.T) { + assert.Equal(t, "a", TruncateStringBytes("ab", 1)) + assert.Equal(t, "ab", TruncateStringBytes("ab", 2)) + assert.Empty(t, TruncateStringBytes("测试", 2)) + assert.Equal(t, "测", TruncateStringBytes("测试", 5)) + assert.Equal(t, "测试", TruncateStringBytes("测试", 6)) + assert.Equal(t, "\xff\xff", TruncateStringBytes("\xff\xff", 2)) +} diff --git a/services/context/access_log.go b/services/context/access_log.go index e22bc5896b4..abf5ae3882b 100644 --- a/services/context/access_log.go +++ b/services/context/access_log.go @@ -15,6 +15,7 @@ import ( "gitea.dev/modules/httplib" "gitea.dev/modules/log" "gitea.dev/modules/setting" + "gitea.dev/modules/util" "gitea.dev/modules/web/middleware" ) @@ -60,7 +61,7 @@ func parseRequestIDFromRequestHeader(req *http.Request) string { return "-" } if len(requestID) > maxRequestIDByteLength { - requestID = requestID[:maxRequestIDByteLength] + "..." + requestID = util.TruncateStringBytes(requestID, maxRequestIDByteLength) + "..." } return requestID } diff --git a/services/org/team.go b/services/org/team.go index 25631cc1594..0a2d2a0d68f 100644 --- a/services/org/team.go +++ b/services/org/team.go @@ -110,9 +110,7 @@ func UpdateTeam(ctx context.Context, t *organization.Team, authChanged, includeA return util.NewInvalidArgumentErrorf("empty team name") } - if len(t.Description) > 255 { - t.Description = t.Description[:255] - } + t.Description = util.TruncateRunes(t.Description, 255) if err = db.WithTx(ctx, func(ctx context.Context) error { t.LowerName = strings.ToLower(t.Name)