From 510d361c7bac0ad0a3763256c20f4f3e446eb55d Mon Sep 17 00:00:00 2001 From: galuis116 Date: Sun, 12 Jul 2026 21:12:36 -0400 Subject: [PATCH 1/2] feat(miner-extension): reject oversized pasted ranked-candidates JSON before saving The extension doesn't request the unlimitedStorage permission, so chrome.storage.local is capped at its default ~10 MiB quota with no guard against an unbounded paste silently failing to save. Reject a paste over a conservative size bound with a clear error, before ever attempting to parse or save it. Fixes #4863 --- apps/gittensory-miner-extension/README.md | 4 ++ apps/gittensory-miner-extension/options.js | 13 +++++ test/unit/miner-extension-content.test.ts | 64 ++++++++++++++++++++++ 3 files changed, 81 insertions(+) diff --git a/apps/gittensory-miner-extension/README.md b/apps/gittensory-miner-extension/README.md index 19c548d416..5071648260 100644 --- a/apps/gittensory-miner-extension/README.md +++ b/apps/gittensory-miner-extension/README.md @@ -23,3 +23,7 @@ every save, and looks up the current issue there. When no ranked signal is cache degrades gracefully by staying hidden. The badge itself shows a "last synced" relative-time label (mirroring ORB's shared `RefreshMeta` component's thresholds) so a contributor can tell how stale the pasted data is; the label is omitted entirely for a cache saved before this field existed. + +The extension does not request the `unlimitedStorage` permission, so a paste is rejected with a clear error before +being parsed or saved once it exceeds a conservative size bound well under `chrome.storage.local`'s default 10 MiB +quota, instead of silently failing to save or leaving storage partially written. diff --git a/apps/gittensory-miner-extension/options.js b/apps/gittensory-miner-extension/options.js index 391d54a022..3de908a0ed 100644 --- a/apps/gittensory-miner-extension/options.js +++ b/apps/gittensory-miner-extension/options.js @@ -5,9 +5,21 @@ function parseWatchedRepos(text) { .filter(Boolean); } +// This extension does not request the "unlimitedStorage" permission, so chrome.storage.local is capped at its +// default 10 MiB (QUOTA_BYTES) quota shared across every key -- an unbounded paste can silently fail to save +// or leave storage in a partial state (#4863). Checked against the raw pasted text's UTF-16 length (not a +// TextEncoder byte count) so this stays a plain, portable JS check usable from an unbundled content script; +// it's a conservative proxy for the eventual serialized size, with headroom under the 10 MiB quota. +const MAX_RANKED_CANDIDATES_JSON_CHARS = 8 * 1024 * 1024; + function parseRankedCandidatesJson(text) { const trimmed = String(text ?? "").trim(); if (!trimmed) return []; + if (trimmed.length > MAX_RANKED_CANDIDATES_JSON_CHARS) { + throw new Error( + `Ranked candidates JSON is too large (${trimmed.length.toLocaleString()} characters; limit ${MAX_RANKED_CANDIDATES_JSON_CHARS.toLocaleString()}). Paste a smaller discover-run export.`, + ); + } const parsed = JSON.parse(trimmed); if (!Array.isArray(parsed)) { throw new Error("Ranked candidates JSON must be an array."); @@ -29,6 +41,7 @@ if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { parseWatchedRepos, parseRankedCandidatesJson, removeLegacyDiscoveryIndexUrl, + MAX_RANKED_CANDIDATES_JSON_CHARS, }; } diff --git a/test/unit/miner-extension-content.test.ts b/test/unit/miner-extension-content.test.ts index 9899237d9e..93fd38f11a 100644 --- a/test/unit/miner-extension-content.test.ts +++ b/test/unit/miner-extension-content.test.ts @@ -148,6 +148,69 @@ describe("miner extension opportunity badge", () => { expect(() => internals.parseRankedCandidatesJson('{"not":"array"}')).toThrow(); }); + it("rejects a pasted ranked-candidates JSON payload over the storage-size bound with a clear error, before ever attempting to parse it (#4863)", () => { + const internals = loadOptionsInternals(); + const oversized = "not valid json but that must not matter".padEnd( + internals.MAX_RANKED_CANDIDATES_JSON_CHARS + 1, + "x", + ); + + expect(() => internals.parseRankedCandidatesJson(oversized)).toThrow(/too large/i); + // Invariant: the size check runs before JSON.parse, so an oversized-but-invalid payload fails on size, + // not on a JSON syntax error -- proven by using text that isn't valid JSON at all. + try { + internals.parseRankedCandidatesJson(oversized); + } catch (error) { + expect(String(error)).not.toMatch(/Unexpected token/i); + } + }); + + it("accepts a pasted ranked-candidates JSON payload exactly at the storage-size bound (#4863)", () => { + const internals = loadOptionsInternals(); + const padding = "x".repeat(internals.MAX_RANKED_CANDIDATES_JSON_CHARS - 4); + const atLimit = `["${padding}"]`; + expect(atLimit).toHaveLength(internals.MAX_RANKED_CANDIDATES_JSON_CHARS); + + expect(internals.parseRankedCandidatesJson(atLimit)).toEqual([padding]); + }); + + it("regression: an oversized paste surfaces its error through the save flow and never reaches chrome.storage.local.set (#4863)", async () => { + const localSetCalls: Array> = []; + const elements = { + "#settings": createFormMock(), + "#status": { textContent: "" }, + "#watchedRepos": { value: "JSONbored/gittensory" }, + "#rankedCandidatesJson": { value: "" }, + }; + const context: Record = { + __GITTENSORY_MINER_EXTENSION_TEST__: true, + document: { querySelector: (selector: string) => elements[selector as keyof typeof elements] ?? null }, + chrome: { + storage: { + sync: { get: async () => ({ watchedRepos: [] }), set: async () => {}, remove: async () => {} }, + local: { + get: async () => ({ rankedCandidates: [] }), + set: async (value: Record) => { + localSetCalls.push(value); + }, + }, + }, + }, + window: { setTimeout: () => 0 }, + }; + context.globalThis = context; + const vmContext = createContext(context); + new Script(optionsScript).runInContext(vmContext); + await flushPromises(); + + const internals = vmContext.__gittensoryMinerOptionsInternals as { MAX_RANKED_CANDIDATES_JSON_CHARS: number }; + elements["#rankedCandidatesJson"].value = "x".repeat(internals.MAX_RANKED_CANDIDATES_JSON_CHARS + 1); + await elements["#settings"].dispatchSubmit(); + + expect(elements["#status"].textContent).toMatch(/too large/i); + expect(localSetCalls).toHaveLength(0); + }); + it("REGRESSION (dead-field removal): no discoveryIndexUrl config field remains in the UI or background reads", () => { expect(optionsHtml).not.toMatch(/discoveryIndexUrl/); expect(backgroundScript).not.toMatch(/discoveryIndexUrl/); @@ -482,5 +545,6 @@ function loadOptionsInternals() { parseWatchedRepos: (text: string) => string[]; parseRankedCandidatesJson: (text: string) => unknown[]; removeLegacyDiscoveryIndexUrl: () => Promise; + MAX_RANKED_CANDIDATES_JSON_CHARS: number; }; } From 88764310e882d863c8ac4c34ddd537de7ae3dfd1 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Sun, 12 Jul 2026 21:28:10 -0400 Subject: [PATCH 2/2] fix(miner-extension): measure real UTF-8 byte size for the pasted JSON size guard A UTF-16 character-length check undercounts any multibyte content, so a payload full of non-ASCII characters could pass the size guard added in the previous commit yet still exceed chrome.storage.local's real quota once serialized, recreating the exact silent-failure bug that guard exists to prevent. Measure the actual UTF-8 byte size via TextEncoder instead. --- apps/gittensory-miner-extension/options.js | 18 ++++++++------ test/unit/miner-extension-content.test.ts | 28 +++++++++++++++++----- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/apps/gittensory-miner-extension/options.js b/apps/gittensory-miner-extension/options.js index 3de908a0ed..7480239264 100644 --- a/apps/gittensory-miner-extension/options.js +++ b/apps/gittensory-miner-extension/options.js @@ -7,17 +7,21 @@ function parseWatchedRepos(text) { // This extension does not request the "unlimitedStorage" permission, so chrome.storage.local is capped at its // default 10 MiB (QUOTA_BYTES) quota shared across every key -- an unbounded paste can silently fail to save -// or leave storage in a partial state (#4863). Checked against the raw pasted text's UTF-16 length (not a -// TextEncoder byte count) so this stays a plain, portable JS check usable from an unbundled content script; -// it's a conservative proxy for the eventual serialized size, with headroom under the 10 MiB quota. -const MAX_RANKED_CANDIDATES_JSON_CHARS = 8 * 1024 * 1024; +// or leave storage in a partial state (#4863). Measured with TextEncoder against the actual serialized UTF-8 +// byte size, NOT the pasted text's UTF-16 .length -- a char count undercounts any multibyte content (e.g. a +// non-ASCII repo/issue title), so a payload that passes a char-based check could still exceed the real quota at +// chrome.storage.local.set, recreating the exact silent-failure bug this guard exists to prevent. TextEncoder is +// a standard Web API available in both the real extension runtime and this repo's node:vm test harness (once +// injected into the sandbox context). +const MAX_RANKED_CANDIDATES_JSON_BYTES = 8 * 1024 * 1024; function parseRankedCandidatesJson(text) { const trimmed = String(text ?? "").trim(); if (!trimmed) return []; - if (trimmed.length > MAX_RANKED_CANDIDATES_JSON_CHARS) { + const byteLength = new TextEncoder().encode(trimmed).length; + if (byteLength > MAX_RANKED_CANDIDATES_JSON_BYTES) { throw new Error( - `Ranked candidates JSON is too large (${trimmed.length.toLocaleString()} characters; limit ${MAX_RANKED_CANDIDATES_JSON_CHARS.toLocaleString()}). Paste a smaller discover-run export.`, + `Ranked candidates JSON is too large (${byteLength.toLocaleString()} bytes; limit ${MAX_RANKED_CANDIDATES_JSON_BYTES.toLocaleString()}). Paste a smaller discover-run export.`, ); } const parsed = JSON.parse(trimmed); @@ -41,7 +45,7 @@ if (globalThis.__GITTENSORY_MINER_EXTENSION_TEST__) { parseWatchedRepos, parseRankedCandidatesJson, removeLegacyDiscoveryIndexUrl, - MAX_RANKED_CANDIDATES_JSON_CHARS, + MAX_RANKED_CANDIDATES_JSON_BYTES, }; } diff --git a/test/unit/miner-extension-content.test.ts b/test/unit/miner-extension-content.test.ts index 93fd38f11a..91c87f90c8 100644 --- a/test/unit/miner-extension-content.test.ts +++ b/test/unit/miner-extension-content.test.ts @@ -151,7 +151,7 @@ describe("miner extension opportunity badge", () => { it("rejects a pasted ranked-candidates JSON payload over the storage-size bound with a clear error, before ever attempting to parse it (#4863)", () => { const internals = loadOptionsInternals(); const oversized = "not valid json but that must not matter".padEnd( - internals.MAX_RANKED_CANDIDATES_JSON_CHARS + 1, + internals.MAX_RANKED_CANDIDATES_JSON_BYTES + 1, "x", ); @@ -167,13 +167,25 @@ describe("miner extension opportunity badge", () => { it("accepts a pasted ranked-candidates JSON payload exactly at the storage-size bound (#4863)", () => { const internals = loadOptionsInternals(); - const padding = "x".repeat(internals.MAX_RANKED_CANDIDATES_JSON_CHARS - 4); + const padding = "x".repeat(internals.MAX_RANKED_CANDIDATES_JSON_BYTES - 4); const atLimit = `["${padding}"]`; - expect(atLimit).toHaveLength(internals.MAX_RANKED_CANDIDATES_JSON_CHARS); + expect(atLimit).toHaveLength(internals.MAX_RANKED_CANDIDATES_JSON_BYTES); expect(internals.parseRankedCandidatesJson(atLimit)).toEqual([padding]); }); + it("REGRESSION (gate-caught): measures real UTF-8 byte size, not UTF-16 character count, so multibyte content can't sneak past the bound (#4863)", () => { + const internals = loadOptionsInternals(); + // "é" is 1 UTF-16 code unit but 2 UTF-8 bytes -- a char-length check would see roughly half the real byte + // size and wrongly accept a payload that actually exceeds the quota once chrome.storage.local serializes it. + const halfLimitCharCount = Math.floor(internals.MAX_RANKED_CANDIDATES_JSON_BYTES / 2) + 10; + const padding = "é".repeat(halfLimitCharCount); + const payload = `["${padding}"]`; + + expect(payload.length).toBeLessThan(internals.MAX_RANKED_CANDIDATES_JSON_BYTES); + expect(() => internals.parseRankedCandidatesJson(payload)).toThrow(/too large/i); + }); + it("regression: an oversized paste surfaces its error through the save flow and never reaches chrome.storage.local.set (#4863)", async () => { const localSetCalls: Array> = []; const elements = { @@ -184,6 +196,7 @@ describe("miner extension opportunity badge", () => { }; const context: Record = { __GITTENSORY_MINER_EXTENSION_TEST__: true, + TextEncoder, document: { querySelector: (selector: string) => elements[selector as keyof typeof elements] ?? null }, chrome: { storage: { @@ -203,8 +216,8 @@ describe("miner extension opportunity badge", () => { new Script(optionsScript).runInContext(vmContext); await flushPromises(); - const internals = vmContext.__gittensoryMinerOptionsInternals as { MAX_RANKED_CANDIDATES_JSON_CHARS: number }; - elements["#rankedCandidatesJson"].value = "x".repeat(internals.MAX_RANKED_CANDIDATES_JSON_CHARS + 1); + const internals = vmContext.__gittensoryMinerOptionsInternals as { MAX_RANKED_CANDIDATES_JSON_BYTES: number }; + elements["#rankedCandidatesJson"].value = "x".repeat(internals.MAX_RANKED_CANDIDATES_JSON_BYTES + 1); await elements["#settings"].dispatchSubmit(); expect(elements["#status"].textContent).toMatch(/too large/i); @@ -321,6 +334,7 @@ describe("miner extension opportunity badge", () => { const context: Record = { __GITTENSORY_MINER_EXTENSION_TEST__: true, Date: { now: () => fakeNowMs }, + TextEncoder, document: { querySelector: (selector: string) => elements[selector as keyof typeof elements] ?? null }, chrome: { storage: { @@ -370,6 +384,7 @@ describe("miner extension opportunity badge", () => { }; const context: Record = { __GITTENSORY_MINER_EXTENSION_TEST__: true, + TextEncoder, document: { querySelector: (selector: string) => elements[selector as keyof typeof elements] ?? null }, chrome: { storage: { @@ -529,6 +544,7 @@ function loadBackgroundInternals({ function loadOptionsInternals() { const context: Record = { __GITTENSORY_MINER_EXTENSION_TEST__: true, + TextEncoder, document: { querySelector: () => null }, chrome: { storage: { @@ -545,6 +561,6 @@ function loadOptionsInternals() { parseWatchedRepos: (text: string) => string[]; parseRankedCandidatesJson: (text: string) => unknown[]; removeLegacyDiscoveryIndexUrl: () => Promise; - MAX_RANKED_CANDIDATES_JSON_CHARS: number; + MAX_RANKED_CANDIDATES_JSON_BYTES: number; }; }