Skip to content

Commit 43f08c2

Browse files
authored
Validate chunk size in IndexPos read path to prevent panic/hang (#350)
IndexPos.Read assumes len(curChunk) equals the size the index declares for the chunk, but loadChunk never verified it. An index that declares a chunk larger than the data actually stored for that chunk ID could drive curChunkOffset past len(curChunk), making the slice in Read panic with "slice bounds out of range". When the declared size is larger but the offset lands exactly at the real end of a non-last chunk, Read instead copies zero bytes and Seek(0) makes no progress, spinning forever. Both are reachable with an untrusted .caibx via 'desync cat' and the 'desync mount-index' FUSE read handler. Verify after loading that the actual chunk length matches the declared index size and return an error otherwise, the same check AssembleFile already performs. Covers the null-chunk shortcut too.
1 parent 5904461 commit 43f08c2

2 files changed

Lines changed: 139 additions & 9 deletions

File tree

readseeker.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -94,17 +94,25 @@ func (ip *IndexPos) loadChunk() error {
9494
// is being loaded
9595
if ip.curChunkID == ip.nullChunk.ID {
9696
ip.curChunk = ip.nullChunk.Data
97-
return nil
98-
}
99-
chunk, err := ip.Store.GetChunk(ip.curChunkID)
100-
if err != nil {
101-
return err
97+
} else {
98+
chunk, err := ip.Store.GetChunk(ip.curChunkID)
99+
if err != nil {
100+
return err
101+
}
102+
b, err := chunk.Data()
103+
if err != nil {
104+
return err
105+
}
106+
ip.curChunk = b
102107
}
103-
b, err := chunk.Data()
104-
if err != nil {
105-
return err
108+
// The read path assumes len(curChunk) matches the size the index declares
109+
// for this chunk. A mismatch means a corrupt or malicious index; without
110+
// this check Read() can slice curChunk out of bounds (panic) or spin in a
111+
// zero-progress loop. AssembleFile performs the same check.
112+
if uint64(len(ip.curChunk)) != ip.Index.Chunks[ip.curChunkIdx].Size {
113+
ip.curChunk = nil
114+
return fmt.Errorf("unexpected size for chunk %s", ip.curChunkID.String())
106115
}
107-
ip.curChunk = b
108116
return nil
109117
}
110118

readseeker_test.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package desync
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"testing"
7+
"time"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
// chunkID returns the ID desync would assign to the given plain data.
14+
func chunkID(b []byte) ChunkID { return Digest.Sum(b) }
15+
16+
// TestIndexReadSeekerSizeMismatchPanic ensures that an index declaring a chunk
17+
// size larger than the actual stored chunk results in an error rather than a
18+
// "slice bounds out of range" panic in the read path.
19+
func TestIndexReadSeekerSizeMismatchPanic(t *testing.T) {
20+
data := []byte("data") // real chunk is 4 bytes
21+
store := &TestStore{Chunks: map[ChunkID][]byte{chunkID(data): data}}
22+
23+
// The index lies: it claims the chunk is 1000 bytes long.
24+
idx := Index{
25+
Index: FormatIndex{ChunkSizeMax: ChunkSizeMaxDefault},
26+
Chunks: []IndexChunk{
27+
{ID: chunkID(data), Start: 0, Size: 1000},
28+
},
29+
}
30+
31+
r := NewIndexReadSeeker(idx, store)
32+
33+
// Seek past the real chunk length but within the declared size.
34+
_, err := r.Seek(200, io.SeekStart)
35+
require.NoError(t, err)
36+
37+
buf := make([]byte, 16)
38+
require.NotPanics(t, func() {
39+
_, err = r.Read(buf)
40+
})
41+
require.Error(t, err)
42+
assert.Contains(t, err.Error(), "unexpected size for chunk")
43+
}
44+
45+
// TestIndexReadSeekerSizeMismatchNoLoop ensures that a short (under-sized) chunk
46+
// that is not the last one causes Read to return an error promptly instead of
47+
// spinning in a zero-progress loop.
48+
func TestIndexReadSeekerSizeMismatchNoLoop(t *testing.T) {
49+
short := []byte("data") // real chunk is 4 bytes
50+
tail := []byte("trailing") // a normal following chunk
51+
store := &TestStore{Chunks: map[ChunkID][]byte{
52+
chunkID(short): short,
53+
chunkID(tail): tail,
54+
}}
55+
56+
// First chunk claims 1000 bytes but only 4 are stored, and it's followed by
57+
// another chunk so the "last chunk" short-read break does not apply.
58+
idx := Index{
59+
Index: FormatIndex{ChunkSizeMax: ChunkSizeMaxDefault},
60+
Chunks: []IndexChunk{
61+
{ID: chunkID(short), Start: 0, Size: 1000},
62+
{ID: chunkID(tail), Start: 1000, Size: uint64(len(tail))},
63+
},
64+
}
65+
66+
r := NewIndexReadSeeker(idx, store)
67+
68+
done := make(chan error, 1)
69+
go func() {
70+
buf := make([]byte, 64)
71+
_, err := r.Read(buf)
72+
done <- err
73+
}()
74+
75+
select {
76+
case err := <-done:
77+
require.Error(t, err)
78+
assert.Contains(t, err.Error(), "unexpected size for chunk")
79+
case <-time.After(5 * time.Second):
80+
t.Fatal("Read did not return; likely spinning in a zero-progress loop")
81+
}
82+
}
83+
84+
// TestIndexReadSeekerValid verifies the read path still returns the correct
85+
// content for a well-formed multi-chunk index, including a null chunk served
86+
// from memory, and that seeking works.
87+
func TestIndexReadSeekerValid(t *testing.T) {
88+
head := []byte("hello, world")
89+
null := make([]byte, ChunkSizeMaxDefault)
90+
tail := []byte("goodbye, world")
91+
92+
store := &TestStore{Chunks: map[ChunkID][]byte{
93+
chunkID(head): head,
94+
chunkID(tail): tail,
95+
// the null chunk is intentionally not stored; it must be served from memory
96+
}}
97+
98+
idx := Index{
99+
Index: FormatIndex{ChunkSizeMax: ChunkSizeMaxDefault},
100+
Chunks: []IndexChunk{
101+
{ID: chunkID(head), Start: 0, Size: uint64(len(head))},
102+
{ID: NewNullChunk(ChunkSizeMaxDefault).ID, Start: uint64(len(head)), Size: ChunkSizeMaxDefault},
103+
{ID: chunkID(tail), Start: uint64(len(head)) + ChunkSizeMaxDefault, Size: uint64(len(tail))},
104+
},
105+
}
106+
107+
want := bytes.Join([][]byte{head, null, tail}, nil)
108+
109+
// Full sequential read.
110+
r := NewIndexReadSeeker(idx, store)
111+
got, err := io.ReadAll(r)
112+
require.NoError(t, err)
113+
assert.Equal(t, want, got)
114+
115+
// Seek to the start of the last chunk and read its content.
116+
off := int64(len(head)) + int64(ChunkSizeMaxDefault)
117+
_, err = r.Seek(off, io.SeekStart)
118+
require.NoError(t, err)
119+
got, err = io.ReadAll(r)
120+
require.NoError(t, err)
121+
assert.Equal(t, tail, got)
122+
}

0 commit comments

Comments
 (0)