Files
Erupe/server/channelserver/handlers_character.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

144 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package channelserver
import (
"database/sql"
"errors"
"fmt"
"time"
cfg "erupe-ce/config"
"erupe-ce/network/mhfpacket"
"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)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
s.logger.Error("No savedata found", zap.Uint32("charID", charID))
return nil, errors.New("no savedata found")
}
s.logger.Error("Failed to get savedata", zap.Error(err), zap.Uint32("charID", charID))
return nil, err
}
saveData := &CharacterSaveData{
CharID: id,
compSave: savedata,
IsNewCharacter: isNew,
Name: name,
Mode: s.server.erupeConfig.RealClientMode,
Pointers: getPointers(s.server.erupeConfig.RealClientMode),
}
if saveData.compSave == nil {
return saveData, nil
}
err = saveData.Decompress()
if err != nil {
s.logger.Error("Failed to decompress savedata", zap.Error(err))
return nil, err
}
saveData.updateStructWithSaveData()
return saveData, nil
}
func (save *CharacterSaveData) Save(s *Session) error {
if save.decompSave == nil {
s.logger.Warn("No decompressed save data, skipping save",
zap.Uint32("charID", save.CharID),
)
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 {
save.KQF = s.kqf
}
save.updateSaveDataWithStruct()
if s.server.erupeConfig.RealClientMode >= cfg.G1 {
err := save.Compress()
if err != nil {
s.logger.Error("Failed to compress savedata", zap.Error(err))
return fmt.Errorf("compress savedata: %w", err)
}
} else {
// Saves were not compressed
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)
}
if err := s.server.charRepo.SaveHouseData(s.charID, save.HouseTier, save.HouseData, save.BookshelfData, save.GalleryData, save.ToreData, save.GardenData); err != nil {
s.logger.Error("Failed to update user binary house data", zap.Error(err))
return fmt.Errorf("save house data: %w", err)
}
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))
}