detached a23e755

fix(vt): bound history decoding

commit
a23e7557946ddf481442056223560f6a7c98fa1c
author
brice · · 2026-07-12 15:37 UTC
parent
e8f2961
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +93 -0
M pkg/vt/history_codec.go +59 -0
@@ -15,6 +15,14 @@ const (
1515 historyVersion = 1
1616 historyCellBytes = 41
1717 maxHistoryChunkRows = 256
18+
19+ // Preserve ten-thousand-row histories while bounding untrusted snapshot
20+ // payloads. The cell limit permits an average width of 100 cells per row.
21+ maxHistoryDecodeChunks = 10_000
22+ maxHistoryDecodeRows = 10_000
23+ maxHistoryDecodeCells = 1_000_000
24+ maxHistoryDecodeStyles = maxHistoryDecodeCells
25+ maxHistoryDecodedBytes = 64 << 20
1826 )
1927
2028 var errInvalidHistory = errors.New("invalid history payload")
@@ -111,6 +119,10 @@ func UnmarshalHistory(data []byte) (HistoryView, error) {
111119 if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {
112120 return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
113121 }
122+ if _, ok := preflightHistory(data[5:]); !ok {
123+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
124+ }
125+
114126 p := historyParser{data: data[5:]}
115127 chunkCount, ok := p.uint32()
116128 if !ok || uint64(chunkCount) > uint64(len(p.data))/4 {
@@ -151,6 +163,53 @@ func UnmarshalHistory(data []byte) (HistoryView, error) {
151163 return HistoryView{chunks: chunks, rows: rowsTotal}, nil
152164 }
153165
166+type historyDecodeStats struct {
167+ chunks uint64
168+ rows uint64
169+ cells uint64
170+ styles uint64
171+ bytes uint64
172+}
173+
174+// preflightHistory validates declarations and aggregate budgets without
175+// allocating decoded history. Its totals can be composed by snapshot decoders.
176+func preflightHistory(data []byte) (historyDecodeStats, bool) {
177+ p := historyParser{data: data}
178+ chunkCount, ok := p.uint32()
179+ if !ok || uint64(chunkCount) > maxHistoryDecodeChunks || uint64(chunkCount) > uint64(len(p.data))/4 {
180+ return historyDecodeStats{}, false
181+ }
182+ stats := historyDecodeStats{chunks: uint64(chunkCount)}
183+ for range chunkCount {
184+ rowCount, ok := p.uint32()
185+ if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 || !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryDecodeRows) {
186+ return historyDecodeStats{}, false
187+ }
188+ for range rowCount {
189+ cellCount, ok := p.uint32()
190+ if !ok || uint64(cellCount) > uint64(len(p.data))/historyCellBytes {
191+ return historyDecodeStats{}, false
192+ }
193+ cellBytes := uint64(cellCount) * historyCellBytes
194+ if !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryDecodeCells) ||
195+ !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||
196+ !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
197+ return historyDecodeStats{}, false
198+ }
199+ p.data = p.data[int(cellBytes):]
200+ }
201+ }
202+ return stats, len(p.data) == 0
203+}
204+
205+func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool {
206+ if *total > limit || amount > limit-*total {
207+ return false
208+ }
209+ *total += amount
210+ return true
211+}
212+
154213 type historyParser struct{ data []byte }
155214
156215 func (p *historyParser) uint32() (uint32, bool) {
@@ -15,6 +15,14 @@ const (
15 historyVersion = 115 historyVersion = 1
16 historyCellBytes = 4116 historyCellBytes = 41
17 maxHistoryChunkRows = 25617 maxHistoryChunkRows = 256
18+
19+ // Preserve ten-thousand-row histories while bounding untrusted snapshot
20+ // payloads. The cell limit permits an average width of 100 cells per row.
21+ maxHistoryDecodeChunks = 10_000
22+ maxHistoryDecodeRows = 10_000
23+ maxHistoryDecodeCells = 1_000_000
24+ maxHistoryDecodeStyles = maxHistoryDecodeCells
25+ maxHistoryDecodedBytes = 64 << 20
18 )26 )
19 27
20 var errInvalidHistory = errors.New("invalid history payload")28 var errInvalidHistory = errors.New("invalid history payload")
@@ -111,6 +119,10 @@ func UnmarshalHistory(data []byte) (HistoryView, error) {
111 if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {119 if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion {
112 return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)120 return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
113 }121 }
122+ if _, ok := preflightHistory(data[5:]); !ok {
123+ return HistoryView{}, fmt.Errorf("unmarshal history: %w", errInvalidHistory)
124+ }
125+
114 p := historyParser{data: data[5:]}126 p := historyParser{data: data[5:]}
115 chunkCount, ok := p.uint32()127 chunkCount, ok := p.uint32()
116 if !ok || uint64(chunkCount) > uint64(len(p.data))/4 {128 if !ok || uint64(chunkCount) > uint64(len(p.data))/4 {
@@ -151,6 +163,53 @@ func UnmarshalHistory(data []byte) (HistoryView, error) {
151 return HistoryView{chunks: chunks, rows: rowsTotal}, nil163 return HistoryView{chunks: chunks, rows: rowsTotal}, nil
152 }164 }
153 165
166+type historyDecodeStats struct {
167+ chunks uint64
168+ rows uint64
169+ cells uint64
170+ styles uint64
171+ bytes uint64
172+}
173+
174+// preflightHistory validates declarations and aggregate budgets without
175+// allocating decoded history. Its totals can be composed by snapshot decoders.
176+func preflightHistory(data []byte) (historyDecodeStats, bool) {
177+ p := historyParser{data: data}
178+ chunkCount, ok := p.uint32()
179+ if !ok || uint64(chunkCount) > maxHistoryDecodeChunks || uint64(chunkCount) > uint64(len(p.data))/4 {
180+ return historyDecodeStats{}, false
181+ }
182+ stats := historyDecodeStats{chunks: uint64(chunkCount)}
183+ for range chunkCount {
184+ rowCount, ok := p.uint32()
185+ if !ok || rowCount == 0 || rowCount > maxHistoryChunkRows || uint64(rowCount) > uint64(len(p.data))/4 || !addHistoryDecodeBudget(&stats.rows, uint64(rowCount), maxHistoryDecodeRows) {
186+ return historyDecodeStats{}, false
187+ }
188+ for range rowCount {
189+ cellCount, ok := p.uint32()
190+ if !ok || uint64(cellCount) > uint64(len(p.data))/historyCellBytes {
191+ return historyDecodeStats{}, false
192+ }
193+ cellBytes := uint64(cellCount) * historyCellBytes
194+ if !addHistoryDecodeBudget(&stats.cells, uint64(cellCount), maxHistoryDecodeCells) ||
195+ !addHistoryDecodeBudget(&stats.styles, uint64(cellCount), maxHistoryDecodeStyles) ||
196+ !addHistoryDecodeBudget(&stats.bytes, cellBytes, maxHistoryDecodedBytes) {
197+ return historyDecodeStats{}, false
198+ }
199+ p.data = p.data[int(cellBytes):]
200+ }
201+ }
202+ return stats, len(p.data) == 0
203+}
204+
205+func addHistoryDecodeBudget(total *uint64, amount, limit uint64) bool {
206+ if *total > limit || amount > limit-*total {
207+ return false
208+ }
209+ *total += amount
210+ return true
211+}
212+
154 type historyParser struct{ data []byte }213 type historyParser struct{ data []byte }
155 214
156 func (p *historyParser) uint32() (uint32, bool) {215 func (p *historyParser) uint32() (uint32, bool) {
M pkg/vt/history_codec_test.go +34 -0
@@ -1,6 +1,7 @@
11 package vt
22
33 import (
4+ "encoding/binary"
45 "testing"
56
67 "github.com/bnema/vev/pkg/renderer"
@@ -97,6 +98,39 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
9798 }
9899 }
99100
101+func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {
102+ tests := []struct {
103+ name string
104+ chunkCount int
105+ rowCount int
106+ }{
107+ {name: "more than ten thousand chunks", chunkCount: 10_001, rowCount: 1},
108+ {name: "more than ten thousand rows", chunkCount: 40, rowCount: maxHistoryChunkRows},
109+ }
110+
111+ for _, tt := range tests {
112+ t.Run(tt.name, func(t *testing.T) {
113+ if _, err := UnmarshalHistory(hostileHistoryDeclarations(tt.chunkCount, tt.rowCount)); err == nil {
114+ t.Fatal("unmarshal accepted resource-exhausting history declarations")
115+ }
116+ })
117+ }
118+}
119+
120+func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
121+ data := make([]byte, 9, 9+chunkCount*(4+rowCount*4))
122+ copy(data, historyMagic)
123+ data[4] = historyVersion
124+ binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
125+ for range chunkCount {
126+ data = binary.BigEndian.AppendUint32(data, uint32(rowCount))
127+ for range rowCount {
128+ data = binary.BigEndian.AppendUint32(data, 0)
129+ }
130+ }
131+ return data
132+}
133+
100134 func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) {
101135 t.Helper()
102136 if got := view.Len(); got != len(want) {
@@ -1,6 +1,7 @@
1 package vt1 package vt
2 2
3 import (3 import (
4+ "encoding/binary"
4 "testing"5 "testing"
5 6
6 "github.com/bnema/vev/pkg/renderer"7 "github.com/bnema/vev/pkg/renderer"
@@ -97,6 +98,39 @@ func TestChunkCodecRejectsTruncatedAndTrailingPayloads(t *testing.T) {
97 }98 }
98 }99 }
99 100
101+func TestChunkCodecRejectsHostileChunkAndRowDeclarations(t *testing.T) {
102+ tests := []struct {
103+ name string
104+ chunkCount int
105+ rowCount int
106+ }{
107+ {name: "more than ten thousand chunks", chunkCount: 10_001, rowCount: 1},
108+ {name: "more than ten thousand rows", chunkCount: 40, rowCount: maxHistoryChunkRows},
109+ }
110+
111+ for _, tt := range tests {
112+ t.Run(tt.name, func(t *testing.T) {
113+ if _, err := UnmarshalHistory(hostileHistoryDeclarations(tt.chunkCount, tt.rowCount)); err == nil {
114+ t.Fatal("unmarshal accepted resource-exhausting history declarations")
115+ }
116+ })
117+ }
118+}
119+
120+func hostileHistoryDeclarations(chunkCount, rowCount int) []byte {
121+ data := make([]byte, 9, 9+chunkCount*(4+rowCount*4))
122+ copy(data, historyMagic)
123+ data[4] = historyVersion
124+ binary.BigEndian.PutUint32(data[5:], uint32(chunkCount))
125+ for range chunkCount {
126+ data = binary.BigEndian.AppendUint32(data, uint32(rowCount))
127+ for range rowCount {
128+ data = binary.BigEndian.AppendUint32(data, 0)
129+ }
130+ }
131+ return data
132+}
133+
100 func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) {134 func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) {
101 t.Helper()135 t.Helper()
102 if got := view.Len(); got != len(want) {136 if got := view.Len(); got != len(want) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close