Skip to content

Commit 0f7281b

Browse files
committed
Reject direct use of the browser storage globals
Naming localStorage or sessionStorage throws where site data is blocked, and works everywhere else, so the bug passes every local run: it stopped the interview page from loading until d5ad668 and left the lobby's Start doing nothing visible. The lint gate now refuses the names under web/ and points at storageArea(), which answers null instead. web/history.js moves to it through a helper that throws when no store can be reached, because its callers tell a failed delete from an empty store by that throw: each catch still reports the failure, so its results do not change. web/peer-lobby.js is left out with the reason beside the exclusion: it has the same failure, at module scope when opened from an invite, and fixing it means choosing where an invite token lives without storage, not a rename. Close #600 Change-Id: I0b593e4dd9b149bd94a449026b0c75029e774f69
1 parent cb0211d commit 0f7281b

4 files changed

Lines changed: 94 additions & 9 deletions

File tree

‎eslint.config.mjs‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,33 @@ const CORRECTNESS = {
6363
"no-async-promise-executor": "error",
6464
};
6565

66+
// Where site data is blocked, touching either storage global throws rather than
67+
// answering a storage that refuses, and the name alone is enough: at module
68+
// scope in `web/interview.js` it stopped the whole page from loading (d5ad668),
69+
// and as an argument it throws before the callee's own `try` can answer it. Both
70+
// work wherever storage is allowed, so a local run never shows either failure.
71+
const STORAGE_MESSAGE =
72+
'Throws where site data is blocked. Use storageArea() from "./audio-output.js".';
73+
const STORAGE_ACCESS = {
74+
"no-restricted-globals": [
75+
"error",
76+
...["localStorage", "sessionStorage"].map((name) => ({
77+
name,
78+
message: STORAGE_MESSAGE,
79+
})),
80+
],
81+
"no-restricted-properties": [
82+
"error",
83+
...["window", "self", "globalThis"].flatMap((object) =>
84+
["localStorage", "sessionStorage"].map((property) => ({
85+
object,
86+
property,
87+
message: STORAGE_MESSAGE,
88+
})),
89+
),
90+
],
91+
};
92+
6693
export default [
6794
{
6895
// `externals/` holds unrelated checkouts, is untracked, and is excluded in
@@ -84,6 +111,19 @@ export default [
84111
linterOptions: { reportUnusedDisableDirectives: "error" },
85112
rules: CORRECTNESS,
86113
},
114+
{
115+
// Application code only. The review workbench under `.claude/skills` is a
116+
// developer tool, not a page a candidate opens.
117+
//
118+
// `web/peer-lobby.js` is exempt for now. Three of its five
119+
// references sit outside any `try`, one of them at module scope, and a
120+
// mechanical `storageArea(...).setItem` would pass this rule and still throw
121+
// on null. The fix needs a decision about where the invite token lives
122+
// without storage, after the page has removed it from the URL.
123+
files: ["web/**/*.js"],
124+
ignores: ["web/peer-lobby.js"],
125+
rules: STORAGE_ACCESS,
126+
},
87127
{
88128
// Node: the browser tests and the generator scripts.
89129
//

‎tests/browser/account.test.js‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1220,6 +1220,39 @@ test("a report the account kept stays on this device too", async () => {
12201220
);
12211221
});
12221222

1223+
// A device copy that cannot be reached is a failed delete, not a missing report.
1224+
// Guards the null `storageArea` answers: passed through, `storedList` reads it
1225+
// as an empty store and the delete answers "missing".
1226+
test("deleting a report says so when local storage cannot be reached", async () => {
1227+
const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
1228+
Object.defineProperty(globalThis, "localStorage", {
1229+
configurable: true,
1230+
get() {
1231+
throw new DOMException("blocked", "SecurityError");
1232+
},
1233+
});
1234+
try {
1235+
assert.equal(
1236+
await deleteReport("keep", {
1237+
fetcher: async () => response({ signedIn: false }),
1238+
}),
1239+
"failed",
1240+
);
1241+
assert.equal(
1242+
await deleteReport("keep", {
1243+
fetcher: async (url) =>
1244+
url === "/api/session"
1245+
? response({ signedIn: true })
1246+
: response({ deleted: 1 }),
1247+
}),
1248+
"account-deleted-local-failed",
1249+
);
1250+
} finally {
1251+
if (original) Object.defineProperty(globalThis, "localStorage", original);
1252+
else delete globalThis.localStorage;
1253+
}
1254+
});
1255+
12231256
test("deleting an id no store holds says so instead of claiming a removal", async () => {
12241257
// A pre-id row whose assigned id never reached storage: the rows are still
12251258
// there, so answering "deleted" named a removal the next read undoes.

‎web/audio-output.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,8 +163,8 @@ function showOutputNote(text) {
163163
/// there, touching either global throws rather than returning a storage that
164164
/// refuses. The helpers below default through this rather than naming the
165165
/// global, because a default parameter is evaluated before their `try`, and
166-
/// every reader of the result already treats a failing call as "nothing
167-
/// stored", which `null` is.
166+
/// they treat a failing call as "nothing stored", which `null` is. A caller
167+
/// that must tell a failure from an empty store has to check for null itself.
168168
export function storageArea(name) {
169169
try {
170170
return globalThis[name];

‎web/history.js‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { storageArea } from "./audio-output.js";
2+
13
export const historyKey = "codetrial_history";
24
export const reviewHistoryKey = "codetrial_review_history";
35

@@ -14,6 +16,16 @@ const REVIEW_HISTORY_BYTES = 164 * 1024;
1416
/// A deadline turns both into the failure they already know how to report.
1517
const requestTimeoutMs = 10_000;
1618

19+
/// The store a caller passed, or this device's, and a throw where neither can be
20+
/// reached. `storageArea` answers null there, and `storedList` reads a null store
21+
/// as "no list stored", so carrying it on would let `deleteReport` claim a removal
22+
/// it never ran. The throw reaches each caller's catch, as the bare global's did.
23+
function deviceStorage(storage) {
24+
const store = storage || storageArea("localStorage");
25+
if (!store) throw new Error("local storage is unavailable");
26+
return store;
27+
}
28+
1729
/// Always a list. The key sits in the origin's local storage, which an older
1830
/// build, another tab, or anyone with devtools open can write, so what it holds
1931
/// is input rather than something this module chose. Unparseable JSON was
@@ -34,7 +46,7 @@ export function readLocalHistory(storage) {
3446
/// not a list, and the shape check already turns that into one.
3547
function storedList(key, storage) {
3648
try {
37-
const stored = JSON.parse((storage || localStorage).getItem(key));
49+
const stored = JSON.parse(deviceStorage(storage).getItem(key));
3850
return Array.isArray(stored) ? stored : null;
3951
} catch {
4052
return null;
@@ -43,7 +55,7 @@ function storedList(key, storage) {
4355

4456
export function readReviewHistory(storage) {
4557
try {
46-
storage ||= localStorage;
58+
storage = deviceStorage(storage);
4759
const stored = storedList(reviewHistoryKey, storage);
4860
if (stored) return stored;
4961
const { retained, json } = boundedReviews(readLocalHistory(storage));
@@ -73,7 +85,7 @@ export function readReviewHistory(storage) {
7385
/// deduplicated within a store quietly merged them.
7486
export function readDeviceHistory(storage) {
7587
try {
76-
storage ||= localStorage;
88+
storage = deviceStorage(storage);
7789
const history = readLocalHistory(storage);
7890
const reviews = readReviewHistory(storage);
7991
// Mutates both arrays, and writes both stores when it has anything to say.
@@ -194,7 +206,7 @@ function legacyKey(entry) {
194206
/// from the history when it is first read, after this has run.
195207
export function renameLocalHistory(pages, storage, markUnmapped = false) {
196208
try {
197-
storage ||= localStorage;
209+
storage = deviceStorage(storage);
198210
const history = renamedEntries(
199211
readLocalHistory(storage),
200212
pages,
@@ -301,7 +313,7 @@ export async function clearReportHistory({
301313
return "failed";
302314
}
303315
try {
304-
storage ||= localStorage;
316+
storage = deviceStorage(storage);
305317
storage.removeItem(historyKey);
306318
storage.removeItem(reviewHistoryKey);
307319
return "cleared";
@@ -348,7 +360,7 @@ export async function deleteReport(
348360
}
349361
let removed = fromAccount;
350362
try {
351-
storage ||= localStorage;
363+
storage = deviceStorage(storage);
352364
// A missing review store is left missing: it is rebuilt from the short
353365
// history on its first read, and that no longer holds the report.
354366
for (const key of [historyKey, reviewHistoryKey]) {
@@ -374,7 +386,7 @@ export async function deleteReport(
374386

375387
function saveLocalReport(entry, storage) {
376388
try {
377-
storage ||= localStorage;
389+
storage = deviceStorage(storage);
378390
const previous = readLocalHistory(storage);
379391
// The short history first, and before the review store is even read.
380392
// Reading it can rebuild it, and that write is the 164 KB one: spending the

0 commit comments

Comments
 (0)