Skip to content

Commit 10dc44f

Browse files
authored
fix(push): fix peeking when push name is truncated (#3842)
* fix(push): Fix peeking when push name is truncated * fix(push): fix peeking malformated push * protect agains potential panic
1 parent e1a2d68 commit 10dc44f

3 files changed

Lines changed: 378 additions & 59 deletions

File tree

internal/proto/peek_push_notification_test.go

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package proto
33
import (
44
"bytes"
55
"fmt"
6+
"io"
7+
"math"
68
"math/rand"
9+
"strconv"
710
"strings"
811
"testing"
912
)
@@ -621,3 +624,161 @@ func TestPeekPushNotificationNameBehavior(t *testing.T) {
621624
t.Log("Note: Buffer must be primed with a peek operation first")
622625
})
623626
}
627+
628+
// chunkedReader hands its data out in fixed-size chunks, one per Read. This
629+
// lets us deterministically reproduce the bufio fill boundary that landed in
630+
// the middle of a push frame header in issue #3839.
631+
type chunkedReader struct {
632+
data []byte
633+
chunkSize int
634+
off int
635+
}
636+
637+
func (r *chunkedReader) Read(p []byte) (int, error) {
638+
if r.off >= len(r.data) {
639+
return 0, io.EOF
640+
}
641+
n := r.chunkSize
642+
if n > len(p) {
643+
n = len(p)
644+
}
645+
if r.off+n > len(r.data) {
646+
n = len(r.data) - r.off
647+
}
648+
copy(p, r.data[r.off:r.off+n])
649+
r.off += n
650+
return n, nil
651+
}
652+
653+
// TestPeekPushNotificationName_TruncatedHeader covers issue #3839: when only
654+
// a prefix of a push frame is buffered, PeekPushNotificationName must NOT
655+
// return a truncated name. It should block to fetch the rest of the header
656+
// (so the caller can identify the notification correctly) or, when the
657+
// underlying read fails, return that error.
658+
func TestPeekPushNotificationName_TruncatedHeader(t *testing.T) {
659+
const frame = ">3\r\n$7\r\nmessage\r\n$7\r\nchannel\r\n$5\r\nhello\r\n"
660+
661+
// Boundaries that previously caused PeekPushNotificationName to silently
662+
// return a truncated name. `prefix` is the number of bytes the chunked
663+
// reader hands out per Read call; with the bug, every chunk boundary that
664+
// lands inside the frame header is a place where the function returned a
665+
// truncated name instead of blocking for the rest.
666+
cutpoints := []struct {
667+
name string
668+
prefix int
669+
}{
670+
{"after_push_marker", 1},
671+
{"inside_array_len_line", 3},
672+
{"after_array_len_line", 4},
673+
{"after_dollar", 5},
674+
{"inside_bulk_len_line", 6},
675+
{"after_bulk_len_line", 8},
676+
{"inside_name", 13}, // the exact failure mode from #3839 ("messa")
677+
{"before_name_crlf", 15},
678+
}
679+
680+
for _, tc := range cutpoints {
681+
t.Run(tc.name, func(t *testing.T) {
682+
data := []byte(frame)
683+
// Use a chunked reader that hands out tc.prefix bytes per Read
684+
// call (not per fill). bufio's first fill therefore returns only
685+
// tc.prefix bytes; satisfying a Peek that wants more forces
686+
// additional Reads, each capped at tc.prefix bytes, until the
687+
// peek window is full. That sequence reproduces the partial-fill
688+
// boundary from issue #3839 where the buffered prefix landed in
689+
// the middle of the push frame header.
690+
rd := NewReader(&chunkedReader{data: data, chunkSize: tc.prefix})
691+
692+
if _, err := rd.PeekReplyType(); err != nil {
693+
t.Fatalf("PeekReplyType: %v", err)
694+
}
695+
696+
name, err := rd.PeekPushNotificationName()
697+
if err != nil {
698+
t.Fatalf("PeekPushNotificationName: unexpected error: %v", err)
699+
}
700+
if name != "message" {
701+
t.Fatalf("PeekPushNotificationName: got %q, want %q", name, "message")
702+
}
703+
704+
// And the full frame must still be readable afterwards.
705+
reply, err := rd.ReadReply()
706+
if err != nil {
707+
t.Fatalf("ReadReply: %v", err)
708+
}
709+
arr, ok := reply.([]interface{})
710+
if !ok || len(arr) != 3 || arr[0] != "message" || arr[1] != "channel" || arr[2] != "hello" {
711+
t.Fatalf("ReadReply: got %#v, want [message channel hello]", reply)
712+
}
713+
})
714+
}
715+
}
716+
717+
// TestPeekPushNotificationName_TruncatedHeaderUnderlyingEOF covers the case
718+
// where the underlying connection delivers a partial frame and then closes.
719+
// PeekPushNotificationName must surface the read error rather than returning
720+
// a truncated name.
721+
func TestPeekPushNotificationName_TruncatedHeaderUnderlyingEOF(t *testing.T) {
722+
// A push frame that ends mid-name.
723+
data := []byte(">3\r\n$7\r\nmessa")
724+
rd := NewReader(bytes.NewReader(data))
725+
726+
if _, err := rd.PeekReplyType(); err != nil {
727+
t.Fatalf("PeekReplyType: %v", err)
728+
}
729+
730+
name, err := rd.PeekPushNotificationName()
731+
if err == nil {
732+
t.Fatalf("PeekPushNotificationName: want error, got name=%q", name)
733+
}
734+
if name != "" {
735+
t.Fatalf("PeekPushNotificationName: want empty name on error, got %q", name)
736+
}
737+
}
738+
739+
// TestPeekPushNotificationName_EmptyArrayLength asserts that ">\r\n" is
740+
// reported as a malformed frame rather than an incomplete prefix. RESP
741+
// requires at least one digit after '>' for the array length; without the
742+
// empty-length check in parsePushNotificationName the parser would treat
743+
// ">\r\n" as a valid prefix and PeekPushNotificationName would block
744+
// waiting for the rest of a frame that is already corrupt.
745+
func TestPeekPushNotificationName_EmptyArrayLength(t *testing.T) {
746+
rd := NewReader(bytes.NewReader([]byte(">\r\n")))
747+
748+
if _, err := rd.PeekReplyType(); err != nil {
749+
t.Fatalf("PeekReplyType: %v", err)
750+
}
751+
752+
name, err := rd.PeekPushNotificationName()
753+
if err == nil {
754+
t.Fatalf("PeekPushNotificationName: want error for empty array length, got name=%q", name)
755+
}
756+
if name != "" {
757+
t.Fatalf("PeekPushNotificationName: want empty name on error, got %q", name)
758+
}
759+
if !strings.Contains(err.Error(), "empty push notification array length") {
760+
t.Fatalf("PeekPushNotificationName: error should mention empty array length, got %v", err)
761+
}
762+
}
763+
764+
// TestPeekPushNotificationName_OverflowingNameLength asserts that a frame
765+
// advertising a name length near math.MaxInt does not panic. Computing
766+
// next+nameLen would overflow int and wrap negative, slipping past a naive
767+
// "end > len(buf)" guard and panicking the backing slice; the parser must
768+
// instead treat it as incomplete and surface an error without crashing.
769+
func TestPeekPushNotificationName_OverflowingNameLength(t *testing.T) {
770+
data := []byte(">1\r\n$" + strconv.Itoa(math.MaxInt) + "\r\n")
771+
rd := NewReader(bytes.NewReader(data))
772+
773+
if _, err := rd.PeekReplyType(); err != nil {
774+
t.Fatalf("PeekReplyType: %v", err)
775+
}
776+
777+
name, err := rd.PeekPushNotificationName()
778+
if err == nil {
779+
t.Fatalf("PeekPushNotificationName: want error for overflowing name length, got name=%q", name)
780+
}
781+
if name != "" {
782+
t.Fatalf("PeekPushNotificationName: want empty name on error, got %q", name)
783+
}
784+
}

