From d311bc407b772cbf44b2810608ad1a5773c51e08 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Sat, 20 Jun 2026 17:14:20 -0700 Subject: [PATCH] harden renderer boot and electron security --- apps/code/src/main/window.ts | 2 +- .../components/BootErrorBoundary.test.tsx | 95 +++++++++++++++++ .../renderer/components/BootErrorBoundary.tsx | 100 ++++++++++++++++++ apps/code/src/renderer/main.tsx | 43 ++++++-- 4 files changed, 228 insertions(+), 12 deletions(-) create mode 100644 apps/code/src/renderer/components/BootErrorBoundary.test.tsx create mode 100644 apps/code/src/renderer/components/BootErrorBoundary.tsx diff --git a/apps/code/src/main/window.ts b/apps/code/src/main/window.ts index 530cf95fbc..29d7b0006f 100644 --- a/apps/code/src/main/window.ts +++ b/apps/code/src/main/window.ts @@ -200,7 +200,7 @@ export function createWindow(): void { ...platformWindowConfig, show: false, webPreferences: { - nodeIntegration: true, + nodeIntegration: false, contextIsolation: true, preload: path.join(__dirname, "preload.js"), enableBlinkFeatures: "GetDisplayMedia", diff --git a/apps/code/src/renderer/components/BootErrorBoundary.test.tsx b/apps/code/src/renderer/components/BootErrorBoundary.test.tsx new file mode 100644 index 0000000000..6ba04b9332 --- /dev/null +++ b/apps/code/src/renderer/components/BootErrorBoundary.test.tsx @@ -0,0 +1,95 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { logError } = vi.hoisted(() => ({ logError: vi.fn() })); + +vi.mock("@posthog/ui/shell/logger", () => ({ + logger: { + scope: () => ({ + error: logError, + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }), + }, +})); + +import { + BootErrorBoundary, + BootErrorScreen, +} from "@components/BootErrorBoundary"; + +function ThrowOnRender({ message }: { message: string }): never { + throw new Error(message); +} + +describe("BootErrorScreen", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("renders the alert, title and an Error message", () => { + render(); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect( + screen.getByText("PostHog Code failed to start"), + ).toBeInTheDocument(); + expect(screen.getByText("kaboom")).toBeInTheDocument(); + }); + + it("stringifies a non-Error value", () => { + render(); + + expect(screen.getByText("plain string failure")).toBeInTheDocument(); + }); + + it("reloads the window when Reload is clicked", () => { + const reload = vi.fn(); + vi.stubGlobal("location", { reload }); + + render(); + fireEvent.click(screen.getByRole("button", { name: /reload/i })); + + expect(reload).toHaveBeenCalledTimes(1); + }); +}); + +describe("BootErrorBoundary", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("renders children when no error is thrown", () => { + render( + + healthy child + , + ); + + expect(screen.getByText("healthy child")).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("catches a render error, shows the fallback and logs it", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + + render( + + + , + ); + + expect(screen.getByRole("alert")).toBeInTheDocument(); + expect(screen.getByText("render boom")).toBeInTheDocument(); + expect(logError).toHaveBeenCalled(); + }); + + it("derives error state from a thrown error", () => { + const error = new Error("derive"); + expect(BootErrorBoundary.getDerivedStateFromError(error)).toEqual({ + error, + }); + }); +}); diff --git a/apps/code/src/renderer/components/BootErrorBoundary.tsx b/apps/code/src/renderer/components/BootErrorBoundary.tsx new file mode 100644 index 0000000000..28321676fd --- /dev/null +++ b/apps/code/src/renderer/components/BootErrorBoundary.tsx @@ -0,0 +1,100 @@ +import { logger } from "@posthog/ui/shell/logger"; +import React, { useEffect, useRef } from "react"; + +const log = logger.scope("boot-error"); + +// Inline styles intentionally: this screen must render even when the app's +// theme, CSS or design-system providers failed to load during a boot failure. +const screenStyle: React.CSSProperties = { + position: "fixed", + inset: 0, + outline: "none", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: 16, + padding: 24, + backgroundColor: "#0a0a0a", + color: "#fafafa", + fontFamily: + "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", + textAlign: "center", +}; + +const titleStyle: React.CSSProperties = { + margin: 0, + fontSize: 18, + fontWeight: 600, +}; + +const messageStyle: React.CSSProperties = { + margin: 0, + maxWidth: 480, + fontSize: 13, + opacity: 0.8, +}; + +const buttonStyle: React.CSSProperties = { + padding: "6px 16px", + fontSize: 13, + fontWeight: 500, + color: "#0a0a0a", + backgroundColor: "#fafafa", + border: "none", + borderRadius: 6, + cursor: "pointer", +}; + +export function BootErrorScreen({ error }: { error: unknown }) { + const containerRef = useRef(null); + // Move focus to the alert on mount so screen readers announce it and keyboard + // users land on it, even when it is the very first thing rendered. + useEffect(() => { + containerRef.current?.focus(); + }, []); + const message = error instanceof Error ? error.message : String(error); + return ( +
+

PostHog Code failed to start

+

{message}

+ +
+ ); +} + +interface BootErrorBoundaryProps { + children: React.ReactNode; +} + +interface BootErrorBoundaryState { + error: Error | null; +} + +export class BootErrorBoundary extends React.Component< + BootErrorBoundaryProps, + BootErrorBoundaryState +> { + state: BootErrorBoundaryState = { error: null }; + + static getDerivedStateFromError(error: Error): BootErrorBoundaryState { + return { error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + log.error("Renderer crashed during render", error, info.componentStack); + } + + render(): React.ReactNode { + if (this.state.error) { + return ; + } + return this.props.children; + } +} diff --git a/apps/code/src/renderer/main.tsx b/apps/code/src/renderer/main.tsx index dfd059c3d5..9d20924840 100644 --- a/apps/code/src/renderer/main.tsx +++ b/apps/code/src/renderer/main.tsx @@ -11,11 +11,16 @@ import "@renderer/di/container"; import "@renderer/platform-adapters/updates"; // Side effect: attaches window focus/visibility listeners so `focused` is accurate before inbox queries mount. import "@posthog/ui/shell/rendererWindowFocusStore"; +import { + BootErrorBoundary, + BootErrorScreen, +} from "@components/BootErrorBoundary"; import { Providers } from "@components/Providers"; import { preloadHighlighter } from "@pierre/diffs"; import { boot } from "@posthog/di/contribution"; import { ServiceProvider } from "@posthog/di/react"; import App from "@posthog/ui/shell/App"; +import { logger } from "@posthog/ui/shell/logger"; import { initializePostHog } from "@posthog/ui/shell/posthogAnalyticsImpl"; import { registerDesktopContributions } from "@renderer/desktop-contributions"; import { container } from "@renderer/di/container"; @@ -80,18 +85,34 @@ if (bootstrapSessionId) { initializePostHog(bootstrapSessionId); } -registerDesktopContributions(); -void boot(container); +const bootLog = logger.scope("renderer-boot"); const rootElement = document.getElementById("root"); if (!rootElement) throw new Error("Root element not found"); -ReactDOM.createRoot(rootElement).render( - - - - - - - , -); +const root = ReactDOM.createRoot(rootElement); + +try { + registerDesktopContributions(); + boot(container).catch((error: unknown) => { + bootLog.error("Renderer boot sequence failed", error); + // Replaces the mounted tree without running effect cleanup; acceptable + // because a failed boot leaves the app unusable regardless. + root.render(); + }); + + root.render( + + + + + + + + + , + ); +} catch (error) { + bootLog.error("Renderer failed to start", error); + root.render(); +}