← back to Wallco Ai
harden vendor redaction in library cache + scrub helper
8cdbb6b04600803d7426d2852d46338101b8fc3e · 2026-05-20 00:03:21 -0700 · Steve Abrams
Reason: first smoke pass surfaced 154 vendor-name leaks in /library HTML and 1 in
/api/library JSON. Root cause — shopify_color_enrichment.title and handle embed the
vendor as " | Thibaut" / "(Anna French)" / "-koroseal" decorations, and the in-server
regex covered only 50 of the 164 actual vendors. Fix is two-layer:
- cache-build pre-scrub strips the row's OWN _vendor_raw from title + pattern_name
using a tight regex around the actual brand string (one row → one match → safe).
This makes the cache self-protecting regardless of the server-side scrubber.
- server-side scrubber retained as belt-and-suspenders for the well-known 50.
- redact also drops shopify_id + handle from the JSON payload — handle was the
one residual JSON leak after the title scrub.
Smoke-test re-run shows 0 vendor leaks in /library HTML text (the 1 remaining match
is inside a Shopify CDN background-image URL, not user-visible text; TODO documented
in src/library.js for a future /img-proxy/:libId).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M scripts/build-library-cache.jsM src/library.js
Diff
commit 8cdbb6b04600803d7426d2852d46338101b8fc3e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 20 00:03:21 2026 -0700
harden vendor redaction in library cache + scrub helper
Reason: first smoke pass surfaced 154 vendor-name leaks in /library HTML and 1 in
/api/library JSON. Root cause — shopify_color_enrichment.title and handle embed the
vendor as " | Thibaut" / "(Anna French)" / "-koroseal" decorations, and the in-server
regex covered only 50 of the 164 actual vendors. Fix is two-layer:
- cache-build pre-scrub strips the row's OWN _vendor_raw from title + pattern_name
using a tight regex around the actual brand string (one row → one match → safe).
This makes the cache self-protecting regardless of the server-side scrubber.
- server-side scrubber retained as belt-and-suspenders for the well-known 50.
- redact also drops shopify_id + handle from the JSON payload — handle was the
one residual JSON leak after the title scrub.
Smoke-test re-run shows 0 vendor leaks in /library HTML text (the 1 remaining match
is inside a Shopify CDN background-image URL, not user-visible text; TODO documented
in src/library.js for a future /img-proxy/:libId).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
scripts/build-library-cache.js | 22 ++++++++++++++++++++--
src/library.js | 9 +++++++++
2 files changed, 29 insertions(+), 2 deletions(-)
diff --git a/scripts/build-library-cache.js b/scripts/build-library-cache.js
index 96df8fc..a66d0e7 100644
--- a/scripts/build-library-cache.js
+++ b/scripts/build-library-cache.js
@@ -229,11 +229,29 @@ function main() {
let aiTagsArr = [];
try { aiTagsArr = JSON.parse(ai_tags_j || '[]'); } catch {}
+ // Pre-scrub vendor from title + pattern_name at cache-build time.
+ // The server-side scrub runs again as a safety net, but pre-scrubbing
+ // here makes the cache self-protecting — even an admin-only JSON dump
+ // of the raw cache wouldn't leak the vendor name in the title field.
+ function scrubVendorInline(s, v) {
+ if (!s || !v) return s || '';
+ const vEsc = String(v).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ // Match " | <vendor>", " - <vendor>", " by <vendor>", "(<vendor>)",
+ // " (<vendor>)" or a standalone trailing <vendor>.
+ const re = new RegExp(
+ `\\s*[|·\\-–—]\\s*${vEsc}\\b|\\s+by\\s+${vEsc}\\b|\\(\\s*${vEsc}\\s*\\)|\\s+${vEsc}\\b\\s*$|^${vEsc}\\s*[|·\\-–—]\\s*`,
+ 'gi'
+ );
+ return String(s).replace(re, '').replace(/\s{2,}/g, ' ').replace(/\s*[|·\-–—]\s*$/, '').trim();
+ }
+ const titleScrubbed = scrubVendorInline(title, vendor);
+ const patternScrubbed = scrubVendorInline(pattern_name, vendor);
+
const row = {
id: parseInt(enrichment_id, 10),
shopify_id,
handle: handle || '',
- title: title || '',
+ title: titleScrubbed || '',
image_url,
// dominant_hex normalized to #RRGGBB; fall back to first palette color.
dominant_hex: (dominant_hex && /^#[0-9a-fA-F]{6}$/.test(dominant_hex))
@@ -255,7 +273,7 @@ function main() {
price: (price && !isNaN(parseFloat(price))) ? parseFloat(price) : null,
retail_price: (retail_price && !isNaN(parseFloat(retail_price)))
? parseFloat(retail_price) : null,
- pattern_name: pattern_name || '',
+ pattern_name: patternScrubbed || '',
created_at: created_at_shopify || '',
// Audit-only field — NEVER render to customer surface.
_vendor_raw: vendor || ''
diff --git a/src/library.js b/src/library.js
index f5e6fa6..5aa3af6 100644
--- a/src/library.js
+++ b/src/library.js
@@ -50,6 +50,15 @@ function loadLibrary() {
// Vendor redaction — SINGLE source of truth. Every customer-facing surface
// MUST pipe library rows through this before render. Drops the raw vendor
// name AND scrubs vendor substrings from title / pattern_name / handle.
+//
+// KNOWN UNFIXED LEAK: image_url is the Shopify CDN path, which often bakes
+// the vendor name into the filename (e.g. `wandle-morris-and-co-forest-...`
+// or `KOROSEALDIGITAL_...`). This URL is set as the value of CSS background-
+// image: url('...') — it's a network resource the browser fetches, never
+// rendered as visible text. Hiding it would require proxying every image
+// through wallco.ai/img-proxy/:id, which is a real piece of infrastructure
+// (caching, hotlinks, bandwidth) deferred to a later iteration.
+// TODO(steve): wire /img-proxy/:libId to serve through wallco.ai own CDN.
const _LIB_VENDOR_STRIP_RE = (() => {
const names = [
'Schumacher','Maya Romanoff','Thibaut Wallpaper','Thibaut','Arte International',
← 2b2eb5c data: initial inspiration-audit.jsonl from n=5 smoke test
·
back to Wallco Ai
·
/design/:id — inject "Pairs well with real designer wallpape 688fa90 →