internal/proto/reader.go

Lines changed: 128 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -100,90 +100,159 @@ func (r *Reader) PeekReplyType() (byte, error) {
100100
return b[0], nil
101101
}
102102

103+
// PeekPushNotificationName returns the notification name of the next RESP3
104+
// push frame without consuming it. The caller is expected to have already
105+
// verified that the next reply is a push notification (e.g. via PeekReplyType
106+
// returning RespPush).
107+
//
108+
// To identify the name the method may block briefly reading more bytes from
109+
// the underlying connection. That is safe: once the push marker '>' has been
110+
// observed, the server is committed to sending the rest of the frame, so
111+
// fetching the next few header bytes does not race with anything the caller
112+
// could be waiting on. Blocking is preferred to a truncated peek, which would
113+
// silently misidentify the notification and cause the caller's ReadReply to
114+
// consume (and drop) the frame; see issue #3839.
103115
func (r *Reader) PeekPushNotificationName() (string, error) {
104-
// "prime" the buffer by peeking at the next byte
105-
c, err := r.Peek(1)
116+
c, err := r.rd.Peek(1)
106117
if err != nil {
107118
return "", err
108119
}
109120
if c[0] != RespPush {
110121
return "", fmt.Errorf("redis: can't peek push notification name, next reply is not a push notification")
111122
}
112123

113-
// peek 36 bytes at most, should be enough to read the push notification name
114-
toPeek := 36
115-
buffered := r.Buffered()
116-
if buffered == 0 {
117-
return "", fmt.Errorf("redis: can't peek push notification name, no data available")
118-
}
119-
if buffered < toPeek {
120-
toPeek = buffered
124+
// Start with a peek window that covers every Redis-defined notification
125+
// header (MOVING, MIGRATING, FAILED_OVER, message, pmessage, smessage,
126+
// subscribe, unsubscribe, ...). If a longer name is encountered, grow
127+
// the window up to maxPushHeaderPeek before giving up.
128+
const initialPeek = 36
129+
const maxPushHeaderPeek = 4096
130+
131+
peekSize := initialPeek
132+
for {
133+
buf, peekErr := r.rd.Peek(peekSize)
134+
name, complete, parseErr := parsePushNotificationName(buf)
135+
if parseErr != nil {
136+
return "", parseErr
137+
}
138+
if complete {
139+
return name, nil
140+
}
141+
// Parser ran out of bytes. Surface a failed underlying read before
142+
// growing further; otherwise grow the peek window and retry.
143+
if peekErr != nil {
144+
return "", peekErr
145+
}
146+
if peekSize >= maxPushHeaderPeek {
147+
return "", fmt.Errorf("redis: push notification header exceeds %d bytes", maxPushHeaderPeek)
148+
}
149+
peekSize *= 2
150+
if peekSize > maxPushHeaderPeek {
151+
peekSize = maxPushHeaderPeek
152+
}
121153
}
122-
buf, err := r.rd.Peek(toPeek)
123-
if err != nil {
124-
return "", err
154+
}
155+
156+
// parsePushNotificationName extracts the notification name from a buffered
157+
// RESP3 push frame prefix. The three return values are:
158+
//
159+
// - (name, true, nil): the full name is in buf.
160+
// - ("", false, nil): buf is a valid prefix but too short to determine the
161+
// name; the caller should fetch more bytes and retry.
162+
// - ("", _, err): buf is malformed.
163+
//
164+
// This split lets PeekPushNotificationName tell "incomplete header" apart
165+
// from "corrupt frame" without ever returning a truncated string.
166+
func parsePushNotificationName(buf []byte) (string, bool, error) {
167+
// Need at least ">N\r" before any meaningful work.
168+
if len(buf) < 3 {
169+
return "", false, nil
125170
}
126171
if buf[0] != RespPush {
127-
return "", fmt.Errorf("redis: can't parse push notification: %q", buf)
172+
return "", false, fmt.Errorf("redis: can't parse push notification: %q", buf)
128173
}
129174

130-
if len(buf) < 3 {
131-
return "", fmt.Errorf("redis: can't parse push notification: %q", buf)
175+
// Skip the array length line ">N\r\n".
176+
const arrayLenStart = 1 // first byte after the '>' marker
177+
pos, ok, err := skipDigitsThenCRLF(buf, arrayLenStart)
178+
if err != nil {
179+
return "", false, fmt.Errorf("redis: can't parse push notification: %w", err)
132180
}
133-
134-
// remove push notification type
135-
buf = buf[1:]
136-
// remove first line - e.g. >2\r\n
137-
for i := 0; i < len(buf)-1; i++ {
138-
if buf[i] == '\r' && buf[i+1] == '\n' {
139-
buf = buf[i+2:]
140-
break
141-
} else {
142-
if buf[i] < '0' || buf[i] > '9' {
143-
return "", fmt.Errorf("redis: can't parse push notification: %q", buf)
144-
}
145-
}
181+
if !ok {
182+
return "", false, nil
183+
}
184+
// Reject ">\r\n": RESP requires at least one digit for the array length.
185+
// Without this check the empty length looks like a valid prefix and the
186+
// caller would block fetching more bytes for a frame that is already
187+
// malformed.
188+
if pos-2 == arrayLenStart {
189+
return "", false, fmt.Errorf("redis: empty push notification array length")
146190
}
147-
if len(buf) < 2 {
148-
return "", fmt.Errorf("redis: can't parse push notification: %q", buf)
191+
192+
// First element type byte: '$' (bulk) or '+' (simple-string).
193+
if pos >= len(buf) {
194+
return "", false, nil
149195
}
150-
// next line should be $<length><string>\r\n or +<length><string>\r\n
151-
// should have the type of the push notification name and it's length
152-
if buf[0] != RespString && buf[0] != RespStatus {
153-
return "", fmt.Errorf("redis: can't parse push notification name: %q", buf)
196+
typeOfName := buf[pos]
197+
if typeOfName != RespString && typeOfName != RespStatus {
198+
return "", false, fmt.Errorf("redis: can't parse push notification name: %q", buf[pos:])
154199
}
155-
typeOfName := buf[0]
156-
// remove the type of the push notification name
157-
buf = buf[1:]
200+
pos++
201+
158202
if typeOfName == RespString {
159-
// remove the length of the string
160-
if len(buf) < 2 {
161-
return "", fmt.Errorf("redis: can't parse push notification name: %q", buf)
162-
}
163-
for i := 0; i < len(buf)-1; i++ {
164-
if buf[i] == '\r' && buf[i+1] == '\n' {
165-
buf = buf[i+2:]
166-
break
167-
} else {
168-
if buf[i] < '0' || buf[i] > '9' {
169-
return "", fmt.Errorf("redis: can't parse push notification name: %q", buf)
170-
}
171-
}
203+
// Read "$M\r\n" then the M-byte name.
204+
lenStart := pos
205+
next, ok, err := skipDigitsThenCRLF(buf, pos)
206+
if err != nil {
207+
return "", false, fmt.Errorf("redis: can't parse push notification name length: %w", err)
208+
}
209+
if !ok {
210+
return "", false, nil
172211
}
212+
if next-2 == lenStart {
213+
return "", false, fmt.Errorf("redis: empty push notification name length")
214+
}
215+
nameLen, err := util.Atoi(buf[lenStart : next-2])
216+
if err != nil {
217+
return "", false, fmt.Errorf("redis: invalid push notification name length %q: %w", buf[lenStart:next-2], err)
218+
}
219+
if nameLen < 0 {
220+
return "", false, fmt.Errorf("redis: negative push notification name length: %d", nameLen)
221+
}
222+
// Compare against the remaining bytes instead of computing
223+
// next+nameLen: a hugely advertised length on malformed input could
224+
// overflow int, wrap negative, slip past an "end > len(buf)" guard and
225+
// panic the slice below. next <= len(buf) here, so the subtraction is
226+
// safe.
227+
if nameLen > len(buf)-next {
228+
return "", false, nil
229+
}
230+
return util.BytesToString(buf[next : next+nameLen]), true, nil
173231
}
174232

175-
if len(buf) < 2 {
176-
return "", fmt.Errorf("redis: can't parse push notification name: %q", buf)
177-
}
178-
// keep only the notification name
179-
for i := 0; i < len(buf)-1; i++ {
233+
// RespStatus: scan for the terminating CRLF.
234+
for i := pos; i < len(buf)-1; i++ {
180235
if buf[i] == '\r' && buf[i+1] == '\n' {
181-
buf = buf[:i]
182-
break
236+
return util.BytesToString(buf[pos:i]), true, nil
183237
}
184238
}
239+
return "", false, nil
240+
}
185241

186-
return util.BytesToString(buf), nil
242+
// skipDigitsThenCRLF advances past zero-or-more ASCII digits and the
243+
// terminating "\r\n" starting at offset start in buf. It returns the position
244+
// after the "\r\n" and true on success; (pos, false, nil) if buf is too
245+
// short; or an error if a non-digit non-CR byte is encountered before the CRLF.
246+
func skipDigitsThenCRLF(buf []byte, start int) (int, bool, error) {
247+
for pos := start; pos < len(buf)-1; pos++ {
248+
if buf[pos] == '\r' && buf[pos+1] == '\n' {
249+
return pos + 2, true, nil
250+
}
251+
if buf[pos] < '0' || buf[pos] > '9' {
252+
return pos, false, fmt.Errorf("expected digit or CRLF, got %q", buf[pos])
253+
}
254+
}
255+
return len(buf), false, nil
187256
}
188257

189258
// ReadLine Return a valid reply, it will check the protocol or redis error,

0 commit comments

Comments
 (0)