Commit 0030cf8082

0030cf80820c6aff9c0806d6602cd1a77d646c57

parent: 3beb0f5b68

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-20 09:41 UTC

push: truncate alert bodies on a rune boundary

A raw byte cut at maxBodyBytes could split a multi-byte UTF-8
character; encoding/json then replaces the dangling bytes with
U+FFFD, so the sent alert grows past the limit and shows a garbled
character where the text was cut. Trim to a valid UTF-8 prefix
instead. Also fixes the collapse-id test, which was checking the
decoded JSON body for what is actually an HTTP header.

Ref #89
internal/push/apns.go +6 −1
@@ -9,6 +9,7 @@ import (
99 "io"
1010 "net/http"
1111 "strconv"
12 "strings"
1213 "time"
1314
1415 "gitbay.org/gitbay/internal/config"
@@ -62,7 +63,11 @@ func NewClient(cfg config.Push) (*Client, error) {
6263// Retry-After when it gave one, zero otherwise.
6364func (c *Client) Send(ctx context.Context, token, title, body, path string) (result, time.Duration, error) {
6465 if len(body) > maxBodyBytes {
65 body = body[:maxBodyBytes]
66 // A raw byte cut can land mid-rune on multi-byte UTF-8 (emoji,
67 // accents, non-Latin usernames). ToValidUTF8 drops the
68 // resulting dangling bytes instead of leaving them for
69 // encoding/json to turn into a garbled U+FFFD.
70 body = strings.ToValidUTF8(body[:maxBodyBytes], "")
6671 }
6772 payload, err := json.Marshal(map[string]any{
6873 "aps": map[string]any{
internal/push/apns_test.go +34 −4
@@ -9,6 +9,7 @@ import (
99 "strings"
1010 "testing"
1111 "time"
12 "unicode/utf8"
1213
1314 "gitbay.org/gitbay/internal/config"
1415)
@@ -34,11 +35,12 @@ func fakeAPNs(t *testing.T, h http.HandlerFunc) (*Client, *httptest.Server) {
3435}
3536
3637func TestSendShapesTheRequest(t *testing.T) {
37 var gotPath, gotTopic, gotType, gotAuth string
38 var gotPath, gotTopic, gotType, gotAuth, gotCollapse string
3839 var payload map[string]any
3940 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
4041 gotPath, gotTopic = r.URL.Path, r.Header.Get("apns-topic")
4142 gotType, gotAuth = r.Header.Get("apns-push-type"), r.Header.Get("authorization")
43 gotCollapse = r.Header.Get("apns-collapse-id")
4244 raw, _ := io.ReadAll(r.Body)
4345 json.Unmarshal(raw, &payload)
4446 w.WriteHeader(200)
@@ -67,9 +69,11 @@ func TestSendShapesTheRequest(t *testing.T) {
6769 if payload["path"] != "krz/gitbay/issues/12" {
6870 t.Fatalf("path = %v", payload["path"])
6971 }
70 // Collapsing is wrong here: two comments are two notices.
71 if _, ok := payload["apns-collapse-id"]; ok {
72 t.Fatal("collapse id set")
72 // Collapsing is wrong here: two comments are two notices. This is an
73 // APNs HTTP header, not a body field, so it must be checked on the
74 // request the handler received, not on the decoded JSON payload.
75 if gotCollapse != "" {
76 t.Fatalf("apns-collapse-id = %q, want unset", gotCollapse)
7377 }
7478}
7579
@@ -118,3 +122,29 @@ func TestSendMapsResponses(t *testing.T) {
118122 })
119123 }
120124}
125
126func TestSendTruncatesBodyOnRuneBoundary(t *testing.T) {
127 var payload map[string]any
128 c, _ := fakeAPNs(t, func(w http.ResponseWriter, r *http.Request) {
129 raw, _ := io.ReadAll(r.Body)
130 json.Unmarshal(raw, &payload)
131 w.WriteHeader(200)
132 })
133 // A leading ASCII byte shifts every following two-byte rune off an
134 // even offset, so a raw cut at maxBodyBytes is guaranteed to land on
135 // the second byte of one of them rather than a rune boundary.
136 long := "x" + strings.Repeat("é", 2000)
137 res, _, err := c.Send(context.Background(), "T", "t", long, "p")
138 if err != nil || res != resultSent {
139 t.Fatalf("res = %v, err = %v", res, err)
140 }
141 aps := payload["aps"].(map[string]any)
142 alert := aps["alert"].(map[string]any)
143 body := alert["body"].(string)
144 if !utf8.ValidString(body) {
145 t.Fatalf("body is not valid UTF-8: %q", body)
146 }
147 if len(body) > maxBodyBytes {
148 t.Fatalf("body is %d bytes, want <= %d", len(body), maxBodyBytes)
149 }
150}