Skip to content

Commit 99c7da7

Browse files
treodenclaude
andcommitted
fix: page-builder security hardening + widget UX/responsive overhaul
Security - Move pageBuilder GraphQL types (Changeset, ChangesetOperation, RolloutPlan, PageBuilderScope) to `.admin.*` so preview tokens, operations and creators are no longer readable through the public storefront /graphql endpoint (P0). - Sanitize EditorJS HTML at the render sinks (Paragraph/RawHtml in common/Editor) so widget-authored content can't inject script. - Allow-list link schemes in resolveLink (+ LinkPicker warning) to neutralize javascript:/data: hrefs before they reach an anchor. - Gate changeset add-op/publish/discard/undo on created_by ownership for personal drafts (IDOR); rollout-backed changesets stay shared. - SELECT ... FOR UPDATE in addChangesetOperation plus a unique (changeset_id, change_order) index (migration Version-1.2.0) to close the change_order collision / cursor lost-update race. - Guard the publish apply-path against a duplicate widget_instance INSERT and a NULL widget FK on placement UPDATE so a previewable changeset can't abort. Editor - Fix the React #418 hydration error (a <button> nested inside the AdminUser account-menu trigger, firing on every admin page). - Wire palette click-to-add (was drag-only while the hover card said "click"). - Keep the operation count live so beforeunload / exit-confirm / draft pills fire for work created in the current session. - Resync the page form + saved snapshots on undo/redo so a later edit can't resurrect the value an undo just reverted. - Allocate the preview sequence at fetch initiation so a stale-but-late response can't overwrite a newer edit. Widgets - Add in-editor empty-state placeholders (WidgetEmptyState) for text_block, featured_blogs, brand_story, faq_block and footer_menu. - Fix responsive breakpoint jumps (TrustStrip, FeaturedBlogs, BentoGrid, CollectionSpotlight) and BentoGrid text contrast / scrim. - Redesign BentoGrid around a dominant 50% hero (4-col 2x2) with rounded corners, soft shadow and image hover-zoom. - Fix the image cover-fill gray bar (height:100%) across BentoGrid, CategoryMosaic, TieredCategories and BrandStory. - Add nav landmarks (BasicMenu, FooterMenu) and wrap storefront strings in _(). - CollectionSpotlight: configurable view-all link (fixes the /collections 404), stack-until-lg responsive, 50/50 image/products split, edge-to-edge copy on mobile. - Raise the product-list image to 720^2 so full-width retina thumbnails stay sharp (srcset cap was ~750px). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 57e7993 commit 99c7da7

42 files changed

Lines changed: 716 additions & 148 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/evershop/src/components/common/Editor.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@ import { Row } from '@components/common/form/Editor.js';
44
import { Image as ResponsiveImage } from '@components/common/Image.js';
55
import { ProductData } from '@components/frontStore/catalog/ProductContext.js';
66
import { ProductList } from '@components/frontStore/catalog/ProductList.js';
7+
import { sanitize } from '@evershop/evershop/lib/util/sanitizeHtml';
78
import React from 'react';
89
import { useQuery } from 'urql';
910
import './Editor.scss';
1011

1112
const Paragraph: React.FC<{ data: { text: string } }> = ({ data }) => {
12-
return <p dangerouslySetInnerHTML={{ __html: data.text }} />;
13+
return <p dangerouslySetInnerHTML={{ __html: sanitize(data.text) }} />;
1314
};
1415

