@tokenoftrust/storefront-runner 2.4.71 → 2.4.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Dependency-free, render-time sanitizer for catalog `description_html` (collections
3
+ * today; the same function covers products). The repo ships no runtime HTML-parsing
4
+ * library (see the "no new deps" convention and `blog/markdown.ts`'s own note that
5
+ * `sanitize-html` is a node-only devtool used by the migration pipeline), so this is
6
+ * a defense-in-depth pass that runs where the content is actually rendered with
7
+ * `set:html` — on top of, not instead of, the migration pipeline's own
8
+ * `sanitizeHtml(...)` pass at ingestion (`packages/migration/src/transform/transform.ts`).
9
+ *
10
+ * Scope matches `findUnsafeHtml`'s refusal list
11
+ * (`packages/private-controlplane/src/customization-richcontent.ts`): strip
12
+ * `<script>`/`<style>` elements entirely (tag + content), strip `<iframe>`/
13
+ * `<object>`/`<embed>` entirely, strip every `on*` event-handler attribute, and
14
+ * neutralize `javascript:`/`vbscript:`/`data:` URLs in `href`/`src`/`action`. Any
15
+ * other tag/attribute passes through unchanged — the allowed-tag narrowing already
16
+ * happened upstream in the migration transform.
17
+ *
18
+ * Returns "" for null/blank input so callers can render no block at all rather than
19
+ * an empty one.
20
+ */
21
+
22
+ const DANGEROUS_ELEMENTS = ["script", "style", "iframe", "object", "embed"];
23
+
24
+ function stripDangerousElements(html: string): string {
25
+ let out = html;
26
+ for (const tag of DANGEROUS_ELEMENTS) {
27
+ // Paired form: <script ...>...</script> (content dropped too — never just
28
+ // unwrapped, since the content of a <script>/<style> is not markup).
29
+ out = out.replace(new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}\\s*>`, "gi"), "");
30
+ // Self-closing / unpaired form: a stray <iframe src="..."> with no closing tag.
31
+ out = out.replace(new RegExp(`<${tag}\\b[^>]*\\/?>`, "gi"), "");
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /** Strips any `on<event>="..."`/`on<event>='...'`/`on<event>=bare` attribute from every tag. */
37
+ function stripEventHandlerAttributes(html: string): string {
38
+ return html.replace(
39
+ /\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi,
40
+ "",
41
+ );
42
+ }
43
+
44
+ /** Rewrites a `javascript:`/`vbscript:`/`data:` URL in href/src/action to "about:blank#blocked". */
45
+ function neutralizeScriptUrls(html: string): string {
46
+ return html.replace(
47
+ /\b(href|src|action)(\s*=\s*)("|')\s*(?:javascript|vbscript|data):[^"'>]*\3/gi,
48
+ (_m, attr: string, eq: string, quote: string) => `${attr}${eq}${quote}about:blank#blocked${quote}`,
49
+ );
50
+ }
51
+
52
+ /**
53
+ * Sanitize already-HTML-sanitized catalog `description_html` for render-time
54
+ * defense-in-depth. Idempotent and pure. Returns "" for null/undefined/blank input.
55
+ */
56
+ export function sanitizeDescriptionHtml(html: string | null | undefined): string {
57
+ if (!html) return "";
58
+ const trimmed = html.trim();
59
+ if (!trimmed) return "";
60
+ const cleaned = neutralizeScriptUrls(stripEventHandlerAttributes(stripDangerousElements(trimmed))).trim();
61
+ return cleaned;
62
+ }
63
+
64
+ /** True when sanitized output has no renderable content (e.g. input was only tags/whitespace). */
65
+ export function isBlankDescriptionHtml(sanitized: string): boolean {
66
+ return sanitized.replace(/<[^>]+>/g, "").trim().length === 0;
67
+ }
@@ -19,6 +19,7 @@ import { parseQuery, buildQueryString } from "@/lib/url";
19
19
  import { breadcrumbLd, itemListLd } from "@/lib/jsonld";
20
20
  import { truncate } from "@/lib/format";
21
21
  import { withBase } from "@/lib/basePath";
22
+ import { isBlankDescriptionHtml, sanitizeDescriptionHtml } from "@/lib/content/sanitizeDescriptionHtml";
22
23
 
23
24
  const { handle } = Astro.params;
24
25
  const { tenant, tot, canonicalBase } = Astro.locals;
@@ -56,6 +57,13 @@ const crumbs = siteTrail(
56
57
 
57
58
  const heroImg = collection.image;
58
59
  const desc = collection.description_text || collection.description_html?.replace(/<[^>]+>/g, "");
60
+ // Full-fidelity body (brands/how-it-works/ingredients/FAQ sections on the merchant's
61
+ // live collection pages), sanitized for render-time defense-in-depth on top of the
62
+ // migration pipeline's own sanitize-html pass at ingestion (see
63
+ // sanitizeDescriptionHtml.ts). Rendered as its own block BELOW the product grid,
64
+ // matching the live site's layout — `desc` above stays the short hero excerpt.
65
+ const contentBlockHtml = sanitizeDescriptionHtml(collection.description_html);
66
+ const hasContentBlock = contentBlockHtml.length > 0 && !isBlankDescriptionHtml(contentBlockHtml);
59
67
 
60
68
  // Zero-results recovery: differentiate the cause (search term vs. facet/price
61
69
  // filters) and offer targeted "undo just this" links alongside the existing
@@ -183,4 +191,10 @@ if (query.priceMin != null || query.priceMax != null) {
183
191
  )}
184
192
  </div>
185
193
  </div>
194
+
195
+ {hasContentBlock && (
196
+ <section class="container-prose pb-16">
197
+ <div class="rte mx-auto max-w-[68ch] text-[var(--color-text)]" set:html={contentBlockHtml} />
198
+ </section>
199
+ )}
186
200
  </Layout>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.4.71",
3
+ "version": "2.4.72",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",