Repository navigation
fix(web): report localStorage save success via sentinel completion value (#402) - #410
Conversation
localStorage.setItem returns undefined on success and JavaScriptBridge.eval converts both that value and a thrown quota/security error to null, so _local_storage_write reported every web save as failed. Append a fixed sentinel string literal to the write eval statement so success has a deterministic completion value, and detect it by exact match. Fixes #402.
There was a problem hiding this comment.
AI Automated Review
Full PR review.
Analysis engine: MiniMax-M3 (anthropic) — primary route
Partial coverage: required-check coverage is incomplete — this review did not resolve every required check and must not be read as a complete pass.
Findings (2 info)
| Severity | Location | Finding |
|---|---|---|
| Info | tests/test_local_storage_xss.gd:41 |
The new flow_write_success_detection_matches_sentinel registers before each flow_export call but after the round-trip and single-call-shape flows; this matches the existing ordering pattern and is correct. |
| Info | scripts/game_state.gd:688 |
save_settings (line 688) calls _local_storage_write and discards the bool — pre-existing behavior filed as follow-up PR 409, out of scope for this PR; the PR does not regress this path. |
Recommendation
Approve. The fix is targeted, internally consistent, and matches the linked issue's acceptance criteria. The PR swaps an un-decodable eval result (undefined and thrown errors both arriving as GDScript null) for a deterministic sentinel completion value, gates detection behind a typeof == TYPE_STRING guard, and extends the existing browser-free XSS suite to cover the new wiring.
Change-by-change findings
scripts/game_state.gd(build_local_storage_write_eval, lines 51–61): a newWRITE_EVAL_SUCCESS_SENTINEL := "windowstead-write-ok"constant is appended viaJSON.stringify(WRITE_EVAL_SUCCESS_SENTINEL). Because the sentinel is interpolated throughJSON.stringifyrather than concatenated raw, the produced statement islocalStorage.setItem("<key>", "<payload>"); "windowstead-write-ok"— a JSON-encoded string literal appended after a semicolon. The first(and the last char of the call part still belong tolocalStorage.setItem(…); attacker-controlled bytes are confined inside the JSON string args as before. Held length is held.scripts/game_state.gd(_local_storage_write, lines 73–75): the oldif result == null/String(result)cascade is replaced byreturn local_storage_write_succeeded(result). The old code would have hitString(false)/String(0)iflocalStorage.setItemever returned a non-string non-null value through some bridge version; the new code gates ontypeof(result) == TYPE_STRINGbefore the comparison, so no untrustedString(...)coercion runs in the common path. Held length is held for the original invariant (true only on real success, false on throws).scripts/game_state.gd(local_storage_write_succeeded, lines 81–82): the new static helper.typeof(result) == TYPE_STRING and result == WRITE_EVAL_SUCCESS_SENTINELcorrectly distinguishes the sentinel from the previous failure modes (thrown → null → false; empty string → false; literal"null"→ false; boolean false → false; 0 → false) and is exercised byflow_write_success_detection_matches_sentinel. Held.tests/test_local_storage_xss.gd:WRITE_STATEMENT_SUFFIX := "; \"windowstead-write-ok\""mirrors the production string (including the surrounding quotes) and is stripped before the existingflow_write_eval_statement_round_trips_evil_key/flow_write_eval_statement_round_trips_evil_payload/flow_eval_statement_has_single_call_shapeflows re-check the call shape, so XSS coverage is not weakened — it is hardened by the explicit ends-with assertion added at the top of each round-trip flow. The newflow_write_success_detection_matches_sentinelruns entirely withoutJavaScriptBridge(it imports the static helpers viaGameState.local_storage_write_succeeded(...)andGameState.WRITE_EVAL_SUCCESS_SENTINEL), satisfying the issue's "without a browser" acceptance criterion. Held.
Claim-by-claim verification
- Claim 1 ("these helpers are the only place either quirk lives") — held. Repo grep finds only
scripts/game_state.gd:51(constant),scripts/game_state.gd:60–61(builder),scripts/game_state.gd:73–75(writer),scripts/game_state.gd:81–82(decoder). The only other writer of localStorage state issave_settingsatscripts/game_state.gd:685, which already routes through_local_storage_writeand is therefore covered by the same fix. - Claim 2 ("thrown quota/security error arrives as null, so the bare call cannot signal success") — held. The previous
if result == null: return falsebranch (which could not distinguish success from throw) is gone; the new decoder accepts only the exact sentinel string, so a thrown error →null→false. - Claim 3 ("
local_storage_write_succeeded: only the exact sentinel string means success") — held for the seven cases the suite asserts (sentinel → true; null / "" / "null" / "ok" / false / 0 → false). The new test imports the static helper directly, so the assertion is exact-match against the constant, not against a literal string that could drift. - Claim 4 ("the first
(must be the call-opening paren and the last char of the call part must be the call-closing)") — held forflow_write_eval_statement_round_trips_evil_key,flow_write_eval_statement_round_trips_evil_payload, and the rewrittenflow_eval_statement_has_single_call_shape, all of which now strip the fixed suffix before checking call shape. The evil keys tested include a trailing((inflow_eval_statement_has_single_call_shape) and complex payloads containing parens/backticks/backslashes, which all still round-trip cleanly. - Claim 5 ("any attacker-supplied parens are confined to the JSON string args") — held for the keys/payloads tested. The appended suffix is a fixed literal interpolated through
JSON.stringify(which escapes any embedded quotes/backslashes/control chars), so no attacker-controlled input reaches the suffix position. The diff additionally removed the oldString(result)coercion that was only safe whenresultwas already a string, replacing it with thetypeof == TYPE_STRINGshort-circuit before the comparison.
Sources
scripts/game_state.gdlines 51, 60–61, 73–75, 81–82, 122, 685, 688 (production change and the other localStorage writer that reuses it).scripts/main.gdlines 1832–1861 (persist) and ~743 (save_game) — the only consumers ofGameState.save_game/_local_storage_write's bool; both already treat true as success and false as retry-announce, so they behave correctly once the return becomes truthful.tests/test_local_storage_xss.gdlines 17–24 (sentinel suffix constant), 41 (flow_write_success_detection_matches_sentinelregistered), 87–104 (round-trip flows with the suffix stripped), 126–149 (single-call shape and success-detection flows).tests/test_case.gd— confirms theassert_eq(actual, expected, name)3-arg convention enforced by AGENTS.md is satisfied throughout;assert_true/assert_falseaccept the 2-arg(condition, name)form the new tests use.- AGENTS.md — review/implementation notes (migration-first; tests run headless with
HOME=/tmp XDG_DATA_HOME=/tmp/.local/share;assert_eq3-arg trap documented at PR 321). - CI status for
6e5ef0b(head of this branch): script test suite, release-tag validation, macOS validation, headless smoke, and all four export validations (Web, macOS, Windows, Linux) all succeeded.
Standards Compliance
AGENTS.md's review notes and testing traps are followed — the new test calls assert_eq only with three arguments (no risk of the PR 321 parse-error trap that drops the whole suite), runs against the same headless 4.7.1 toolchain referenced in godot-toolchain.json and .justfile, and uses the preload(...) / load(...) patterns the suite conventions require. The change does not alter save format or migration; setItem still receives the exact same JSON.stringify(data) payload byte-for-byte, so no migration-first work is needed.
Linked Issue Fit
Issue PR 402 asks for: _local_storage_write returns true when setItem succeeds and false on genuine failure; existing XSS guarantees preserved; a regression test that asserts the new logic without a browser; on the web build, a successful save no longer surfaces "Save failed" and clears the dirty flag. The PR delivers all four:
- The new sentinel gives a deterministic completion value distinct from a thrown error; the decoder gates on
typeof == TYPE_STRINGand exact match. - XSS guarantees in
build_local_storage_write_evalare unchanged (JSON-encoded key/payload) and the test suite now explicitly asserts the call shape after stripping the fixed suffix, with the same evil keys/payloads as before plus the trailing-(key in the single-call-shape flow. flow_write_success_detection_matches_sentinelexercises the static decoder directly, with no browser.- The consumers (
persistat ~1845, Save button at ~743) already treat a true return as success — they were correct all along; the bug was the lying input, which is now fixed.
Tool Harness Findings
Fifteen tool calls confirmed:
- The new constant, builder, writer, and decoder all live at the expected line ranges in
scripts/game_state.gd. - The only other writer of the same localStorage state is
save_settingsatscripts/game_state.gd:685–688(calls_local_storage_write(SETTINGS_KEY, payload)and discards the result — the pre-existing PR 409 follow-up, out of scope here per the PR body). - No production code uses
String(result)/String(false)/String(0)anywhere inscripts/, so the CI script-error rail concern in the PR description is satisfied. scripts/main.gd'spersist()(~1832–1861) andsave_game()(~743) already implement the desired success/retry semantics; they become correct on the web once_local_storage_write's return is truthful.tests/test_local_storage_xss.gd's helper functions (_extract_first_json_arg,_extract_second_json_arg,_find_top_level_comma) operate only on the JSON-encoded args and are reused unmodified — the round-trip flows still prove the key/payload surviveJSON.stringify→ eval-string →JSON.parse_string.tests/test_runner.gdis a separate suite that exercises the desktop path; the web-save XSS regression lives intest_local_storage_xss.gd, which CI runs headlessly per the AGENTS.md instructions.
Unknowns or Needs Verification
None for the verdict. Two items worth noting but not blocking:
- The PR body mentions a follow-up issue PR 409 for
save_settings(which discards_local_storage_write's return). That is explicitly out of scope here, and the PR does not regress that path —save_settingscontinues to ignore the result as before. - The fix is invisible to headless CI because no CI job runs the web build in a browser (the export-validation job only checks artifacts exist, per the issue body). The CI rail that does fire (
tests/test_local_storage_xss.gd) covers the eval statement shape and the success-detection logic without a browser, which is the appropriate coverage for a stateless helper. Runtime browser verification remains a manual concern, matching the pre-existing repo posture.
Requirement trace
1 of 1 requirement(s) not fully traced to enforcement and a test:
req-6f9f0abbedbf— unverifiable (no valid enforcement location)
Approval withheld: this review's coverage is incomplete — required-check coverage or the tool-loop investigation did not finish, so it is publishing as an advisory comment rather than an approval.
What
Fixes #402.
On the web build,
GameState._local_storage_writereported every save as failed:localStorage.setItem(...)returnsundefinedon success andJavaScriptBridge.evalconvertsundefined— like a thrown quota/security error — to GDScriptnull, so the oldif result == null: return falsecould not distinguish success from failure.main.gd persist()therefore keptsim.dirtyset, announced "Save failed: colony progress is not being persisted.", and rewrote localStorage every 10 ticks; the Save button always reported failure.The write eval statement now ends with a fixed sentinel string literal (
WRITE_EVAL_SUCCESS_SENTINEL), giving it a deterministic completion value; success is detected by exact match in the newlocal_storage_write_succeededdecoder. A throw still yieldsnull→false.Invariants this change keeps, and every other path enforcing the same rules
build_local_storage_write_eval(scripts/game_state.gd): unchanged JSON encoding of key/payload; the appended; "windowstead-write-ok"is a fixed literal, not attacker-controllable (evil content is confined inside the JSON string args). Kept by the updatedtests/test_local_storage_xss.gdround-trip + single-call-shape tests, which strip only the exact fixed suffix before re-checking thelocalStorage.setItem(...)call.build_local_storage_read_eval/build_local_storage_remove_eval(scripts/game_state.gd): untouched; their XSS tests unchanged._local_storage_writereturnstrueonly when the write actually happened;falseon genuine failure (issue [P3] persist() clears dirty before write succeeds; second Save click after a failure reports success without saving #379 retry semantics)._save_game_impl(web branch,SAVE_KEY) →main.gd persist()(~1845) now clearssim.dirty/_persist_failure_announcedon web success;main.gd save_game()(~743) Save button reports success only on real writes; both unchanged in code and keep behaving correctly because the bool is now truthful.save_settings(end ofscripts/game_state.gd) calls_local_storage_writeand discards its result (pre-existing) — filed as follow-up [P3] save_settings discards the web localStorage write result — settings save failures are invisible #409, out of scope here.clear_game'sremoveItempath ignores eval results;removeItemis idempotent and has no false-success consumer, so no sentinel needed._write_text_file(FileAccess) and the desktop save/load paths are untouched.setItemreceives the exact sameJSON.stringify(data)payload byte-for-byte; only the eval wrapper changed. Load,validate_save_schema, andmigrate_savepaths are untouched (save/version migration stays migration-first — nothing to migrate here).SCRIPT ERRORrail: noString(...)conversion is performed on untrusted Variant types —local_storage_write_succeededuses atypeof(result) == TYPE_STRINGguard (String(false)/String(0)throws a runtime script error in Godot 4.7.1, which would fail the suite per the CI log check).Verification
godot --headless --path . --script res://tests/<suite>.gdfor all 30 suites, Godot 4.7.1-stable, same SHA256 asgodot-toolchain.json): all pass, noSCRIPT ERROR/ Parse Error lines.tests/test_local_storage_xss.gdextended: success-detection asserts (sentinel → true;null,"","null", arbitrary string,false,0→ false) without a browser, per the issue's acceptance criteria.globalThis.evalbehavior (trailing string literal is the completion value; throws propagate).