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:
Houmgaor
2026-03-17 19:03:43 +01:00
parent 5009a37d19
commit b40217c7fe
13 changed files with 478 additions and 28 deletions

View File

@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
"time"
cfg "erupe-ce/config"
"erupe-ce/network/mhfpacket"
@@ -11,6 +12,12 @@ import (
"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.
func GetCharacterSaveData(s *Session, charID uint32) (*CharacterSaveData, error) {
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")
}
// 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 {
s.kqf = save.KQF
} else {
@@ -74,6 +85,14 @@ func (save *CharacterSaveData) Save(s *Session) error {
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 {
s.logger.Error("Failed to update savedata", zap.Error(err), zap.Uint32("charID", save.CharID))
return fmt.Errorf("save character data: %w", err)
@@ -87,6 +106,37 @@ func (save *CharacterSaveData) Save(s *Session) error {
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) {
pkt := p.(*mhfpacket.MsgMhfSexChanger)
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))