fix(vt): bound history decoding
- commit
- a23e7557946ddf481442056223560f6a7c98fa1c
- author
- brice · · 2026-07-12 15:37 UTC
- parent
- e8f2961
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +93 -0
M pkg/vt/history_codec.go +59 -0
| @@ -15,6 +15,14 @@ const ( | |||
| 15 | 15 | historyVersion = 1 | |
| 16 | 16 | historyCellBytes = 41 | |
| 17 | 17 | 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 | 28 | var errInvalidHistory = errors.New("invalid history payload") | |
| @@ -111,6 +119,10 @@ func UnmarshalHistory(data []byte) (HistoryView, error) { | |||
| 111 | 119 | if len(data) < 9 || string(data[:4]) != historyMagic || data[4] != historyVersion { | |
| 112 | 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 | 126 | p := historyParser{data: data[5:]} | |
| 115 | 127 | chunkCount, ok := p.uint32() | |
| 116 | 128 | if !ok || uint64(chunkCount) > uint64(len(p.data))/4 { | |
| @@ -151,6 +163,53 @@ func UnmarshalHistory(data []byte) (HistoryView, error) { | |||
| 151 | 163 | 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 | 213 | type historyParser struct{ data []byte } | |
| 155 | 214 | ||
| 156 | 215 | func (p *historyParser) uint32() (uint32, bool) { | |
| @@ -15,6 +15,14 @@ const ( | |||||
| 15 | historyVersion = 1 | 15 | historyVersion = 1 | ||
| 16 | historyCellBytes = 41 | 16 | historyCellBytes = 41 | ||
| 17 | maxHistoryChunkRows = 256 | 17 | 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}, nil | 163 | 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 @@ | |||
| 1 | 1 | package vt | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/binary" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 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 | 134 | func assertHistoryRowsEqual(t *testing.T, view HistoryView, want [][]renderer.Cell) { | |
| 101 | 135 | t.Helper() | |
| 102 | 136 | if got := view.Len(); got != len(want) { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package vt | 1 | 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) { | ||