mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-26 17:43:21 +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:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user