Skip to content

Commit da58aff

Browse files
authored
Fix runtime crashes and scope CSP violations with severity. (#104)
* categorize csp issues * fix sendRespnse warn * add csp classifier and metadata * Added hydration safety gate and deferred mutation replay * classify csp and contain runtime crash * scope csp with severity * add tests * fix error for svg elements * bump growthbook version * fix error condition * remove pendingMutationsRef
1 parent 2cb19d0 commit da58aff

File tree

13 files changed

+523
-47
lines changed

13 files changed

+523
-47
lines changed

devtools.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export type CopyMode = "energetic" | "concise" | "humorous";
2222

2323
export type CSPError = {
2424
violatedDirective: string;
25+
effectiveDirective?: string;
26+
blockedURI?: string;
27+
sourceFile?: string;
28+
isFatal?: boolean;
29+
timestamp?: number;
2530
};
2631

2732
export interface VisualEditorVariation {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"@chakra-ui/react": "^2.8.2",
2323
"@emotion/react": "^11.13.0",
2424
"@emotion/styled": "^11.13.0",
25-
"@growthbook/growthbook": "^1.5.0",
25+
"@growthbook/growthbook": "^1.6.5",
2626
"@medv/finder": "^3.2.0",
2727
"@phosphor-icons/react": "^2.1.7",
2828
"@radix-ui/colors": "^3.0.0",

src/background/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
8787
// not found, send empty message to signal unset
8888
});
8989
}
90+
sendResponse({ success: true });
9091
}
9192
if (message.type === "setGlobalState") {
9293
await setState(message.property, message.value, message.persist);

src/content_script/index.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ chrome.runtime.onMessage.addListener((message, _, sendResponse) => {
124124
// not found, send empty message to signal unset
125125
});
126126
}
127+
sendResponse({ success: true });
127128
}
128129
if (message.type === "setTabState") {
129130
setState(message.property, message.value); // Update the state property
@@ -202,17 +203,27 @@ if (!document.getElementById(DEVTOOLS_SCRIPT_ID)) {
202203

203204
// Inject visual editor content script
204205
const VISUAL_EDITOR_SCRIPT_ID = "visual-editor-script";
205-
if (
206-
!document.getElementById(VISUAL_EDITOR_SCRIPT_ID) &&
207-
(!!loadVisualEditorQueryParams() || forceLoadVisualEditor)
208-
) {
206+
const shouldInjectVisualEditor =
207+
!!loadVisualEditorQueryParams() || forceLoadVisualEditor;
208+
const injectVisualEditorScript = () => {
209+
if (document.getElementById(VISUAL_EDITOR_SCRIPT_ID)) return;
209210
const script = document.createElement("script");
210211
script.id = VISUAL_EDITOR_SCRIPT_ID;
211212
script.async = true;
212213
script.charset = "utf-8";
213214
script.src = chrome.runtime.getURL("js/visual_editor.js");
214215

215216
document.body.appendChild(script);
217+
};
218+
219+
if (shouldInjectVisualEditor) {
220+
if (document.readyState === "complete") {
221+
injectVisualEditorScript();
222+
} else {
223+
window.addEventListener("load", injectVisualEditorScript, {
224+
once: true,
225+
});
226+
}
216227
}
217228
// check if the storage has been removed and reload the data from embed script
218229
window.addEventListener("storage", (event) => {

src/visual_editor/components/ErrorDisplay.tsx

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,41 @@ import React, { useRef, FC, useEffect } from "react";
22
import { CSPError } from "../../../devtools";
33

44
const CSPErrorDisplay = ({ cspError }: { cspError: CSPError | null }) => (
5-
<div className="p-4 text-red-400">
6-
The {cspError ? `${cspError.violatedDirective} directive in the` : ""}{" "}
7-
Content Security Policy on this page is too strict for the Visual Editor.
8-
Refer to the Visual Editor documentation's{" "}
9-
<a
10-
className="underline"
11-
href="https://docs.growthbook.io/app/visual#security-requirements"
12-
target="_blank"
13-
rel="noreferrer"
14-
>
15-
'Security Requirements'
16-
</a>{" "}
17-
for details.
5+
<div className={cspError?.isFatal ? "p-4 text-red-400" : "p-4 text-amber-300"}>
6+
<p className="mb-2">
7+
{cspError?.isFatal ? "Critical CSP restriction" : "CSP warning"}:{" "}
8+
{cspError
9+
? `${cspError.effectiveDirective || cspError.violatedDirective}`
10+
: "Unknown directive"}
11+
.
12+
</p>
13+
{!cspError?.isFatal ? (
14+
<p className="text-sm">
15+
Some site scripts were blocked by CSP. Visual Editor should stay active.
16+
</p>
17+
) : (
18+
<p className="text-sm">
19+
Extension resources required by Visual Editor were blocked.
20+
</p>
21+
)}
22+
<p className="text-xs mt-2 break-all">
23+
Blocked URI: {cspError?.blockedURI || "unknown"}
24+
</p>
25+
<p className="text-xs mt-1 break-all">
26+
Source: {cspError?.sourceFile || "unknown"}
27+
</p>
28+
<p className="text-xs mt-2">
29+
Refer to{" "}
30+
<a
31+
className="underline"
32+
href="https://docs.growthbook.io/app/visual#security-requirements"
33+
target="_blank"
34+
rel="noreferrer"
35+
>
36+
Visual Editor Security Requirements
37+
</a>
38+
.
39+
</p>
1840
</div>
1941
);
2042

@@ -23,6 +45,10 @@ interface ErrorDisplayProps {
2345
cspError: CSPError | null;
2446
}
2547
const ErrorDisplay: FC<ErrorDisplayProps> = ({ error, cspError }) => {
48+
if (cspError && !cspError.isFatal && !error) {
49+
return <CSPErrorDisplay cspError={cspError} />;
50+
}
51+
2652
switch (error) {
2753
case "no-api-host":
2854
case "no-api-key":
@@ -52,15 +78,16 @@ const ErrorDisplay: FC<ErrorDisplayProps> = ({ error, cspError }) => {
5278
export default (props: ErrorDisplayProps) => {
5379
const { error, cspError } = props;
5480
const errorContainerRef = useRef<HTMLDivElement | null>(null);
81+
const shouldAutoScroll = !!error || !!cspError?.isFatal;
5582

5683
// scroll to error
5784
useEffect(() => {
58-
if (!error && !cspError && !errorContainerRef.current) return;
85+
if (!shouldAutoScroll) return;
5986
errorContainerRef.current?.scrollIntoView({
6087
behavior: "smooth",
6188
block: "end",
6289
});
63-
}, [error, cspError, errorContainerRef.current]);
90+
}, [shouldAutoScroll]);
6491

6592
return (
6693
<div ref={errorContainerRef}>

src/visual_editor/index.tsx

Lines changed: 162 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { debounce } from "lodash";
22
import React, {
3+
Component,
4+
ErrorInfo,
35
FC,
46
useCallback,
57
useEffect,
@@ -47,7 +49,7 @@ import DebugPanel from "./components/DebugPanel";
4749

4850
import VisualEditorCss from "./shadowDom.css";
4951
import "./targetPage.css";
50-
import { isGlobalObserverPaused, resumeGlobalObserver } from "dom-mutator";
52+
import { isGlobalObserverPaused } from "dom-mutator";
5153

5254
const VisualEditor: FC<{}> = () => {
5355
const { x, y, setX, setY, parentStyles } = useFixedPositioning({
@@ -170,6 +172,10 @@ const VisualEditor: FC<{}> = () => {
170172
(selectedVariation?.css ? 1 : 0),
171173
[selectedVariation],
172174
);
175+
const issueError = error || aiError;
176+
const hasNonFatalCspWarning = !!cspError && !cspError.isFatal && !issueError;
177+
const hasIssues = !!issueError || !!cspError;
178+
const issuesCount = (issueError ? 1 : 0) + (cspError ? 1 : 0);
173179

174180
useEffect(() => {
175181
if (!variations.length) return;
@@ -336,8 +342,14 @@ const VisualEditor: FC<{}> = () => {
336342
</VisualEditorSection>
337343
)}
338344

339-
{error || aiError ? (
340-
<ErrorDisplay error={error || aiError} cspError={cspError} />
345+
{hasIssues ? (
346+
<VisualEditorSection
347+
title={`Issues (${issuesCount})`}
348+
isCollapsible
349+
isExpanded={!hasNonFatalCspWarning}
350+
>
351+
<ErrorDisplay error={issueError} cspError={cspError} />
352+
</VisualEditorSection>
341353
) : null}
342354

343355
<div className="m-4 text-center">
@@ -401,27 +413,160 @@ const VisualEditor: FC<{}> = () => {
401413
);
402414
};
403415

416+
type VisualEditorErrorBoundaryProps = {
417+
onRetry: () => void;
418+
children: React.ReactNode;
419+
};
420+
type VisualEditorErrorBoundaryState = {
421+
hasError: boolean;
422+
message: string;
423+
};
424+
425+
class VisualEditorErrorBoundary extends Component<
426+
VisualEditorErrorBoundaryProps,
427+
VisualEditorErrorBoundaryState
428+
> {
429+
state: VisualEditorErrorBoundaryState = {
430+
hasError: false,
431+
message: "",
432+
};
433+
434+
static getDerivedStateFromError(error: Error) {
435+
return { hasError: true, message: error?.message || "Unknown error" };
436+
}
437+
438+
componentDidCatch(error: Error, info: ErrorInfo) {
439+
window.postMessage(
440+
{
441+
type: "GB_ERROR",
442+
error: `visual-editor-crash: ${error?.message || "Unknown error"}; ${info.componentStack || ""}`,
443+
},
444+
window.location.origin,
445+
);
446+
}
447+
448+
private retry = () => {
449+
this.setState({ hasError: false, message: "" });
450+
this.props.onRetry();
451+
};
452+
453+
render() {
454+
if (this.state.hasError) {
455+
return (
456+
<div className="rounded-xl shadow-xl z-max w-80 bg-dark p-4 text-red-400">
457+
<p className="text-sm mb-2">Visual Editor hit a runtime error.</p>
458+
<p className="text-xs mb-3 break-words">{this.state.message}</p>
459+
<button
460+
className="bg-slate-600 text-slate-100 px-3 py-1 rounded text-xs"
461+
onClick={this.retry}
462+
>
463+
Retry
464+
</button>
465+
</div>
466+
);
467+
}
468+
return this.props.children;
469+
}
470+
}
471+
404472
/**
405473
* mounting the visual editor
406474
*/
407475
export const CONTAINER_ID = "__gb_visual_editor";
408476

409-
const container = document.createElement("div");
410-
container.id = CONTAINER_ID;
477+
export let shadowRoot: ShadowRoot | null = null;
478+
let root: ReactDOM.Root | null = null;
479+
let mountObserver: MutationObserver | null = null;
480+
let remountTimeout: number | null = null;
481+
let remountCount = 0;
482+
let remountWindowStart = 0;
483+
const REMOUNT_WINDOW_MS = 10000;
484+
const MAX_REMOUNTS_PER_WINDOW = 5;
485+
486+
const isNodeAttached = (node: Node | null) =>
487+
!!node && document.documentElement.contains(node);
488+
489+
const scheduleRemount = () => {
490+
const now = Date.now();
491+
if (now - remountWindowStart > REMOUNT_WINDOW_MS) {
492+
remountWindowStart = now;
493+
remountCount = 0;
494+
}
495+
if (remountCount >= MAX_REMOUNTS_PER_WINDOW) return;
496+
remountCount += 1;
497+
498+
if (remountTimeout) window.clearTimeout(remountTimeout);
499+
remountTimeout = window.setTimeout(() => {
500+
ensureVisualEditorMounted();
501+
}, 150);
502+
};
411503

412-
export const shadowRoot = container?.attachShadow({ mode: "open" });
504+
const getContainer = () =>
505+
document.getElementById(CONTAINER_ID) as HTMLDivElement | null;
506+
507+
const ensureVisualEditorMounted = () => {
508+
let container = getContainer();
509+
if (!container || !isNodeAttached(container)) {
510+
container = document.createElement("div");
511+
container.id = CONTAINER_ID;
512+
document.body.appendChild(container);
513+
}
514+
515+
if (!container.shadowRoot) {
516+
shadowRoot = container.attachShadow({ mode: "open" });
517+
shadowRoot.innerHTML = `
518+
<style>${VisualEditorCss}</style>
519+
<div id="visual-editor-root"></div>
520+
`;
521+
root = null;
522+
} else {
523+
shadowRoot = container.shadowRoot;
524+
if (!shadowRoot.querySelector("#visual-editor-root")) {
525+
shadowRoot.innerHTML = `
526+
<style>${VisualEditorCss}</style>
527+
<div id="visual-editor-root"></div>
528+
`;
529+
root = null;
530+
}
531+
}
532+
533+
const mountNode = shadowRoot?.querySelector("#visual-editor-root");
534+
if (!mountNode) return;
535+
536+
if (!root) {
537+
root = ReactDOM.createRoot(mountNode);
538+
}
539+
root.render(
540+
<VisualEditorErrorBoundary onRetry={() => ensureVisualEditorMounted()}>
541+
<VisualEditor />
542+
</VisualEditorErrorBoundary>,
543+
);
413544

414-
if (shadowRoot) {
415-
shadowRoot.innerHTML = `
416-
<style>${VisualEditorCss}</style>
417-
<div id="visual-editor-root"></div>
418-
`;
419-
}
545+
if (!mountObserver) {
546+
mountObserver = new MutationObserver(() => {
547+
const currentContainer = getContainer();
548+
if (!currentContainer || !isNodeAttached(currentContainer)) {
549+
scheduleRemount();
550+
}
551+
});
552+
mountObserver.observe(document.documentElement, {
553+
childList: true,
554+
subtree: true,
555+
});
556+
}
557+
};
420558

421-
document.body.appendChild(container);
559+
document.addEventListener("visibilitychange", () => {
560+
if (document.visibilityState === "visible") {
561+
ensureVisualEditorMounted();
562+
}
563+
});
422564

423-
const root = ReactDOM.createRoot(
424-
shadowRoot.querySelector("#visual-editor-root")!,
425-
);
565+
window.addEventListener("pagehide", () => {
566+
if (remountTimeout) {
567+
window.clearTimeout(remountTimeout);
568+
remountTimeout = null;
569+
}
570+
});
426571

427-
root.render(<VisualEditor />);
572+
ensureVisualEditorMounted();

0 commit comments

Comments
 (0)