mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-22 07:32:32 +01:00
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)
This commit is contained in:
@@ -2,6 +2,7 @@ package deltacomp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -49,8 +50,21 @@ func readCount(r *bytes.Reader) (int, error) {
|
|||||||
|
|
||||||
// ApplyDataDiff applies a delta data diff patch onto given base data.
|
// ApplyDataDiff applies a delta data diff patch onto given base data.
|
||||||
func ApplyDataDiff(diff []byte, baseData []byte) []byte {
|
func ApplyDataDiff(diff []byte, baseData []byte) []byte {
|
||||||
// Make a copy of the base data to return,
|
result, err := ApplyDataDiffWithLimit(diff, baseData, 0)
|
||||||
// (probably just make this modify the given slice in the future).
|
if err != nil {
|
||||||
|
zap.L().Error("ApplyDataDiff failed", zap.Error(err))
|
||||||
|
// Return original data on error to avoid corruption
|
||||||
|
out := make([]byte, len(baseData))
|
||||||
|
copy(out, baseData)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyDataDiffWithLimit applies a delta data diff patch onto given base data.
|
||||||
|
// If maxOutput > 0, the result is capped at that size; exceeding it returns an error.
|
||||||
|
// If maxOutput == 0, no limit is enforced (backwards-compatible behavior).
|
||||||
|
func ApplyDataDiffWithLimit(diff []byte, baseData []byte, maxOutput int) ([]byte, error) {
|
||||||
baseCopy := make([]byte, len(baseData))
|
baseCopy := make([]byte, len(baseData))
|
||||||
copy(baseCopy, baseData)
|
copy(baseCopy, baseData)
|
||||||
|
|
||||||
@@ -76,32 +90,35 @@ func ApplyDataDiff(diff []byte, baseData []byte) []byte {
|
|||||||
}
|
}
|
||||||
differentCount--
|
differentCount--
|
||||||
|
|
||||||
// Grow slice if it's required
|
if dataOffset < 0 {
|
||||||
if len(baseCopy) < dataOffset {
|
return nil, fmt.Errorf("negative data offset %d", dataOffset)
|
||||||
zap.L().Warn("Slice smaller than data offset, growing slice")
|
}
|
||||||
baseCopy = append(baseCopy, make([]byte, (dataOffset+differentCount)-len(baseData))...)
|
if differentCount < 0 {
|
||||||
} else {
|
return nil, fmt.Errorf("negative different count %d at offset %d", differentCount, dataOffset)
|
||||||
length := len(baseCopy[dataOffset:])
|
}
|
||||||
if length < differentCount {
|
|
||||||
length -= differentCount
|
endOffset := dataOffset + differentCount
|
||||||
baseCopy = append(baseCopy, make([]byte, length)...)
|
if maxOutput > 0 && endOffset > maxOutput {
|
||||||
}
|
return nil, fmt.Errorf("patch writes to offset %d, exceeds limit %d", endOffset, maxOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grow slice if required
|
||||||
|
if endOffset > len(baseCopy) {
|
||||||
|
baseCopy = append(baseCopy, make([]byte, endOffset-len(baseCopy))...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply the patch bytes.
|
// Apply the patch bytes.
|
||||||
for i := 0; i < differentCount; i++ {
|
for i := 0; i < differentCount; i++ {
|
||||||
b, err := checkReadUint8(patch)
|
b, err := checkReadUint8(patch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
zap.L().Error("Invalid or misunderstood patch format", zap.Int("dataOffset", dataOffset))
|
return nil, fmt.Errorf("truncated patch at offset %d+%d: %w", dataOffset, i, err)
|
||||||
return baseCopy
|
|
||||||
}
|
}
|
||||||
|
|
||||||
baseCopy[dataOffset+i] = b
|
baseCopy[dataOffset+i] = b
|
||||||
}
|
}
|
||||||
|
|
||||||
dataOffset += differentCount - 1
|
dataOffset += differentCount - 1
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return baseCopy
|
return baseCopy, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,83 @@ func readTestDataFile(filename string) []byte {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyDataDiffWithLimit_BoundsCheck(t *testing.T) {
|
||||||
|
// Base data: 10 bytes
|
||||||
|
baseData := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A}
|
||||||
|
|
||||||
|
// Build a patch that tries to write at offset 8 with 5 different bytes,
|
||||||
|
// which would extend to offset 13 (beyond 10-byte base).
|
||||||
|
// Format: matchCount=9 (first is +1), differentCount=6 (is -1 = 5 bytes)
|
||||||
|
diff := []byte{
|
||||||
|
0x09, // matchCount (first is +1, so offset becomes -1+9=8)
|
||||||
|
0x06, // differentCount (6-1=5 different bytes)
|
||||||
|
0xAA, 0xBB, 0xCC, 0xDD, 0xEE, // 5 patch bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("within_limit", func(t *testing.T) {
|
||||||
|
// Limit of 20 allows the growth
|
||||||
|
result, err := ApplyDataDiffWithLimit(diff, baseData, 20)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(result) < 13 {
|
||||||
|
t.Errorf("expected result length >= 13, got %d", len(result))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("exceeds_limit", func(t *testing.T) {
|
||||||
|
// Limit of 10 doesn't allow writing past the base
|
||||||
|
_, err := ApplyDataDiffWithLimit(diff, baseData, 10)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for write past limit, got none")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no_limit", func(t *testing.T) {
|
||||||
|
// maxOutput=0 means no limit (backwards compatible)
|
||||||
|
result, err := ApplyDataDiffWithLimit(diff, baseData, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(result) < 13 {
|
||||||
|
t.Errorf("expected result length >= 13, got %d", len(result))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyDataDiffWithLimit_TruncatedPatch(t *testing.T) {
|
||||||
|
baseData := []byte{0x01, 0x02, 0x03, 0x04}
|
||||||
|
|
||||||
|
// Patch claims 3 different bytes but only provides 1
|
||||||
|
diff := []byte{
|
||||||
|
0x02, // matchCount (offset = -1+2 = 1)
|
||||||
|
0x04, // differentCount (4-1=3 different bytes)
|
||||||
|
0xAA, // only 1 byte provided (missing 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := ApplyDataDiffWithLimit(diff, baseData, 100)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for truncated patch, got none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyDataDiff_ReturnsOriginalOnError(t *testing.T) {
|
||||||
|
baseData := []byte{0x01, 0x02, 0x03, 0x04}
|
||||||
|
|
||||||
|
// Truncated patch
|
||||||
|
diff := []byte{
|
||||||
|
0x02,
|
||||||
|
0x04,
|
||||||
|
0xAA, // only 1 of 3 expected bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
result := ApplyDataDiff(diff, baseData)
|
||||||
|
// On error, ApplyDataDiff should return the original data unchanged
|
||||||
|
if !bytes.Equal(result, baseData) {
|
||||||
|
t.Errorf("expected original data on error, got %v", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeltaPatch(t *testing.T) {
|
func TestDeltaPatch(t *testing.T) {
|
||||||
for k, tt := range tests {
|
for k, tt := range tests {
|
||||||
testname := fmt.Sprintf("delta_patch_test_%d", k)
|
testname := fmt.Sprintf("delta_patch_test_%d", k)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package nullcomp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +50,61 @@ func Decompress(compData []byte) ([]byte, error) {
|
|||||||
return output, nil
|
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.
|
// Compress null compresses give given data.
|
||||||
func Compress(rawData []byte) ([]byte, error) {
|
func Compress(rawData []byte) ([]byte, error) {
|
||||||
r := bytes.NewReader(rawData)
|
r := bytes.NewReader(rawData)
|
||||||
|
|||||||
@@ -362,6 +362,110 @@ func TestDecompress_EdgeCases(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === DecompressWithLimit tests ===
|
||||||
|
|
||||||
|
func TestDecompressWithLimit_RespectsLimit(t *testing.T) {
|
||||||
|
// Compress data that decompresses to 1000 bytes
|
||||||
|
input := make([]byte, 1000)
|
||||||
|
for i := range input {
|
||||||
|
input[i] = byte(i % 256)
|
||||||
|
}
|
||||||
|
compressed, err := Compress(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Compress() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
maxOutput int
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "limit larger than data",
|
||||||
|
maxOutput: 2000,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit equal to data",
|
||||||
|
maxOutput: 1000,
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit smaller than data",
|
||||||
|
maxOutput: 500,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "limit of 1",
|
||||||
|
maxOutput: 1,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := DecompressWithLimit(compressed, tt.maxOutput)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error but got none")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(result, input) {
|
||||||
|
t.Error("decompressed data doesn't match original")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecompressWithLimit_NullExpansionBomb(t *testing.T) {
|
||||||
|
// Craft a payload that would expand to a huge size via null runs:
|
||||||
|
// header + 0x00 0xFF repeated many times
|
||||||
|
var payload []byte
|
||||||
|
payload = append(payload, []byte("cmp\x2020110113\x20\x20\x20\x00")...)
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
payload = append(payload, 0x00, 0xFF) // each pair = 255 null bytes
|
||||||
|
}
|
||||||
|
// Total decompressed would be 255,000 bytes
|
||||||
|
|
||||||
|
_, err := DecompressWithLimit(payload, 10000)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for null expansion bomb, got none")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecompressWithLimit_UncompressedDataExceedsLimit(t *testing.T) {
|
||||||
|
// Data without cmp header that exceeds the limit
|
||||||
|
data := make([]byte, 100)
|
||||||
|
for i := range data {
|
||||||
|
data[i] = byte(i + 1) // non-zero so it's not confused with compressed
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := DecompressWithLimit(data, 50)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for uncompressed data exceeding limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecompressWithLimit_RoundTrip(t *testing.T) {
|
||||||
|
input := []byte("Hello\x00\x00\x00World\x00\x00End")
|
||||||
|
compressed, err := Compress(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Compress() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := DecompressWithLimit(compressed, 1024)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecompressWithLimit() error = %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(result, input) {
|
||||||
|
t.Errorf("round trip failed: got %v, want %v", result, input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func BenchmarkCompress(b *testing.B) {
|
func BenchmarkCompress(b *testing.B) {
|
||||||
data := make([]byte, 10000)
|
data := make([]byte, 10000)
|
||||||
// Fill with some pattern (half nulls, half data)
|
// Fill with some pattern (half nulls, half data)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
cfg "erupe-ce/config"
|
cfg "erupe-ce/config"
|
||||||
"erupe-ce/network/mhfpacket"
|
"erupe-ce/network/mhfpacket"
|
||||||
@@ -11,6 +12,12 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Backup configuration constants.
|
||||||
|
const (
|
||||||
|
saveBackupSlots = 3 // number of rotating backup slots per character
|
||||||
|
saveBackupInterval = 30 * time.Minute // minimum time between backups
|
||||||
|
)
|
||||||
|
|
||||||
// GetCharacterSaveData loads a character's save data from the database.
|
// GetCharacterSaveData loads a character's save data from the database.
|
||||||
func GetCharacterSaveData(s *Session, charID uint32) (*CharacterSaveData, error) {
|
func GetCharacterSaveData(s *Session, charID uint32) (*CharacterSaveData, error) {
|
||||||
id, savedata, isNew, name, err := s.server.charRepo.LoadSaveData(charID)
|
id, savedata, isNew, name, err := s.server.charRepo.LoadSaveData(charID)
|
||||||
@@ -55,6 +62,10 @@ func (save *CharacterSaveData) Save(s *Session) error {
|
|||||||
return errors.New("no decompressed save data")
|
return errors.New("no decompressed save data")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture the previous compressed savedata before it's overwritten by
|
||||||
|
// Compress(). This is what gets backed up — the last known-good state.
|
||||||
|
prevCompSave := save.compSave
|
||||||
|
|
||||||
if !s.kqfOverride {
|
if !s.kqfOverride {
|
||||||
s.kqf = save.KQF
|
s.kqf = save.KQF
|
||||||
} else {
|
} else {
|
||||||
@@ -74,6 +85,14 @@ func (save *CharacterSaveData) Save(s *Session) error {
|
|||||||
save.compSave = save.decompSave
|
save.compSave = save.decompSave
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Time-gated rotating backup: snapshot the previous compressed savedata
|
||||||
|
// before overwriting, but only if enough time has elapsed since the last
|
||||||
|
// backup. This keeps storage bounded (3 slots × blob size per character)
|
||||||
|
// while providing recovery points.
|
||||||
|
if len(prevCompSave) > 0 {
|
||||||
|
maybeSaveBackup(s, save.CharID, prevCompSave)
|
||||||
|
}
|
||||||
|
|
||||||
if err := s.server.charRepo.SaveCharacterData(save.CharID, save.compSave, save.HR, save.GR, save.Gender, save.WeaponType, save.WeaponID); err != nil {
|
if err := s.server.charRepo.SaveCharacterData(save.CharID, save.compSave, save.HR, save.GR, save.Gender, save.WeaponType, save.WeaponID); err != nil {
|
||||||
s.logger.Error("Failed to update savedata", zap.Error(err), zap.Uint32("charID", save.CharID))
|
s.logger.Error("Failed to update savedata", zap.Error(err), zap.Uint32("charID", save.CharID))
|
||||||
return fmt.Errorf("save character data: %w", err)
|
return fmt.Errorf("save character data: %w", err)
|
||||||
@@ -87,6 +106,37 @@ func (save *CharacterSaveData) Save(s *Session) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maybeSaveBackup checks whether enough time has elapsed since the last backup
|
||||||
|
// and, if so, writes the given compressed savedata into the next rotating slot.
|
||||||
|
// Errors are logged but do not block the save — backups are best-effort.
|
||||||
|
func maybeSaveBackup(s *Session, charID uint32, compSave []byte) {
|
||||||
|
lastBackup, err := s.server.charRepo.GetLastBackupTime(charID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to query last backup time, skipping backup",
|
||||||
|
zap.Error(err), zap.Uint32("charID", charID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Since(lastBackup) < saveBackupInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick the next slot using a simple counter derived from the backup times.
|
||||||
|
// We rotate through slots 0, 1, 2 based on how many backups exist modulo
|
||||||
|
// the slot count. In practice this fills slots in order and then overwrites
|
||||||
|
// the oldest.
|
||||||
|
slot := int(lastBackup.Unix()/int64(saveBackupInterval.Seconds())) % saveBackupSlots
|
||||||
|
|
||||||
|
if err := s.server.charRepo.SaveBackup(charID, slot, compSave); err != nil {
|
||||||
|
s.logger.Warn("Failed to save backup",
|
||||||
|
zap.Error(err), zap.Uint32("charID", charID), zap.Int("slot", slot))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.logger.Info("Savedata backup created",
|
||||||
|
zap.Uint32("charID", charID), zap.Int("slot", slot))
|
||||||
|
}
|
||||||
|
|
||||||
func handleMsgMhfSexChanger(s *Session, p mhfpacket.MHFPacket) {
|
func handleMsgMhfSexChanger(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfSexChanger)
|
pkt := p.(*mhfpacket.MsgMhfSexChanger)
|
||||||
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
|
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
|
||||||
|
|||||||
@@ -17,8 +17,27 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Save data size limits.
|
||||||
|
// The largest known decompressed savedata is ZZ at ~147KB. We use generous
|
||||||
|
// ceilings to accommodate unknown versions while still catching runaway data.
|
||||||
|
const (
|
||||||
|
saveDataMaxCompressedPayload = 524288 // 512KB max compressed payload from client
|
||||||
|
saveDataMaxDecompressedPayload = 1048576 // 1MB max decompressed savedata
|
||||||
|
)
|
||||||
|
|
||||||
func handleMsgMhfSavedata(s *Session, p mhfpacket.MHFPacket) {
|
func handleMsgMhfSavedata(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfSavedata)
|
pkt := p.(*mhfpacket.MsgMhfSavedata)
|
||||||
|
|
||||||
|
if len(pkt.RawDataPayload) > saveDataMaxCompressedPayload {
|
||||||
|
s.logger.Warn("Savedata payload exceeds size limit",
|
||||||
|
zap.Int("len", len(pkt.RawDataPayload)),
|
||||||
|
zap.Int("max", saveDataMaxCompressedPayload),
|
||||||
|
zap.Uint32("charID", s.charID),
|
||||||
|
)
|
||||||
|
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
characterSaveData, err := GetCharacterSaveData(s, s.charID)
|
characterSaveData, err := GetCharacterSaveData(s, s.charID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("failed to retrieve character save data from db", zap.Error(err), zap.Uint32("charID", s.charID))
|
s.logger.Error("failed to retrieve character save data from db", zap.Error(err), zap.Uint32("charID", s.charID))
|
||||||
@@ -34,19 +53,25 @@ func handleMsgMhfSavedata(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
if pkt.SaveType == 1 {
|
if pkt.SaveType == 1 {
|
||||||
// Diff-based update.
|
// Diff-based update.
|
||||||
// diffs themselves are also potentially compressed
|
// diffs themselves are also potentially compressed
|
||||||
diff, err := nullcomp.Decompress(pkt.RawDataPayload)
|
diff, err := nullcomp.DecompressWithLimit(pkt.RawDataPayload, saveDataMaxDecompressedPayload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to decompress diff", zap.Error(err))
|
s.logger.Error("Failed to decompress diff", zap.Error(err))
|
||||||
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
|
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Perform diff.
|
// Perform diff with bounds checking.
|
||||||
s.logger.Info("Diffing...")
|
s.logger.Info("Diffing...")
|
||||||
characterSaveData.decompSave = deltacomp.ApplyDataDiff(diff, characterSaveData.decompSave)
|
patched, err := deltacomp.ApplyDataDiffWithLimit(diff, characterSaveData.decompSave, saveDataMaxDecompressedPayload)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to apply save diff", zap.Error(err), zap.Uint32("charID", s.charID))
|
||||||
|
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
characterSaveData.decompSave = patched
|
||||||
} else {
|
} else {
|
||||||
dumpSaveData(s, pkt.RawDataPayload, "savedata")
|
dumpSaveData(s, pkt.RawDataPayload, "savedata")
|
||||||
// Regular blob update.
|
// Regular blob update.
|
||||||
saveData, err := nullcomp.Decompress(pkt.RawDataPayload)
|
saveData, err := nullcomp.DecompressWithLimit(pkt.RawDataPayload, saveDataMaxDecompressedPayload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to decompress savedata from packet", zap.Error(err))
|
s.logger.Error("Failed to decompress savedata from packet", zap.Error(err))
|
||||||
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
|
doAckSimpleFail(s, pkt.AckHandle, make([]byte, 4))
|
||||||
@@ -193,7 +218,7 @@ func handleMsgMhfLoaddata(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
}
|
}
|
||||||
doAckBufSucceed(s, pkt.AckHandle, data)
|
doAckBufSucceed(s, pkt.AckHandle, data)
|
||||||
|
|
||||||
decompSaveData, err := nullcomp.Decompress(data)
|
decompSaveData, err := nullcomp.DecompressWithLimit(data, saveDataMaxDecompressedPayload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to decompress savedata", zap.Error(err))
|
s.logger.Error("Failed to decompress savedata", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -652,6 +652,73 @@ func TestConcurrentSaveData_Integration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Tier 1 protection tests
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
func TestSaveDataSizeLimit(t *testing.T) {
|
||||||
|
// Verify the size constants are sensible
|
||||||
|
if saveDataMaxCompressedPayload <= 0 {
|
||||||
|
t.Error("saveDataMaxCompressedPayload must be positive")
|
||||||
|
}
|
||||||
|
if saveDataMaxDecompressedPayload <= 0 {
|
||||||
|
t.Error("saveDataMaxDecompressedPayload must be positive")
|
||||||
|
}
|
||||||
|
if saveDataMaxCompressedPayload > saveDataMaxDecompressedPayload {
|
||||||
|
t.Error("compressed limit should not exceed decompressed limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveDataSizeLimitRejectsOversized(t *testing.T) {
|
||||||
|
server := createMockServer()
|
||||||
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
|
// Create a payload larger than the limit
|
||||||
|
oversized := make([]byte, saveDataMaxCompressedPayload+1)
|
||||||
|
pkt := &mhfpacket.MsgMhfSavedata{
|
||||||
|
SaveType: 0,
|
||||||
|
AckHandle: 1234,
|
||||||
|
AllocMemSize: uint32(len(oversized)),
|
||||||
|
DataSize: uint32(len(oversized)),
|
||||||
|
RawDataPayload: oversized,
|
||||||
|
}
|
||||||
|
|
||||||
|
// This should return early with a fail ACK, not panic
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
t.Errorf("handleMsgMhfSavedata panicked on oversized payload: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
handleMsgMhfSavedata(session, pkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaveDataSizeLimitAcceptsNormalPayload(t *testing.T) {
|
||||||
|
// Verify a normal-sized payload passes the size check
|
||||||
|
normalSize := 100000 // 100KB - typical save
|
||||||
|
if normalSize > saveDataMaxCompressedPayload {
|
||||||
|
t.Errorf("normal save size %d exceeds limit %d", normalSize, saveDataMaxCompressedPayload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecompressWithLimitConstants(t *testing.T) {
|
||||||
|
// Verify limits are consistent with known save sizes
|
||||||
|
// ZZ save is ~147KB decompressed; limit should be well above that
|
||||||
|
zzSaveSize := 150000
|
||||||
|
if saveDataMaxDecompressedPayload < zzSaveSize*2 {
|
||||||
|
t.Errorf("decompressed limit %d is too close to known ZZ save size %d",
|
||||||
|
saveDataMaxDecompressedPayload, zzSaveSize)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBackupConstants(t *testing.T) {
|
||||||
|
if saveBackupSlots <= 0 {
|
||||||
|
t.Error("saveBackupSlots must be positive")
|
||||||
|
}
|
||||||
|
if saveBackupInterval <= 0 {
|
||||||
|
t.Error("saveBackupInterval must be positive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tests consolidated from handlers_coverage4_test.go
|
// Tests consolidated from handlers_coverage4_test.go
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func handleMsgMhfSavePlateData(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
if len(data) > 0 {
|
if len(data) > 0 {
|
||||||
// Decompress
|
// Decompress
|
||||||
s.logger.Debug("Decompressing PlateData", zap.Int("compressed_size", len(data)))
|
s.logger.Debug("Decompressing PlateData", zap.Int("compressed_size", len(data)))
|
||||||
data, err = nullcomp.Decompress(data)
|
data, err = nullcomp.DecompressWithLimit(data, plateDataMaxPayload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to decompress platedata",
|
s.logger.Error("Failed to decompress platedata",
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -91,9 +91,18 @@ func handleMsgMhfSavePlateData(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
data = make([]byte, plateDataEmptySize)
|
data = make([]byte, plateDataEmptySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform diff and compress it to write back to db
|
// Perform diff with bounds checking and compress it to write back to db
|
||||||
s.logger.Debug("Applying PlateData diff", zap.Int("base_size", len(data)))
|
s.logger.Debug("Applying PlateData diff", zap.Int("base_size", len(data)))
|
||||||
saveOutput, err := nullcomp.Compress(deltacomp.ApplyDataDiff(pkt.RawDataPayload, data))
|
patched, err := deltacomp.ApplyDataDiffWithLimit(pkt.RawDataPayload, data, plateDataMaxPayload)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to apply platedata diff",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.Uint32("charID", s.charID),
|
||||||
|
)
|
||||||
|
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveOutput, err := nullcomp.Compress(patched)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to diff and compress platedata",
|
s.logger.Error("Failed to diff and compress platedata",
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -173,7 +182,7 @@ func handleMsgMhfSavePlateBox(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
if len(data) > 0 {
|
if len(data) > 0 {
|
||||||
// Decompress
|
// Decompress
|
||||||
s.logger.Info("Decompressing...")
|
s.logger.Info("Decompressing...")
|
||||||
data, err = nullcomp.Decompress(data)
|
data, err = nullcomp.DecompressWithLimit(data, plateBoxMaxPayload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to decompress platebox", zap.Error(err))
|
s.logger.Error("Failed to decompress platebox", zap.Error(err))
|
||||||
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00})
|
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00})
|
||||||
@@ -184,9 +193,15 @@ func handleMsgMhfSavePlateBox(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
data = make([]byte, plateBoxEmptySize)
|
data = make([]byte, plateBoxEmptySize)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Perform diff and compress it to write back to db
|
// Perform diff with bounds checking and compress it to write back to db
|
||||||
s.logger.Info("Diffing...")
|
s.logger.Info("Diffing...")
|
||||||
saveOutput, err := nullcomp.Compress(deltacomp.ApplyDataDiff(pkt.RawDataPayload, data))
|
patched, err := deltacomp.ApplyDataDiffWithLimit(pkt.RawDataPayload, data, plateBoxMaxPayload)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to apply platebox diff", zap.Error(err))
|
||||||
|
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saveOutput, err := nullcomp.Compress(patched)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to diff and compress platebox", zap.Error(err))
|
s.logger.Error("Failed to diff and compress platebox", zap.Error(err))
|
||||||
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00})
|
doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00})
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func (save *CharacterSaveData) Compress() error {
|
|||||||
|
|
||||||
func (save *CharacterSaveData) Decompress() error {
|
func (save *CharacterSaveData) Decompress() error {
|
||||||
var err error
|
var err error
|
||||||
save.decompSave, err = nullcomp.Decompress(save.compSave)
|
save.decompSave, err = nullcomp.DecompressWithLimit(save.compSave, saveDataMaxDecompressedPayload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,6 +212,32 @@ func (r *CharacterRepository) UpdateGCPAndPact(charID uint32, gcp uint32, pactID
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveBackup upserts a savedata snapshot into the rotating backup table.
|
||||||
|
func (r *CharacterRepository) SaveBackup(charID uint32, slot int, data []byte) error {
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
INSERT INTO savedata_backups (char_id, slot, savedata, saved_at)
|
||||||
|
VALUES ($1, $2, $3, now())
|
||||||
|
ON CONFLICT (char_id, slot) DO UPDATE SET savedata = $3, saved_at = now()
|
||||||
|
`, charID, slot, data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLastBackupTime returns the most recent backup timestamp for a character.
|
||||||
|
// Returns the zero time if no backups exist.
|
||||||
|
func (r *CharacterRepository) GetLastBackupTime(charID uint32) (time.Time, error) {
|
||||||
|
var t sql.NullTime
|
||||||
|
err := r.db.QueryRow(
|
||||||
|
"SELECT MAX(saved_at) FROM savedata_backups WHERE char_id = $1", charID,
|
||||||
|
).Scan(&t)
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, err
|
||||||
|
}
|
||||||
|
if !t.Valid {
|
||||||
|
return time.Time{}, nil
|
||||||
|
}
|
||||||
|
return t.Time, nil
|
||||||
|
}
|
||||||
|
|
||||||
// FindByRastaID looks up name and id by rasta_id.
|
// FindByRastaID looks up name and id by rasta_id.
|
||||||
func (r *CharacterRepository) FindByRastaID(rastaID int) (charID uint32, name string, err error) {
|
func (r *CharacterRepository) FindByRastaID(rastaID int) (charID uint32, name string, err error) {
|
||||||
err = r.db.QueryRow("SELECT name, id FROM characters WHERE rasta_id=$1", rastaID).Scan(&name, &charID)
|
err = r.db.QueryRow("SELECT name, id FROM characters WHERE rasta_id=$1", rastaID).Scan(&name, &charID)
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ type CharacterRepo interface {
|
|||||||
SaveCharacterData(charID uint32, compSave []byte, hr, gr uint16, isFemale bool, weaponType uint8, weaponID uint16) error
|
SaveCharacterData(charID uint32, compSave []byte, hr, gr uint16, isFemale bool, weaponType uint8, weaponID uint16) error
|
||||||
SaveHouseData(charID uint32, houseTier []byte, houseData, bookshelf, gallery, tore, garden []byte) error
|
SaveHouseData(charID uint32, houseTier []byte, houseData, bookshelf, gallery, tore, garden []byte) error
|
||||||
LoadSaveData(charID uint32) (uint32, []byte, bool, string, error)
|
LoadSaveData(charID uint32) (uint32, []byte, bool, string, error)
|
||||||
|
SaveBackup(charID uint32, slot int, data []byte) error
|
||||||
|
GetLastBackupTime(charID uint32) (time.Time, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GuildRepo defines the contract for guild data access.
|
// GuildRepo defines the contract for guild data access.
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ func (m *mockCharacterRepo) SaveHouseData(_ uint32, _ []byte, _, _, _, _, _ []by
|
|||||||
func (m *mockCharacterRepo) LoadSaveData(_ uint32) (uint32, []byte, bool, string, error) {
|
func (m *mockCharacterRepo) LoadSaveData(_ uint32) (uint32, []byte, bool, string, error) {
|
||||||
return m.loadSaveDataID, m.loadSaveDataData, m.loadSaveDataNew, m.loadSaveDataName, m.loadSaveDataErr
|
return m.loadSaveDataID, m.loadSaveDataData, m.loadSaveDataNew, m.loadSaveDataName, m.loadSaveDataErr
|
||||||
}
|
}
|
||||||
|
func (m *mockCharacterRepo) SaveBackup(_ uint32, _ int, _ []byte) error { return nil }
|
||||||
|
func (m *mockCharacterRepo) GetLastBackupTime(_ uint32) (time.Time, error) { return time.Time{}, nil }
|
||||||
|
|
||||||
// --- mockGoocooRepo ---
|
// --- mockGoocooRepo ---
|
||||||
|
|
||||||
|
|||||||
9
server/migrations/sql/0007_savedata_backups.sql
Normal file
9
server/migrations/sql/0007_savedata_backups.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Rotating savedata backup table (3 slots per character, time-gated).
|
||||||
|
-- Prevents permanent data loss from save corruption by keeping recent snapshots.
|
||||||
|
CREATE TABLE IF NOT EXISTS savedata_backups (
|
||||||
|
char_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||||
|
slot SMALLINT NOT NULL CHECK (slot BETWEEN 0 AND 2),
|
||||||
|
savedata BYTEA NOT NULL,
|
||||||
|
saved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (char_id, slot)
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user