From 269e721fe851b609b05bfeb3cf8ab677f96878a8 Mon Sep 17 00:00:00 2001 From: golem Date: Sun, 18 Oct 2026 02:23:03 -0600 Subject: [PATCH] fix: limit the prompt template to 1,000 characters storage.sync rejects any item over 8192 bytes, and the options page writes every field in one set(). An oversized template therefore made every later save fail, including changes to unrelated settings, with only a brief "Could not save settings". Cap the template at 1,000 UTF-16 units: maxlength on the input, and mergeSettings falls back to the default for a longer stored value, as it does for other out-of-range settings. Even a template of pasted control characters (six bytes each once JSON-escaped) stays under the quota; a test pins that arithmetic and another ties the HTML maxlength to the constant. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + lib/defaults.js | 6 ++++++ lib/settings.js | 4 ++-- options/options.html | 4 ++-- test/settings.test.js | 28 +++++++++++++++++++++++++++- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index e2dba84..48e80c4 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Open `about:addons`, select this extension, and choose the **Options** tab guarantee. - **Open in** — a new tab, a new background tab, or a new window. - **Prompt template** — wraps your text, for example `Explain simply: {query}`. + Up to 1,000 characters. Restore defaults resets extension preferences to Simple mode with translation hidden; it does not change Firefox's search engine or shortcut settings. diff --git a/lib/defaults.js b/lib/defaults.js index fcf5e57..fc8e59c 100644 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -3,6 +3,12 @@ export const OPEN_IN_VALUES = ["new-tab", "background-tab", "new-window"]; +// In UTF-16 units, as maxlength counts. Keeps the template far inside +// storage.sync's 8192-byte item quota even when every character is a pasted +// control character (six bytes once JSON-escaped), and far beyond any template +// the 8,000-character URL budget could send. +export const MAX_TEMPLATE_CHARS = 1000; + export const DEFAULT_SETTINGS = { contextMenuMode: "simple", showTranslate: false, diff --git a/lib/settings.js b/lib/settings.js index 978b926..068a1ba 100644 --- a/lib/settings.js +++ b/lib/settings.js @@ -1,4 +1,4 @@ -import { DEFAULT_SETTINGS, OPEN_IN_VALUES } from "./defaults.js"; +import { DEFAULT_SETTINGS, MAX_TEMPLATE_CHARS, OPEN_IN_VALUES } from "./defaults.js"; export function normalizeLanguage(value) { if (typeof value !== "string") return ""; @@ -28,7 +28,7 @@ export function mergeSettings(stored) { if (OPEN_IN_VALUES.includes(stored.openIn)) { merged.openIn = stored.openIn; } - if (typeof stored.promptTemplate === "string") { + if (typeof stored.promptTemplate === "string" && stored.promptTemplate.length <= MAX_TEMPLATE_CHARS) { merged.promptTemplate = stored.promptTemplate; } return merged; diff --git a/options/options.html b/options/options.html index 127b569..0b852e5 100644 --- a/options/options.html +++ b/options/options.html @@ -80,8 +80,8 @@
- -

{query} is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts.

+ +

{query} is replaced with your selected text. Used by Ask normally, Simple mode, the toolbar button, and the keyboard shortcut. Other advanced actions use their own prompts. Up to 1,000 characters.

diff --git a/test/settings.test.js b/test/settings.test.js index 58b6e13..0f7fb34 100644 --- a/test/settings.test.js +++ b/test/settings.test.js @@ -1,7 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { DEFAULT_SETTINGS } from "../lib/defaults.js"; +import { readFileSync } from "node:fs"; + +import { DEFAULT_SETTINGS, MAX_TEMPLATE_CHARS } from "../lib/defaults.js"; import { mergeSettings } from "../lib/settings.js"; test("empty object yields the defaults", () => { @@ -67,6 +69,30 @@ test("wrong types fall back to the default", () => { assert.equal(merged.promptTemplate, DEFAULT_SETTINGS.promptTemplate); }); +test("a prompt template over the length limit falls back to the default", () => { + assert.equal(typeof MAX_TEMPLATE_CHARS, "number"); + const atLimit = "x".repeat(MAX_TEMPLATE_CHARS); + assert.equal(mergeSettings({ promptTemplate: atLimit }).promptTemplate, atLimit); + assert.equal(mergeSettings({ promptTemplate: `${atLimit}x` }).promptTemplate, + DEFAULT_SETTINGS.promptTemplate); +}); + +// storage.sync rejects any item over 8192 bytes (key plus JSON value), and the +// options page writes every field in one set(), so one oversized template would +// make every later save fail. The worst character is a pasted control +// character, which JSON escapes to six bytes. +test("a template at the limit fits storage.sync's per-item quota in the worst case", () => { + const worst = "\u0001".repeat(MAX_TEMPLATE_CHARS); + const bytes = new TextEncoder().encode("promptTemplate" + JSON.stringify(worst)).length; + assert.ok(bytes <= 8192, `${bytes} bytes`); +}); + +test("the options page enforces the same template limit", () => { + const html = readFileSync(new URL("../options/options.html", import.meta.url), "utf8"); + const input = html.match(/]*id="promptTemplate"[^>]*>/)?.[0] ?? ""; + assert.equal(input.match(/maxlength="(\d+)"/)?.[1], String(MAX_TEMPLATE_CHARS)); +}); + test("an out-of-range openIn falls back to the default", () => { assert.equal(mergeSettings({ openIn: "teleport" }).openIn, DEFAULT_SETTINGS.openIn); assert.equal(mergeSettings({ openIn: "" }).openIn, DEFAULT_SETTINGS.openIn);