1516
const Header: React.FC<{ data: { level: number; text: string } }> = ({
@@ -119,7 +120,7 @@ const Image: React.FC<{
119120
};
120121

121122
const RawHtml: React.FC<{ data: { html: string } }> = ({ data }) => {
122-
return <div dangerouslySetInnerHTML={{ __html: data.html }} />;
123+
return <div dangerouslySetInnerHTML={{ __html: sanitize(data.html) }} />;
123124
};
124125

125126
interface SavedProduct {
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React, { useEffect, useState } from 'react';
2+
import { isInPageBuilderIframe } from './pageBuilderMode.js';
3+
4+
/**
5+
* Shared in-editor placeholder for widgets that render nothing until they're
6+
* configured. On the live storefront an unconfigured widget should render
7+
* nothing (return null); but in the page-builder iframe that makes a freshly
8+
* dropped widget invisible and unselectable except via the Layers panel.
9+
*
10+
* Drop this in the widget's empty branch instead of `return null`:
11+
*
12+
* if (rows.length === 0) {
13+
* return <WidgetEmptyState type="text_block" title="Text block"
14+
* hint="Open settings to add your copy." />;
15+
* }
16+
*
17+
* It renders the dashed placeholder ONLY inside the page-builder iframe, and
18+
* only after mount — the first (SSR) render is `null` so it matches the
19+
* production storefront output and never trips hydration.
20+
*/
21+
export function WidgetEmptyState({
22+
type,
23+
title,
24+
hint,
25+
icon
26+
}: {
27+
/** Widget type id — emitted as `data-evershop-pb-empty` for tooling. */
28+
type: string;
29+
title: string;
30+
hint: string;
31+
/** Optional custom glyph; defaults to a generic block icon. */
32+
icon?: React.ReactNode;
33+
}): React.ReactElement | null {
34+
const [isClient, setIsClient] = useState(false);
35+
useEffect(() => {
36+
setIsClient(true);
37+
}, []);
38+
39+
if (!isClient || !isInPageBuilderIframe()) return null;
40+
41+
return (
42+
<div
43+
className="evershop-widget-empty py-6"
44+
data-evershop-pb-empty={type}
45+
>
46+
<div className="mx-auto max-w-xl rounded-lg border-2 border-dashed border-divider bg-muted/30 p-8 text-center">
47+
<div className="mx-auto mb-3 flex h-10 w-10 items-center justify-center rounded-full border border-divider bg-background text-muted-foreground">
48+
{icon ?? (
49+
<svg
50+
width="18"
51+
height="18"
52+
viewBox="0 0 24 24"
53+
fill="none"
54+
stroke="currentColor"
55+
strokeWidth="1.8"
56+
strokeLinecap="round"
57+
strokeLinejoin="round"
58+
aria-hidden="true"
59+
>
60+
<rect x="3" y="3" width="18" height="18" rx="2" />
61+
<path d="M3 9h18M9 21V9" />
62+
</svg>
63+
)}
64+
</div>
65+
<div className="text-sm font-medium text-foreground">{title}</div>
66+
<div className="mt-1 text-xs text-muted-foreground">{hint}</div>
67+
</div>
68+
</div>
69+
);
70+
}

packages/evershop/src/components/common/page-builder/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export {
99
useWidgetSettings
1010
} from './WidgetContext.js';
1111
export { WidgetChrome } from './WidgetChrome.js';
12+
export { WidgetEmptyState } from './WidgetEmptyState.js';
1213
export {
1314
WidgetSettingsScope,
1415
useWidgetSettingsScope,

packages/evershop/src/components/common/page-builder/pickers/LinkPicker.tsx

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
PromotionUrn,
1414
UrnService
1515
} from '@evershop/evershop/lib/urn';
16+
import { isSafeUrl } from '@evershop/evershop/lib/util/safeUrl';
1617
import React, { useState } from 'react';
1718

1819
/**
@@ -222,10 +223,18 @@ export function LinkPicker({
222223
placeholder="/c/sale or https://example.com"
223224
className={drawerInputClass}
224225
/>
225-
<div className="text-[11px] text-muted-foreground">
226-
{_('Paste a URL or a relative path starting with ')}
227-
<code>/</code>.
228-
</div>
226+
{!parsed && value && !isSafeUrl(value) ? (
227+
<div className="text-[11px] text-destructive">
228+
{_(
229+
'Only http(s), mailto, tel or relative links are allowed — this link will be ignored on the storefront.'
230+
)}
231+
</div>
232+
) : (
233+
<div className="text-[11px] text-muted-foreground">
234+
{_('Paste a URL or a relative path starting with ')}
235+
<code>/</code>.
236+
</div>
237+
)}
229238
</div>
230239
)}
231240
</div>

packages/evershop/src/components/frontStore/catalog/ProductList.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,12 @@ export interface ProductListProps {
2121

2222
export const ProductList: React.FC<ProductListProps> = ({
2323
products = [],
24-
imageWidth = 300,
25-
imageHeight = 300,
24+
// 720² keeps the 1:1 grid ratio but lifts the <Image> srcset cap (width × 3
25+
// = 2160) so full-width product cards on retina phones get a sharp 1200–1920
26+
// tile instead of the old 300² cap of ~750px. Display size is unchanged (CSS
27+
// controls it). The `list` layout still clamps to 150 below.
28+
imageWidth = 720,
29+
imageHeight = 720,
2630
isLoading = false,
2731
emptyMessage = _('No products found'),
2832
className = '',
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* URL scheme allow-list for user/admin-authored links (widget CTAs, menu
3+
* items, tile links). Blocks the `javascript:` / `data:` / `vbscript:` href
4+
* XSS vector while letting through the schemes a storefront link legitimately
5+
* uses.
6+
*
7+
* Used on both surfaces:
8+
* - server: `resolveLink` neutralizes an unsafe stored value to `null` so it
9+
* never reaches an `href` (the security boundary),
10+
* - client: the LinkPicker "Custom" tab warns the merchant as they type.
11+
*/
12+
13+
const SAFE_SCHEMES = ['http', 'https', 'mailto', 'tel'];
14+
15+
/**
16+
* True when `url` is safe to place in an `href`. Relative paths, anchors and
17+
* query links are safe; absolute URLs must use an allow-listed scheme.
18+
*
19+
* Browsers strip TAB/LF/CR anywhere in a URL and ignore leading control
20+
* characters before a scheme, so `java\tscript:alert(1)` executes — the check
21+
* strips control characters before inspecting the scheme to close that hole.
22+
*/
23+
export function isSafeUrl(url: unknown): boolean {
24+
if (typeof url !== 'string') return false;
25+
// Strip ASCII control chars (0x00-0x1F, 0x7F) so a smuggled scheme like
26+
// `java\tscript:` can't slip past the scheme test below.
27+
28+
const stripped = url.replace(/[\x00-\x1F\x7F]/g, '').trim();
29+
if (stripped === '') return true; // empty -> nothing to render, harmless
30+
// Relative path, anchor or query - no scheme, always safe.
31+
if (/^(?:[/#?]|\.{1,2}\/)/.test(stripped)) return true;
32+
const match = stripped.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/);
33+
if (!match) return true; // no scheme (e.g. "foo/bar") -> relative, safe
34+
return SAFE_SCHEMES.includes(match[1].toLowerCase());
35+
}

packages/evershop/src/lib/util/sanitizeHtml.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,22 @@ const sanitizeOptions: sanitizeHtml.IOptions = {
1616
}
1717
};
1818

19+
/**
20+
* Sanitize a single HTML string with the same allow-list used for stored
21+
* rich-text. Safe to run isomorphically (sanitize-html is pure JS) and
22+
* deterministic, so it can run at render time without a hydration mismatch.
23+
*
24+
* Use this at any `dangerouslySetInnerHTML` sink that renders
25+
* user/admin-authored HTML — it strips `<script>`, event-handler attributes
26+
* (`onerror`, …), and `javascript:`/`data:` URLs while keeping formatting.
27+
*/
28+
export function sanitize(html: unknown): string {
29+
if (typeof html !== 'string' || html.length === 0) {
30+
return '';
31+
}
32+
return sanitizeHtml(html, sanitizeOptions);
33+
}
34+
1935
export interface Row {
2036
id: string;
2137
size: number;

packages/evershop/src/lib/widget/linkResolver.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { select } from '../postgres/query.js';
44
import { buildUrl } from '../router/buildUrl.js';
55
import { UrnService } from '../urn/index.js';
66
import { addProcessor, getValueSync } from '../util/registry.js';
7+
import { isSafeUrl } from '../util/safeUrl.js';
78

89
/**
910
* Widget link URN + resolver.
@@ -218,7 +219,10 @@ export async function resolveLink(
218219
loaders: LinkLoaders | undefined
219220
): Promise<string | null> {
220221
if (!value) return null;
221-
if (!UrnService.isValid(value)) return value; // plain URL passthrough
222+
// Plain URL passthrough — but drop `javascript:`/`data:` and other unsafe
223+
// schemes so a hand-authored custom link can't become an href XSS on the
224+
// storefront. Returning null makes the widget suppress the anchor.
225+
if (!UrnService.isValid(value)) return isSafeUrl(value) ? value : null;
222226
const { service, type, uuid } = UrnService.parse(value);
223227
const loader = loaders?.[loaderKey(service, type)];
224228
if (!loader) return null;

packages/evershop/src/modules/auth/pages/admin/all/AdminUser.jsx

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,21 @@ export default function AdminUser({ adminUser = null, logoutUrl, loginPage }) {
3535
<div className="admin-user flex grow justify-end items-center">
3636
<div className="flex justify-items-start gap-2 justify-center">
3737
<DropdownMenu>
38-
<DropdownMenuTrigger>
39-
<button
40-
className="w-[2.188rem] h-[2.188rem] flex items-center justify-center rounded-full bg-primary/45 font-semibold border-2 border-primary cursor-pointer hover:bg-primary/60 transition-colors"
41-
onClick={(e) => e.preventDefault()}
42-
>
43-
{fullName[0]}
44-
</button>
38+
{/* `render` makes the trigger BE the avatar button. Nesting a
39+
<button> inside the trigger (which renders its own <button>) is
40+
invalid HTML — the browser reparents it on parse, so the
41+
server-rendered markup and the hydrated DOM diverge and every
42+
admin page logs React hydration error #418. */}
43+
<DropdownMenuTrigger
44+
aria-label={_('Account menu')}
45+
render={
46+
<button
47+
type="button"
48+
className="w-[2.188rem] h-[2.188rem] flex items-center justify-center rounded-full bg-primary/45 font-semibold border-2 border-primary cursor-pointer hover:bg-primary/60 transition-colors"
49+
/>
50+
}
51+
>
52+
{fullName[0]}
4553
</DropdownMenuTrigger>
4654
<DropdownMenuContent align="end" className="w-45">
4755
<DropdownMenuLabel className="text-base font-normal">

packages/evershop/src/modules/blog/components/frontStore/FeaturedBlogs.tsx

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
import { WidgetEmptyState } from '@components/common/page-builder/index.js';
12
import {
23
BlogPostCardData,
34
PostListItem
45
} from '@components/frontStore/blog/PostListItem.js';
56
import React from 'react';
67

8+
// Full column ladder per setting. Blog cards carry an image + title + excerpt,
9+
// so they need a single column on phones and only reach 3–4 columns on real
10+
// desktops — jumping straight from 1 to 3/4 at `md` (768px) crushed them on
11+
// tablets. 2-up fills the 640–1023px range.
712
const GRID_COLS: Record<number, string> = {
813
1: 'grid-cols-1',
9-
2: 'md:grid-cols-2',
10-
3: 'md:grid-cols-3',
11-
4: 'md:grid-cols-4'
14+
2: 'grid-cols-1 sm:grid-cols-2',
15+
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
16+
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4'
1217
};
1318

1419
interface FeaturedBlogsProps {
@@ -26,25 +31,37 @@ export default function FeaturedBlogs({
2631
}: FeaturedBlogsProps) {
2732
const widget = featuredBlogsWidget;
2833
if (!widget || !widget.posts || widget.posts.length === 0) {
29-
return null;
34+
return (
35+
<WidgetEmptyState
36+
type="featured_blogs"
37+
title="Featured blogs"
38+
hint="Pick posts to feature in the settings panel."
39+
/>
40+
);
3041
}
3142
const cols = widget.columns || 3;
3243
const gridClass = GRID_COLS[cols] || GRID_COLS[3];
3344

3445
return (
35-
<div className="featured-blogs page-width py-8">
46+
// Match the shared band rhythm (py-6 md:py-10) and theme tokens used by
47+
// the other widgets, and don't self-apply `page-width` — it double-pads /
48+
// re-centers when the widget is dropped inside a Section or Columns. Width
49+
// comes from the surrounding Area/Section like every other widget.
50+
<div className="featured-blogs py-6 md:py-10">
3651
{widget.eyebrow && (
37-
<p className="text-sm uppercase tracking-wide text-gray-500">
52+
<p className="text-[11px] font-semibold uppercase tracking-widest text-foreground/70">
3853
{widget.eyebrow}
3954
</p>
4055
)}
4156
{widget.heading && (
42-
<h2 className="text-2xl font-bold mb-2">{widget.heading}</h2>
57+
<h2 className="mt-1 text-2xl font-semibold tracking-tight md:text-3xl">
58+
{widget.heading}
59+
</h2>
4360
)}
4461
{widget.subText && (
45-
<p className="text-gray-600 mb-6">{widget.subText}</p>
62+
<p className="mt-2 mb-6 text-muted-foreground">{widget.subText}</p>
4663
)}
47-
<div className={`grid grid-cols-1 ${gridClass} gap-8`}>
64+
<div className={`mt-6 grid ${gridClass} gap-8`}>
4865
{widget.posts.map((post) => (
4966
<PostListItem key={post.uuid} post={post} />
5067
))}

0 commit comments

Comments
 (0)