From 021bcd9351ca427540e3b7c912c88beb10fe985b Mon Sep 17 00:00:00 2001 From: catloversg <152669316+catloversg@users.noreply.github.com> Date: Thu, 14 May 2026 00:24:06 +0700 Subject: [PATCH] MISC: Harden saving to avoid save data corruption (#2755) --- src/db.ts | 9 ++++++++- src/engine.tsx | 1 - src/index.tsx | 2 ++ src/ui/React/AlertManager.tsx | 4 +++- src/utils/Protections.ts | 17 ++++++++++++++++- src/utils/SaveDataUtils.ts | 13 +++++++++++++ src/utils/TypeAssertion.ts | 32 ++++++++++++++++++++++++++++---- 7 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/db.ts b/src/db.ts index ad688f803..d83dbeab6 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,5 +1,6 @@ import type { SaveData } from "./types"; -import { isSaveData } from "./utils/TypeAssertion"; +import { InvalidSaveData } from "./utils/SaveDataUtils"; +import { isSaveData, validateSaveData } from "./utils/TypeAssertion"; export class IndexedDBVersionError extends Error { constructor(message: string, options: ErrorOptions) { @@ -100,6 +101,12 @@ export async function load(skipCheckingLoadedData = false): Promise { + // Validate save data at runtime. Players may accidentally manipulate the prototypes of built-in JS objects and cause + // functions in SaveObject.ts to generate invalid save data. + const validationResult = validateSaveData(saveData); + if (!validationResult.success) { + throw new InvalidSaveData(validationResult.message); + } const db = await getDB(); return new Promise((resolve, reject) => { const transaction = db.transaction(["savestring"], "readwrite"); diff --git a/src/engine.tsx b/src/engine.tsx index 78cf9160c..c64072970 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -8,7 +8,6 @@ import { Factions } from "./Faction/Factions"; import { staneksGift } from "./CotMG/Helper"; import { processPassiveFactionRepGain, inviteToFaction } from "./Faction/FactionHelpers"; import { Router } from "./ui/GameRoot"; -import "./utils/Protections"; // Side-effect: Protect against certain unrecoverable errors import "./PersonObjects/Player/PlayerObject"; // For side-effect of creating Player import { diff --git a/src/index.tsx b/src/index.tsx index 836100dc5..ce3298141 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,3 +1,5 @@ +import "./utils/Protections"; // Side-effect: Protect against certain unrecoverable errors + import React from "react"; import ReactDOM from "react-dom"; diff --git a/src/ui/React/AlertManager.tsx b/src/ui/React/AlertManager.tsx index 8f153318e..4971ad6f9 100644 --- a/src/ui/React/AlertManager.tsx +++ b/src/ui/React/AlertManager.tsx @@ -67,7 +67,9 @@ export function AlertManager({ hidden }: { hidden: boolean }): React.ReactElemen textPropsAsString = JSON.stringify(text.props); } catch (e) { console.error(e); - // Use the current timestamp as the fallback value. + } + // Use the current timestamp as the fallback value. + if (textPropsAsString == null) { textPropsAsString = Date.now().toString(); } return cyrb53(textPropsAsString); diff --git a/src/utils/Protections.ts b/src/utils/Protections.ts index 1ff968576..1f1509682 100644 --- a/src/utils/Protections.ts +++ b/src/utils/Protections.ts @@ -1,5 +1,5 @@ // This file is imported for side effects only. -/* Prevent inadvertantly redefining certain window properties, +/* Prevent inadvertently redefining certain window properties, which are known to cause unrecoverable game errors when redefined. The player is able to redefine these properties as writable if desired. */ Object.defineProperties(window, { @@ -7,3 +7,18 @@ Object.defineProperties(window, { Object: { writable: false }, String: { writable: false }, }); + +// Prevent accidentally manipulating IndexedDB APIs. +Object.freeze(IDBFactory.prototype); +Object.freeze(IDBDatabase.prototype); +Object.freeze(IDBTransaction.prototype); +Object.freeze(IDBObjectStore.prototype); +Object.freeze(IDBRequest.prototype); +Object.freeze(IDBOpenDBRequest.prototype); +if (window.indexedDB) { + Object.freeze(window.indexedDB); + Object.defineProperty(window, "indexedDB", { + value: window.indexedDB, + writable: false, + }); +} diff --git a/src/utils/SaveDataUtils.ts b/src/utils/SaveDataUtils.ts index d62f942f8..f023b44b4 100644 --- a/src/utils/SaveDataUtils.ts +++ b/src/utils/SaveDataUtils.ts @@ -35,6 +35,19 @@ async function decompress(binaryData: Uint8Array): Promise } export async function encodeJsonSaveString(jsonSaveString: string): Promise { + if (jsonSaveString == null) { + throw new InvalidSaveData(`jsonSaveString is ${jsonSaveString}`); + } + if (typeof jsonSaveString !== "string") { + console.error(jsonSaveString); + throw new InvalidSaveData(`Type of jsonSaveString is ${typeof jsonSaveString}`); + } + if (!jsonSaveString.startsWith(`{"ctor":"BitburnerSaveObject"`)) { + console.error(jsonSaveString); + throw new InvalidSaveData( + `Invalid jsonSaveString (doesn't seem to contain a BitburnerSaveObject): ${jsonSaveString.slice(0, 100)}`, + ); + } // Fallback to the base64 format if player's browser does not support Compression Streams API. if (canUseBinaryFormat()) { return await compress(jsonSaveString); diff --git a/src/utils/TypeAssertion.ts b/src/utils/TypeAssertion.ts index 997df2c24..6a5685af2 100644 --- a/src/utils/TypeAssertion.ts +++ b/src/utils/TypeAssertion.ts @@ -1,4 +1,5 @@ import type { SaveData, Unknownify } from "../types"; +import type { Result } from "@nsdefs"; // This function is empty because Unknownify is a typesafe assertion on any object with no runtime checks needed. // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -90,12 +91,35 @@ export function assertNumberArray(unknownData: unknown, assertFinite = false): a } } -export function isSaveData(unknownData: unknown): unknownData is SaveData { - if (typeof unknownData === "string") { - return true; +export function validateSaveData(unknownData: unknown): Result { + if (unknownData == null) { + return { success: false, message: `Save data is ${unknownData}` }; } - return unknownData instanceof Uint8Array && unknownData.buffer instanceof ArrayBuffer; + if (unknownData === "") { + return { success: false, message: "Save data is an empty string" }; + } + if (typeof unknownData === "string") { + return { success: true }; + } + + if (!(unknownData instanceof Uint8Array)) { + console.error(unknownData); + return { success: false, message: "Save data is not an instance of Uint8Array" }; + } + if (unknownData.length === 0) { + return { success: false, message: "Save data is an empty Uint8Array" }; + } + if (!(unknownData.buffer instanceof ArrayBuffer)) { + console.error(unknownData.buffer); + return { success: false, message: "Save data is a Uint8Array, but its buffer is not an ArrayBuffer" }; + } + + return { success: true }; +} + +export function isSaveData(unknownData: unknown): unknownData is SaveData { + return validateSaveData(unknownData).success; } export function assertSaveData(unknownData: unknown): asserts unknownData is SaveData {