Files
Erupe/server/channelserver/compression/nullcomp/nullcomp.go
Houmgaor b40217c7fe feat(savedata): add tier 1 data integrity protections
Prevent savedata corruption and denial-of-service by adding four layers
of protection to the save pipeline:

- Bounded decompression (nullcomp.DecompressWithLimit): caps output size
  to prevent OOM from crafted payloads that expand to exhaust memory
- Bounds-checked delta patching (deltacomp.ApplyDataDiffWithLimit):
  validates offsets before writing, returns errors for negative offsets,
  truncated patches, and oversized output; ApplyDataDiff now returns
  original data on error instead of partial corruption
- Size limits on save handlers: rejects compressed payloads >512KB and
  decompressed data >1MB before processing; applied to main savedata,
  platedata, and platebox diff paths
- Rotating savedata backups: 3 slots per character with 30-minute
  interval, snapshots the previous state before overwriting, backed by
  new savedata_backups table (migration 0007)
2026-03-17 19:03:43 +01:00

155 lines
3.5 KiB
Go

package nullcomp
import (
"bytes"
"fmt"
"io"
)
// Decompress decompresses null-compressesed data.
func Decompress(compData []byte) ([]byte, error) {
r := bytes.NewReader(compData)
header := make([]byte, 16)
n, err := r.Read(header)
if err != nil {
return nil, err
} else if n != len(header) {
return nil, err
}
// Just return the data if it doesn't contain the cmp header.
if !bytes.Equal(header, []byte("cmp\x2020110113\x20\x20\x20\x00")) {
return compData, nil
}
var output []byte
for {
b, err := r.ReadByte()
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
if b == 0 {
// If it's a null byte, then the next byte is how many nulls to add.
nullCount, err := r.ReadByte()
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
output = append(output, make([]byte, int(nullCount))...)
} else {
output = append(output, b)
}
}
return output, nil
}
// DecompressWithLimit decompresses null-compressed data, returning an error if
// the decompressed output would exceed maxOutput bytes. This prevents
// denial-of-service via crafted payloads that expand to exhaust memory.
func DecompressWithLimit(compData []byte, maxOutput int) ([]byte, error) {
r := bytes.NewReader(compData)
header := make([]byte, 16)
n, err := r.Read(header)
if err != nil {
return nil, err
} else if n != len(header) {
return nil, err
}
// Just return the data if it doesn't contain the cmp header.
if !bytes.Equal(header, []byte("cmp\x2020110113\x20\x20\x20\x00")) {
if len(compData) > maxOutput {
return nil, fmt.Errorf("uncompressed data size %d exceeds limit %d", len(compData), maxOutput)
}
return compData, nil
}
var output []byte
for {
b, err := r.ReadByte()
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
if b == 0 {
// If it's a null byte, then the next byte is how many nulls to add.
nullCount, err := r.ReadByte()
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
if len(output)+int(nullCount) > maxOutput {
return nil, fmt.Errorf("decompressed size exceeds limit %d", maxOutput)
}
output = append(output, make([]byte, int(nullCount))...)
} else {
if len(output)+1 > maxOutput {
return nil, fmt.Errorf("decompressed size exceeds limit %d", maxOutput)
}
output = append(output, b)
}
}
return output, nil
}
// Compress null compresses give given data.
func Compress(rawData []byte) ([]byte, error) {
r := bytes.NewReader(rawData)
var output []byte
output = append(output, []byte("cmp\x2020110113\x20\x20\x20\x00")...)
for {
b, err := r.ReadByte()
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
if b == 0 {
output = append(output, []byte{0x00}...)
// read to get null count
nullCount := 1
for {
i, err := r.ReadByte()
if err == io.EOF {
output = append(output, []byte{byte(nullCount)}...)
break
} else if i != 0 && nullCount != 0 {
_ = r.UnreadByte()
output = append(output, []byte{byte(nullCount)}...)
break
} else if i != 0 && nullCount == 0 {
_ = r.UnreadByte()
output = output[:len(output)-2]
output = append(output, []byte{byte(0xFF)}...)
break
} else if err != nil {
return nil, err
}
nullCount++
// Flush the null-count if it gets to 255, start on the next null count.
if nullCount == 255 {
output = append(output, []byte{0xFF, 0x00}...)
nullCount = 0
}
}
} else {
output = append(output, b)
}
}
return output, nil
}