From 0fd4c48c4cc8343334cb5cb7a3fa9b3dcaba3a53 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 00:11:44 +0200 Subject: [PATCH 1/5] fix(registry): sanitize serial content for ^SN/^SF emit contentSpec restricts `^/~` and other non-alphanumerics at input, but ZPL-imported designs bypass that filter. Re-apply filterContent at emit time so hostile content can't smuggle command/format prefixes (^/~) or parameter separators (`,`) into the ^SN start parameter, and use the sanitised length for ^SF pad-digits so the printed value matches the FD payload. fdField on the FD side stays as belt-and-suspenders for any future spec relaxation. --- src/registry/registry.test.ts | 20 ++++++++++++++++++++ src/registry/serial.tsx | 11 ++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/registry/registry.test.ts b/src/registry/registry.test.ts index 74c878d8..4d140545 100644 --- a/src/registry/registry.test.ts +++ b/src/registry/registry.test.ts @@ -384,6 +384,26 @@ describe('serial.toZPL', () => { expect(zpl).toContain('^SF1,3,Y'); expect(zpl).toContain('^FD001^FS'); }); + + it('strips ^/~ and other non-charset chars from ZPL-imported content', () => { + // contentSpec restricts to alphanumerics at input; toZPL re-applies the + // filter so ZPL-imported designs can't smuggle ^ (command), ~ (format), + // or , (parameter separator) into the ^SN start parameter or FD payload. + const zpl = def.toZPL(makeObj('serial', { + content: 'a^b,c', increment: 1, fontHeight: 30, fontWidth: 0, rotation: 'N', zplMode: 'SN', + })); + expect(zpl).toContain('^SNabc,1,Y^FDabc^FS'); + }); + + it('uses sanitized length for ^SF pad-digits', () => { + // Pad-digits must match the actually-emitted FD payload, not the raw + // (pre-sanitisation) content length. + const zpl = def.toZPL(makeObj('serial', { + content: 'ab^cd', increment: 1, fontHeight: 30, fontWidth: 0, rotation: 'N', zplMode: 'SF', + })); + // 'ab^cd' → 'abcd' (4 chars after stripping ^) + expect(zpl).toContain('^SF1,4,Y^FDabcd^FS'); + }); }); // ── registry completeness ───────────────────────────────────────────────────── diff --git a/src/registry/serial.tsx b/src/registry/serial.tsx index 43536aeb..d79ac0fe 100644 --- a/src/registry/serial.tsx +++ b/src/registry/serial.tsx @@ -1,7 +1,7 @@ import type { ObjectTypeDefinition } from '../types/ObjectType'; import { useT } from '../lib/useT'; import { inputCls, labelCls } from '../components/Properties/styles'; -import { fieldPos } from './zplHelpers'; +import { fieldPos, fdField } from './zplHelpers'; import { filterContent, type ContentSpec } from './contentSpec'; import { RotationSelect } from '../components/Properties/RotationSelect'; import { NumberInput } from '../components/Properties/NumberInput'; @@ -34,12 +34,17 @@ export const serial: ObjectTypeDefinition = { toZPL: (obj) => { const p = obj.props; const field = `${fieldPos(obj)}^A0${p.rotation},${p.fontHeight},${p.fontWidth}`; + // Re-apply the input charset filter at emit time so ZPL-imported content + // (which bypasses the in-app filter) can't smuggle ^/~ into the ^SN start + // parameter or comma-split the parameter list. fdField additionally + // hex-escapes any survivors in the FD payload — belt and suspenders. + const safe = filterContent(p.content, serialSpec); if (p.zplMode === 'SF') { // ^SF: increment, pad-digits (derived from content length), change-per-label - return `${field}^SF${p.increment},${p.content.length},Y^FD${p.content}^FS`; + return `${field}^SF${p.increment},${safe.length},Y${fdField(safe)}`; } // ^SN: start, increment, change-per-label - return `${field}^SN${p.content},${p.increment},Y^FD${p.content}^FS`; + return `${field}^SN${safe},${p.increment},Y${fdField(safe)}`; }, PropertiesPanel: ({ obj, onChange }) => { From 368255d3def90e33a83fd987b4d606de78e29352 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 00:11:52 +0200 Subject: [PATCH 2/5] fix(cache): cap image/font byte size and validate MIME at the loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI `accept="image/*"` and `accept=".ttf,.otf"` are hints only — files arriving via drag-drop or programmatic upload bypass them. Add a 2 MiB cap per image and 4 MiB cap per font, plus MIME / extension checks at the loader boundary. Bounds the localStorage quota damage from a single oversized drop and prevents non-image / non-font bytes from being persisted as data-URLs. --- src/lib/fontCache.test.ts | 26 +++++++++++++++++++++++++- src/lib/fontCache.ts | 17 ++++++++++++++++- src/lib/imageCache.test.ts | 24 +++++++++++++++++++++++- src/lib/imageCache.ts | 17 +++++++++++++++-- 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/src/lib/fontCache.test.ts b/src/lib/fontCache.test.ts index 87bd494f..459382da 100644 --- a/src/lib/fontCache.test.ts +++ b/src/lib/fontCache.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { getFont, getFontFamily, getAllFonts, loadFontFile, removeFont, subscribe } from './fontCache'; +import { + getFont, + getFontFamily, + getAllFonts, + loadFontFile, + removeFont, + subscribe, + MAX_FONT_BYTES, +} from './fontCache'; function clearCache(): void { for (const font of getAllFonts()) { @@ -120,4 +128,20 @@ describe('fontCache', () => { await loadFontFile(makeFakeFile('arial.ttf'), 'ARIAL.TTF'); expect(listener).not.toHaveBeenCalled(); }); + + // ── validation ──────────────────────────────────────────────────────────────── + + it('loadFontFile rejects files without a TTF/OTF extension', async () => { + const file = new File(['x'], 'arial.woff2', { type: 'font/woff2' }); + await expect(loadFontFile(file, 'ARIAL.WOFF2')).rejects.toThrow(/Not a TTF\/OTF font/); + }); + + it('loadFontFile rejects files above the byte cap', async () => { + const oversized = new File( + [new Uint8Array(MAX_FONT_BYTES + 1)], + 'big.ttf', + { type: 'font/ttf' }, + ); + await expect(loadFontFile(oversized, 'BIG.TTF')).rejects.toThrow(/too large/); + }); }); diff --git a/src/lib/fontCache.ts b/src/lib/fontCache.ts index 77f4891e..eca90e5a 100644 --- a/src/lib/fontCache.ts +++ b/src/lib/fontCache.ts @@ -16,6 +16,14 @@ export interface CachedFont { } const LS_PREFIX = 'zpl-font-'; + +/** Hard cap on a single font file. Browser MIME types for fonts are + * inconsistent (TTF often arrives as `application/octet-stream` or empty); + * we accept by extension and rely on this byte cap to bound damage. */ +export const MAX_FONT_BYTES = 4 * 1024 * 1024; + +const FONT_EXT_RE = /\.(ttf|otf)$/i; + const cache = new Map(); const listeners = new Set<() => void>(); @@ -72,8 +80,15 @@ export function getAllFonts(): CachedFont[] { return [...cache.values()]; } -/** Load a TTF/OTF File into the cache under the given printer font name. */ +/** Load a TTF/OTF File into the cache under the given printer font name. + * Rejects on non-TTF/OTF extension or oversized files. */ export async function loadFontFile(file: File, printerName: string): Promise { + if (!FONT_EXT_RE.test(file.name)) { + throw new Error(`Not a TTF/OTF font: ${file.name}`); + } + if (file.size > MAX_FONT_BYTES) { + throw new Error(`Font too large: ${file.name} (${file.size} bytes, max ${MAX_FONT_BYTES})`); + } return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = async () => { diff --git a/src/lib/imageCache.test.ts b/src/lib/imageCache.test.ts index 2e093357..211ca66a 100644 --- a/src/lib/imageCache.test.ts +++ b/src/lib/imageCache.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect, beforeEach } from 'vitest'; -import { getImage, getAllImages, putImage, removeImage } from './imageCache'; +import { + getImage, + getAllImages, + putImage, + removeImage, + loadImageFile, + MAX_IMAGE_BYTES, +} from './imageCache'; import type { CachedImage } from './imageCache'; function makeFakeImage(id: string): CachedImage { @@ -66,4 +73,19 @@ describe('imageCache', () => { expect(img.width).toBe(50); expect(getAllImages()).toHaveLength(1); }); + + it('loadImageFile rejects non-image MIME types', async () => { + const file = new File(['hello'], 'bad.txt', { type: 'text/plain' }); + await expect(loadImageFile(file)).rejects.toThrow(/Not an image/); + }); + + it('loadImageFile rejects files above the byte cap', async () => { + // File constructor accepts size from chunks; pad with a buffer larger than cap. + const oversized = new File( + [new Uint8Array(MAX_IMAGE_BYTES + 1)], + 'big.png', + { type: 'image/png' }, + ); + await expect(loadImageFile(oversized)).rejects.toThrow(/too large/); + }); }); diff --git a/src/lib/imageCache.ts b/src/lib/imageCache.ts index 73558e3f..a0e8ea2f 100644 --- a/src/lib/imageCache.ts +++ b/src/lib/imageCache.ts @@ -17,6 +17,12 @@ export interface CachedImage { const LS_PREFIX = 'zpl-img-'; +/** Hard cap on a single image's source bytes. localStorage quota across all + * origins is ~5 MiB; capping per-image at 2 MiB stops one oversized drop + * from filling the entire cache. The UI's `accept="image/*"` is a hint + * only — this is the authoritative limit. */ +export const MAX_IMAGE_BYTES = 2 * 1024 * 1024; + const cache = new Map(); // Hydrate from localStorage on module load @@ -53,8 +59,15 @@ export function removeImage(id: string): void { localStorage.removeItem(LS_PREFIX + id); } -/** Load a File into the cache. Returns the CachedImage entry. */ -export function loadImageFile(file: File): Promise { +/** Load a File into the cache. Returns the CachedImage entry. Rejects on + * non-image MIME type, oversized files, or decode failures. */ +export async function loadImageFile(file: File): Promise { + if (!file.type.startsWith('image/')) { + throw new Error(`Not an image: ${file.name}`); + } + if (file.size > MAX_IMAGE_BYTES) { + throw new Error(`Image too large: ${file.name} (${file.size} bytes, max ${MAX_IMAGE_BYTES})`); + } return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { From 7a559a4a6dbb5a3fe7c0e0cb80cbfac8a64c7483 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 00:17:36 +0200 Subject: [PATCH 3/5] refactor(lib): dedupe localStorage hydration/setItem patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both imageCache and fontCache had identical `for (let i = 0; i < length; i++) … try JSON.parse` hydration loops and identical try/setItem/catch quota guards. Extract into localStorageBucket: `hydrateLocalStoragePrefix` and `safeLocalStorageSet`. Each cache module now expresses its concern (image/font specifics) without re-implementing the storage plumbing. --- src/lib/fontCache.ts | 26 +++++---------- src/lib/imageCache.ts | 22 ++++--------- src/lib/localStorageBucket.test.ts | 52 ++++++++++++++++++++++++++++++ src/lib/localStorageBucket.ts | 42 ++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 34 deletions(-) create mode 100644 src/lib/localStorageBucket.test.ts create mode 100644 src/lib/localStorageBucket.ts diff --git a/src/lib/fontCache.ts b/src/lib/fontCache.ts index eca90e5a..cfd85eb5 100644 --- a/src/lib/fontCache.ts +++ b/src/lib/fontCache.ts @@ -5,6 +5,8 @@ * so Konva (canvas) can render text using it. */ +import { hydrateLocalStoragePrefix, safeLocalStorageSet } from "./localStorageBucket"; + export interface CachedFont { id: string; /** Original printer filename e.g. "ARIAL.TTF" (uppercased for lookup) */ @@ -52,19 +54,11 @@ async function registerFontFace(entry: CachedFont): Promise { } } -// Hydrate from localStorage on module load -for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key?.startsWith(LS_PREFIX)) continue; - try { - const entry = JSON.parse(localStorage.getItem(key) ?? 'null') as CachedFont; - cache.set(entry.name, entry); - // Re-register fonts asynchronously — canvas renders after React mounts - void registerFontFace(entry); - } catch { - // ignore corrupt entries - } -} +hydrateLocalStoragePrefix(LS_PREFIX, (entry) => { + cache.set(entry.name, entry); + // Re-register asynchronously — canvas renders after React mounts. + void registerFontFace(entry); +}); /** Look up a cached font by printer filename (case-insensitive). */ export function getFont(printerName: string): CachedFont | undefined { @@ -97,11 +91,7 @@ export async function loadFontFile(file: File, printerName: string): Promise(); -// Hydrate from localStorage on module load -for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (!key?.startsWith(LS_PREFIX)) continue; - try { - const entry = JSON.parse(localStorage.getItem(key) ?? 'null') as CachedImage; - cache.set(entry.id, entry); - } catch { - // ignore corrupt entries - } -} +hydrateLocalStoragePrefix(LS_PREFIX, (entry) => { + cache.set(entry.id, entry); +}); export function getImage(id: string): CachedImage | undefined { return cache.get(id); @@ -47,11 +41,7 @@ export function getAllImages(): CachedImage[] { export function putImage(img: CachedImage): void { cache.set(img.id, img); - try { - localStorage.setItem(LS_PREFIX + img.id, JSON.stringify(img)); - } catch { - // localStorage full — image stays in memory only - } + safeLocalStorageSet(LS_PREFIX + img.id, JSON.stringify(img)); } export function removeImage(id: string): void { diff --git a/src/lib/localStorageBucket.test.ts b/src/lib/localStorageBucket.test.ts new file mode 100644 index 00000000..5e812a07 --- /dev/null +++ b/src/lib/localStorageBucket.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi } from "vitest"; +import { + hydrateLocalStoragePrefix, + safeLocalStorageSet, +} from "./localStorageBucket"; + +// The vitest setup's localStorage shim retains keys after `clear()` (sets to +// undefined), so cross-test ghost keys would leak into hydration. Each test +// uses a unique prefix instead of relying on `clear`. + +describe("hydrateLocalStoragePrefix", () => { + it("forwards parsed entries that match the prefix", () => { + localStorage.setItem("hyd-a-1", JSON.stringify({ id: "1" })); + localStorage.setItem("hyd-a-2", JSON.stringify({ id: "2" })); + localStorage.setItem("other-x", JSON.stringify({ id: "x" })); + + const seen: { id: string }[] = []; + hydrateLocalStoragePrefix<{ id: string }>("hyd-a-", (e) => seen.push(e)); + + expect(seen.map((e) => e.id).sort()).toEqual(["1", "2"]); + }); + + it("silently drops corrupt JSON", () => { + localStorage.setItem("hyd-b-good", JSON.stringify({ id: "good" })); + localStorage.setItem("hyd-b-bad", "{not-json"); + + const seen: { id: string }[] = []; + hydrateLocalStoragePrefix<{ id: string }>("hyd-b-", (e) => seen.push(e)); + + expect(seen).toEqual([{ id: "good" }]); + }); +}); + +describe("safeLocalStorageSet", () => { + it("writes the value when storage accepts it", () => { + safeLocalStorageSet("sls-write-key", "v"); + expect(localStorage.getItem("sls-write-key")).toBe("v"); + }); + + it("swallows quota errors", () => { + const spy = vi + .spyOn(localStorage, "setItem") + .mockImplementation(() => { + throw new Error("QuotaExceededError"); + }); + try { + expect(() => safeLocalStorageSet("sls-quota-key", "v")).not.toThrow(); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/src/lib/localStorageBucket.ts b/src/lib/localStorageBucket.ts new file mode 100644 index 00000000..1e8287ae --- /dev/null +++ b/src/lib/localStorageBucket.ts @@ -0,0 +1,42 @@ +/** + * Prefix-keyed localStorage helpers shared by the image / font caches. + * + * Both caches persist data-URL-bearing entries so reloads survive, but they + * must never crash the app: quota exhaustion and corrupt JSON are routine. + * These helpers centralise the two failure modes (quota on write, corrupt + * entries on hydration) so each cache module stays focused on its domain. + */ + +/** + * Iterate every localStorage entry whose key starts with `prefix`, parse it + * as JSON, and forward the parsed value to `accept`. Corrupt entries are + * silently dropped — runtime hydration must never throw. + */ +export function hydrateLocalStoragePrefix( + prefix: string, + accept: (entry: T) => void, +): void { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key?.startsWith(prefix)) continue; + try { + const entry = JSON.parse(localStorage.getItem(key) ?? "null") as T; + accept(entry); + } catch { + // ignore corrupt entries + } + } +} + +/** + * Best-effort `setItem` that swallows quota errors. Callers keep an + * in-memory copy as the authoritative state; localStorage is only the + * cross-reload survival mechanism. + */ +export function safeLocalStorageSet(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + // localStorage full — caller's in-memory copy stays authoritative + } +} From 0113e239a91c31fdc15269f9a75473f7c287f966 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 00:18:16 +0200 Subject: [PATCH 4/5] fix(lib): skip null reads in hydrateLocalStoragePrefix Forward only well-defined entries to the consumer. `getItem` returns null for removed keys (the test-env localStorage shim retains tombstones after `clear`); skipping early avoids forwarding JS `null` typed as T. --- src/lib/localStorageBucket.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/localStorageBucket.ts b/src/lib/localStorageBucket.ts index 1e8287ae..ff97daed 100644 --- a/src/lib/localStorageBucket.ts +++ b/src/lib/localStorageBucket.ts @@ -19,9 +19,10 @@ export function hydrateLocalStoragePrefix( for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (!key?.startsWith(prefix)) continue; + const raw = localStorage.getItem(key); + if (raw === null) continue; try { - const entry = JSON.parse(localStorage.getItem(key) ?? "null") as T; - accept(entry); + accept(JSON.parse(raw) as T); } catch { // ignore corrupt entries } From 87b6b96d18a3cfe68185b75d6c6823de65b21357 Mon Sep 17 00:00:00 2001 From: u8array Date: Sun, 10 May 2026 08:23:17 +0200 Subject: [PATCH 5/5] fix(lib): harden hydrateLocalStoragePrefix against external/concurrent mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot the matching keys before the parse loop so an `accept` callback that removes or adds localStorage entries can't shift indexes. Also drop non-object primitives (numbers, booleans, arrays, null) — `T` is contractually an object shape, so a stray `localStorage.setItem(..., "true")` shouldn't propagate as a typed entry. --- src/lib/localStorageBucket.test.ts | 28 ++++++++++++++++++++++++++++ src/lib/localStorageBucket.ts | 19 +++++++++++++++---- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/src/lib/localStorageBucket.test.ts b/src/lib/localStorageBucket.test.ts index 5e812a07..80f7d405 100644 --- a/src/lib/localStorageBucket.test.ts +++ b/src/lib/localStorageBucket.test.ts @@ -29,6 +29,34 @@ describe("hydrateLocalStoragePrefix", () => { expect(seen).toEqual([{ id: "good" }]); }); + + it("drops non-object primitives (number, bool, null, array)", () => { + localStorage.setItem("hyd-c-num", "42"); + localStorage.setItem("hyd-c-bool", "true"); + localStorage.setItem("hyd-c-null", "null"); + localStorage.setItem("hyd-c-arr", "[1,2]"); + localStorage.setItem("hyd-c-obj", JSON.stringify({ id: "ok" })); + + const seen: { id: string }[] = []; + hydrateLocalStoragePrefix<{ id: string }>("hyd-c-", (e) => seen.push(e)); + + expect(seen).toEqual([{ id: "ok" }]); + }); + + it("snapshots keys so an accept callback can mutate localStorage", () => { + localStorage.setItem("hyd-d-1", JSON.stringify({ id: "1" })); + localStorage.setItem("hyd-d-2", JSON.stringify({ id: "2" })); + localStorage.setItem("hyd-d-3", JSON.stringify({ id: "3" })); + + const seen: string[] = []; + hydrateLocalStoragePrefix<{ id: string }>("hyd-d-", (e) => { + seen.push(e.id); + // Mutating the store mid-iteration must not cause us to skip entries. + localStorage.removeItem(`hyd-d-${e.id}`); + }); + + expect(seen.sort()).toEqual(["1", "2", "3"]); + }); }); describe("safeLocalStorageSet", () => { diff --git a/src/lib/localStorageBucket.ts b/src/lib/localStorageBucket.ts index ff97daed..609a298d 100644 --- a/src/lib/localStorageBucket.ts +++ b/src/lib/localStorageBucket.ts @@ -9,20 +9,31 @@ /** * Iterate every localStorage entry whose key starts with `prefix`, parse it - * as JSON, and forward the parsed value to `accept`. Corrupt entries are - * silently dropped — runtime hydration must never throw. + * as JSON, and forward the parsed value to `accept`. Corrupt entries and + * non-object primitives (numbers, booleans, null, arrays) are silently + * dropped — runtime hydration must never throw, and `T` is contractually + * an object shape. + * + * Keys are snapshotted before iteration so an `accept` callback that + * removes or adds localStorage entries can't shift indexes mid-loop. */ export function hydrateLocalStoragePrefix( prefix: string, accept: (entry: T) => void, ): void { + const keys: string[] = []; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); - if (!key?.startsWith(prefix)) continue; + if (key?.startsWith(prefix)) keys.push(key); + } + for (const key of keys) { const raw = localStorage.getItem(key); if (raw === null) continue; try { - accept(JSON.parse(raw) as T); + const entry: unknown = JSON.parse(raw); + if (entry !== null && typeof entry === "object" && !Array.isArray(entry)) { + accept(entry as T); + } } catch { // ignore corrupt entries }