diff --git a/ui/changelog.d/posthog-toolbar-localhost.added.md b/ui/changelog.d/posthog-toolbar-localhost.added.md new file mode 100644 index 0000000000..a2aa815a1c --- /dev/null +++ b/ui/changelog.d/posthog-toolbar-localhost.added.md @@ -0,0 +1 @@ +PostHog Toolbar support in development with separate ingestion and app hosts diff --git a/ui/components/integrations/saml/saml-config-form.test.tsx b/ui/components/integrations/saml/saml-config-form.test.tsx index a067d8b2d2..4949a9b255 100644 --- a/ui/components/integrations/saml/saml-config-form.test.tsx +++ b/ui/components/integrations/saml/saml-config-form.test.tsx @@ -53,7 +53,8 @@ const runtimeConfig: RuntimePublicConfig = { apiDocsUrl: null, posthogEnabled: false, posthogKey: null, - posthogHost: null, + posthogIngestionHost: null, + posthogUiHost: null, reoDevClientId: null, cloudEnabled: false, cloudBillingEnabled: false, diff --git a/ui/components/survey/feedback-survey.test.tsx b/ui/components/survey/feedback-survey.test.tsx index 436307e0e5..b837beb8b4 100644 --- a/ui/components/survey/feedback-survey.test.tsx +++ b/ui/components/survey/feedback-survey.test.tsx @@ -10,6 +10,12 @@ import { clampSidePanelWidth } from "@/lib/ui-layout"; type SurveyCallback = (surveys: Survey[]) => void; +interface PostHogInitConfig { + api_host?: string; + loaded?: (client: unknown) => void; + ui_host?: string; +} + const mocks = vi.hoisted(() => ({ isCloud: vi.fn(), useRuntimeConfig: vi.fn(), @@ -100,13 +106,15 @@ describe("FeedbackSurvey", () => { mocks.loaded = false; mocks.pathname = "/"; mocks.isPushViewport = false; + vi.stubEnv("NODE_ENV", "development"); localStorage.clear(); mocks.isCloud.mockReturnValue(true); mocks.useRuntimeConfig.mockReturnValue({ cloudEnabled: true, posthogEnabled: true, posthogKey: POSTHOG_KEY, - posthogHost: "https://eu.posthog.com", + posthogIngestionHost: "https://us.i.posthog.com", + posthogUiHost: "https://us.posthog.com", }); provideSurveys([SURVEY_FIXTURE]); }); @@ -115,6 +123,7 @@ describe("FeedbackSurvey", () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); vi.unstubAllGlobals(); + Reflect.deleteProperty(window, "posthog"); }); it("renders an accessible icon-only circular feedback trigger", async () => { @@ -443,8 +452,8 @@ describe("FeedbackSurvey", () => { await waitFor(() => expect(mocks.init).toHaveBeenCalledTimes(1)); expect(mocks.init.mock.calls[0][0]).toBe(POSTHOG_KEY); expect(mocks.init.mock.calls[0][1]).toMatchObject({ - api_host: "https://eu.posthog.com", - ui_host: "https://eu.posthog.com", + api_host: "https://us.i.posthog.com", + ui_host: "https://us.posthog.com", autocapture: false, capture_pageview: false, capture_pageleave: false, @@ -452,6 +461,40 @@ describe("FeedbackSurvey", () => { await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled()); }); + it("exposes the initialized PostHog client for the development Toolbar", async () => { + // Given + await renderSurvey(); + await waitFor(() => expect(mocks.init).toHaveBeenCalledOnce()); + const config = mocks.init.mock.calls[0]?.[1] as + | PostHogInitConfig + | undefined; + const { default: posthogClient } = await import("posthog-js"); + + // When + config?.loaded?.(posthogClient); + + // Then + expect(config?.loaded).toBeTypeOf("function"); + expect(Reflect.get(window, "posthog")).toBe(posthogClient); + }); + + it("keeps the initialized PostHog client off window in production", async () => { + // Given + vi.stubEnv("NODE_ENV", "production"); + await renderSurvey(); + await waitFor(() => expect(mocks.init).toHaveBeenCalledOnce()); + const config = mocks.init.mock.calls[0]?.[1] as + | PostHogInitConfig + | undefined; + const { default: posthogClient } = await import("posthog-js"); + + // When + config?.loaded?.(posthogClient); + + // Then + expect(Reflect.has(window, "posthog")).toBe(false); + }); + it("consumes the already-initialized Cloud instance without re-initializing", async () => { // Given - on Prowler Cloud the singleton is already initialized // (app/providers.tsx), so __loaded is true. @@ -464,6 +507,8 @@ describe("FeedbackSurvey", () => { // the trigger renders. await waitFor(() => expect(mocks.onSurveysLoaded).toHaveBeenCalled()); expect(mocks.init).not.toHaveBeenCalled(); + const { default: posthogClient } = await import("posthog-js"); + expect(Reflect.get(window, "posthog")).toBe(posthogClient); expect( await screen.findByRole("button", { name: "Give feedback" }), ).toBeVisible(); @@ -476,7 +521,8 @@ describe("FeedbackSurvey", () => { cloudEnabled: false, posthogEnabled: false, posthogKey: null, - posthogHost: null, + posthogIngestionHost: null, + posthogUiHost: null, }); // When @@ -496,7 +542,8 @@ describe("FeedbackSurvey", () => { cloudEnabled: true, posthogEnabled: false, posthogKey: POSTHOG_KEY, - posthogHost: "https://eu.posthog.com", + posthogIngestionHost: "https://eu.i.posthog.com", + posthogUiHost: "https://eu.posthog.com", }); // When diff --git a/ui/components/survey/feedback-survey.tsx b/ui/components/survey/feedback-survey.tsx index eff7cd1b56..645c266d3b 100644 --- a/ui/components/survey/feedback-survey.tsx +++ b/ui/components/survey/feedback-survey.tsx @@ -8,16 +8,20 @@ import { isCloud } from "@/lib/shared/env"; const RuntimeFeedbackSurvey = lazy(() => import("./runtime-feedback-survey")); export function FeedbackSurvey() { - const { posthogEnabled, posthogKey, posthogHost } = useRuntimeConfig(); + const { posthogEnabled, posthogKey, posthogIngestionHost, posthogUiHost } = + useRuntimeConfig(); - if (!isCloud() || !posthogEnabled || !posthogKey || !posthogHost) return null; + if (!isCloud() || !posthogEnabled || !posthogKey || !posthogIngestionHost) { + return null; + } return ( ); diff --git a/ui/components/survey/runtime-feedback-survey.tsx b/ui/components/survey/runtime-feedback-survey.tsx index 49c9d0ceb6..3e7db575af 100644 --- a/ui/components/survey/runtime-feedback-survey.tsx +++ b/ui/components/survey/runtime-feedback-survey.tsx @@ -4,7 +4,7 @@ import { MessageSquareText } from "lucide-react"; import { usePathname } from "next/navigation"; import posthogClient from "posthog-js"; import type { Survey } from "posthog-js"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/shadcn/button/button"; import { @@ -16,6 +16,8 @@ import { useMediaQuery } from "@/hooks/use-media-query"; import { useMountEffect } from "@/hooks/use-mount-effect"; import { useStore } from "@/hooks/use-store"; import { isLighthouseChatRoute } from "@/lib/lighthouse-routes"; +import { resolvePosthogUiHost } from "@/lib/posthog-hosts"; +import { exposePosthogForToolbar } from "@/lib/posthog-toolbar"; import { clampSidePanelWidth, SIDE_PANEL_PUSH_MEDIA_QUERY, @@ -36,18 +38,23 @@ const SURVEY_EVENT = { interface RuntimeFeedbackSurveyProps { posthogKey: string; - posthogHost: string; + posthogIngestionHost: string; + posthogUiHost: string | null; +} + +interface VisibleFeedbackSurveyProps extends RuntimeFeedbackSurveyProps { + isPushViewport: boolean; + sidePanelResizing: boolean | undefined; + sidePanelVisible: boolean; + sidePanelWidth: number | undefined; } export default function RuntimeFeedbackSurvey({ posthogKey, - posthogHost, + posthogIngestionHost, + posthogUiHost, }: RuntimeFeedbackSurveyProps) { const pathname = usePathname(); - const [survey, setSurvey] = useState(null); - const [open, setOpen] = useState(false); - const [response, setResponse] = useState(""); - const [submitted, setSubmitted] = useState(false); const sidePanelOpen = useStore(useSidePanelStore, (state) => state.isOpen); const sidePanelWidth = useStore(useSidePanelStore, (state) => state.width); const sidePanelResizing = useStore( @@ -58,14 +65,47 @@ export default function RuntimeFeedbackSurvey({ const sidePanelVisible = Boolean(sidePanelOpen) && !isLighthouseChatRoute(pathname); + // Reset the open survey by unmounting it while an overlay panel is visible. + if (sidePanelVisible && !isPushViewport) return null; + + return ( + + ); +} + +function VisibleFeedbackSurvey({ + posthogKey, + posthogIngestionHost, + posthogUiHost, + isPushViewport, + sidePanelResizing, + sidePanelVisible, + sidePanelWidth, +}: VisibleFeedbackSurveyProps) { + const [survey, setSurvey] = useState(null); + const [open, setOpen] = useState(false); + const [response, setResponse] = useState(""); + const [submitted, setSubmitted] = useState(false); + useMountEffect(() => { - if (!posthogClient.__loaded) { + if (posthogClient.__loaded) { + exposePosthogForToolbar(posthogClient); + } else { posthogClient.init(posthogKey, { - api_host: posthogHost, - ui_host: posthogHost, + api_host: posthogIngestionHost, + ui_host: resolvePosthogUiHost(posthogIngestionHost, posthogUiHost), autocapture: false, capture_pageview: false, capture_pageleave: false, + loaded: exposePosthogForToolbar, }); } @@ -78,13 +118,8 @@ export default function RuntimeFeedbackSurvey({ }); }); - useEffect(() => { - if (sidePanelVisible && !isPushViewport) setOpen(false); - }, [sidePanelVisible, isPushViewport]); - const question = survey?.questions?.[0]; if (!survey || question?.type !== "open") return null; - if (sidePanelVisible && !isPushViewport) return null; const questionId = question.id ?? ""; const appearance = survey.appearance; diff --git a/ui/lib/csp.ts b/ui/lib/csp.ts index 617fe913e3..77a41c2e3c 100644 --- a/ui/lib/csp.ts +++ b/ui/lib/csp.ts @@ -1,30 +1,77 @@ +import { resolvePosthogUiHost } from "@/lib/posthog-hosts"; + const POSTHOG_CSP_SOURCE = "https://*.posthog.com"; interface CspOptions { cloudEnabled: boolean; posthogEnabled: boolean; posthogKey: string | null; - posthogHost: string | null; + posthogIngestionHost: string | null; + posthogUiHost: string | null; + posthogToolbarEnabled: boolean; } +const getPosthogToolbarUiSource = ( + ingestionHost: string, + configuredUiHost: string | null, +): string => { + try { + const resolvedUiHost = resolvePosthogUiHost( + ingestionHost, + configuredUiHost, + ); + const url = new URL(resolvedUiHost); + const isHttp = url.protocol === "http:" || url.protocol === "https:"; + const coveredByCloudWildcard = + url.protocol === "https:" && url.hostname.endsWith(".posthog.com"); + + return isHttp && !coveredByCloudWildcard ? ` ${url.origin}` : ""; + } catch { + return ""; + } +}; + export function getCspHeader({ cloudEnabled, posthogEnabled, posthogKey, - posthogHost, + posthogIngestionHost, + posthogUiHost, + posthogToolbarEnabled, }: CspOptions): string { const allowPosthog = - cloudEnabled && posthogEnabled && Boolean(posthogKey && posthogHost); + cloudEnabled && + posthogEnabled && + Boolean(posthogKey && posthogIngestionHost); const posthogSource = allowPosthog ? ` ${POSTHOG_CSP_SOURCE}` : ""; + const allowPosthogToolbar = allowPosthog && posthogToolbarEnabled; + const toolbarUiSource = + allowPosthogToolbar && posthogIngestionHost + ? getPosthogToolbarUiSource(posthogIngestionHost, posthogUiHost) + : ""; + const toolbarPosthogSource = allowPosthogToolbar + ? ` ${POSTHOG_CSP_SOURCE}${toolbarUiSource}` + : ""; + const toolbarWorkerSource = allowPosthogToolbar + ? "worker-src 'self' blob: data:;" + : ""; + const toolbarMediaSource = allowPosthogToolbar + ? `media-src 'self' ${POSTHOG_CSP_SOURCE}${toolbarUiSource};` + : ""; + const frameAncestors = allowPosthogToolbar + ? `'self' ${POSTHOG_CSP_SOURCE}${toolbarUiSource}` + : "'none'"; return ` default-src 'self'; - script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com${posthogSource}; - connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io${posthogSource}; - img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${posthogSource}; - font-src 'self'; - style-src 'self' 'unsafe-inline'; - frame-src 'self' https://js.stripe.com https://www.googletagmanager.com${posthogSource}; - frame-ancestors 'none'; + script-src 'self' 'unsafe-inline' 'unsafe-eval' https://js.stripe.com https://www.googletagmanager.com https://browser.sentry-cdn.com${posthogSource}${toolbarUiSource}; + connect-src 'self' https://api.iconify.design https://api.simplesvg.com https://api.unisvg.com https://js.stripe.com https://www.googletagmanager.com https://*.sentry.io https://*.ingest.sentry.io${posthogSource}${toolbarUiSource}; + img-src 'self' https://www.google-analytics.com https://www.googletagmanager.com${posthogSource}${toolbarUiSource}; + font-src 'self'${toolbarPosthogSource}; + style-src 'self' 'unsafe-inline'${toolbarPosthogSource}; + ${toolbarMediaSource} + ${toolbarWorkerSource} + frame-src 'self' https://js.stripe.com https://www.googletagmanager.com${posthogSource}${toolbarUiSource}; + frame-ancestors ${frameAncestors}; `.replace(/\n/g, ""); } diff --git a/ui/lib/env.test.ts b/ui/lib/env.test.ts index ee916e6b12..32465d81a7 100644 --- a/ui/lib/env.test.ts +++ b/ui/lib/env.test.ts @@ -62,6 +62,7 @@ describe("lib/env gated integration validation", () => { "POSTHOG_KEY", "UI_POSTHOG_HOST", "POSTHOG_HOST", + "UI_POSTHOG_UI_HOST", "CLOUD_BILLING_ENABLED", ] as const; @@ -189,6 +190,7 @@ describe("lib/env billing and Stripe boot warnings", () => { "POSTHOG_KEY", "UI_POSTHOG_HOST", "POSTHOG_HOST", + "UI_POSTHOG_UI_HOST", "UI_CLOUD_STRIPE_PUBLISHABLE_KEY", "NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY", "UI_CLOUD_STRIPE_PUBLISHABLE_KEY_V2", diff --git a/ui/lib/get-runtime-config.client.test.ts b/ui/lib/get-runtime-config.client.test.ts index 7da677b1d5..2c0362cb40 100644 --- a/ui/lib/get-runtime-config.client.test.ts +++ b/ui/lib/get-runtime-config.client.test.ts @@ -55,7 +55,8 @@ describe("getRuntimeConfigClient", () => { JSON.stringify({ posthogEnabled: true, posthogKey: "phc_key", - posthogHost: "https://eu.i.posthog.com", + posthogIngestionHost: "https://eu.i.posthog.com", + posthogUiHost: "https://eu.posthog.com", }), ); const { getRuntimeConfigClient } = await import( @@ -68,6 +69,8 @@ describe("getRuntimeConfigClient", () => { // Then expect(config.posthogEnabled).toBe(true); expect(config.posthogKey).toBe("phc_key"); + expect(config.posthogIngestionHost).toBe("https://eu.i.posthog.com"); + expect(config.posthogUiHost).toBe("https://eu.posthog.com"); }); it("falls back to an all-null config when the island is absent", async () => { @@ -139,8 +142,9 @@ describe("getRuntimeConfigClient", () => { "cloudEnabled", "googleTagManagerId", "posthogEnabled", - "posthogHost", + "posthogIngestionHost", "posthogKey", + "posthogUiHost", "reoDevClientId", "sentryDsn", "sentryEnvironment", diff --git a/ui/lib/integrations.test.ts b/ui/lib/integrations.test.ts index 0832b418e3..fd25289139 100644 --- a/ui/lib/integrations.test.ts +++ b/ui/lib/integrations.test.ts @@ -24,6 +24,7 @@ const GATED_ENV_VARS = [ "POSTHOG_KEY", "UI_POSTHOG_HOST", "POSTHOG_HOST", + "UI_POSTHOG_UI_HOST", ] as const; beforeEach(() => { @@ -274,6 +275,20 @@ describe("isGatedIntegrationEnabled", () => { }); describe("warnGatedIntegrationsMisconfig", () => { + it("warns when the optional PostHog app host is set while disabled", () => { + // Given + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.stubEnv("UI_POSTHOG_UI_HOST", "https://eu.posthog.com"); + + // When + warnGatedIntegrationsMisconfig(); + + // Then + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("UI_POSTHOG_UI_HOST"), + ); + }); + it("warns when a config value is set but its enable flag is not 'true'", () => { // Given const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); diff --git a/ui/lib/integrations.ts b/ui/lib/integrations.ts index 07d3886bad..a7408e9ea2 100644 --- a/ui/lib/integrations.ts +++ b/ui/lib/integrations.ts @@ -54,7 +54,7 @@ export const GATED_INTEGRATIONS: Record = { { key: "UI_POSTHOG_KEY", legacy: "POSTHOG_KEY" }, { key: "UI_POSTHOG_HOST", legacy: "POSTHOG_HOST" }, ], - optional: [], + optional: [{ key: "UI_POSTHOG_UI_HOST" }], }, } as const satisfies Record; diff --git a/ui/lib/posthog-hosts.test.ts b/ui/lib/posthog-hosts.test.ts new file mode 100644 index 0000000000..036ab80a81 --- /dev/null +++ b/ui/lib/posthog-hosts.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; + +import { resolvePosthogUiHost } from "./posthog-hosts"; + +describe("resolvePosthogUiHost", () => { + it("prefers an explicitly configured app host", () => { + // Given + const ingestionHost = "https://eu.i.posthog.com"; + const uiHost = "https://posthog.example.com/"; + + // When / Then + expect(resolvePosthogUiHost(ingestionHost, uiHost)).toBe( + "https://posthog.example.com", + ); + }); + + it.each([ + ["https://eu.i.posthog.com", "https://eu.posthog.com"], + ["https://us.i.posthog.com/", "https://us.posthog.com"], + ])("derives the Cloud app host from %s", (ingestionHost, expected) => { + expect(resolvePosthogUiHost(ingestionHost, null)).toBe(expected); + }); + + it("uses the ingestion host for a self-hosted instance", () => { + expect( + resolvePosthogUiHost("https://posthog.internal.example/", null), + ).toBe("https://posthog.internal.example"); + }); +}); diff --git a/ui/lib/posthog-hosts.ts b/ui/lib/posthog-hosts.ts new file mode 100644 index 0000000000..2da15a5b5d --- /dev/null +++ b/ui/lib/posthog-hosts.ts @@ -0,0 +1,22 @@ +const POSTHOG_CLOUD_INGESTION_HOST = /^(eu|us)\.i\.posthog\.com$/i; + +const stripTrailingSlash = (host: string): string => host.replace(/\/+$/, ""); + +export function resolvePosthogUiHost( + ingestionHost: string, + configuredUiHost: string | null, +): string { + if (configuredUiHost) return stripTrailingSlash(configuredUiHost); + + const normalizedIngestionHost = stripTrailingSlash(ingestionHost); + + try { + const url = new URL(normalizedIngestionHost); + const match = url.hostname.match(POSTHOG_CLOUD_INGESTION_HOST); + if (match) return `${url.protocol}//${match[1]}.posthog.com`; + } catch { + // Keep the configured value so posthog-js reports the invalid host. + } + + return normalizedIngestionHost; +} diff --git a/ui/lib/posthog-toolbar.ts b/ui/lib/posthog-toolbar.ts new file mode 100644 index 0000000000..e112a77c36 --- /dev/null +++ b/ui/lib/posthog-toolbar.ts @@ -0,0 +1,11 @@ +import type { PostHogInterface } from "posthog-js"; + +declare global { + interface Window { + posthog?: PostHogInterface; + } +} + +export function exposePosthogForToolbar(client: PostHogInterface): void { + if (process.env.NODE_ENV === "development") window.posthog = client; +} diff --git a/ui/lib/runtime-config.shared.ts b/ui/lib/runtime-config.shared.ts index 3a36534bc0..db1318fb21 100644 --- a/ui/lib/runtime-config.shared.ts +++ b/ui/lib/runtime-config.shared.ts @@ -8,7 +8,8 @@ export interface RuntimePublicConfig { apiDocsUrl: string | null; posthogEnabled: boolean; posthogKey: string | null; - posthogHost: string | null; + posthogIngestionHost: string | null; + posthogUiHost: string | null; reoDevClientId: string | null; // reserved cloudEnabled: boolean; cloudBillingEnabled: boolean; @@ -27,7 +28,8 @@ export const EMPTY_RUNTIME_PUBLIC_CONFIG: RuntimePublicConfig = { apiDocsUrl: null, posthogEnabled: false, posthogKey: null, - posthogHost: null, + posthogIngestionHost: null, + posthogUiHost: null, reoDevClientId: null, cloudEnabled: false, cloudBillingEnabled: false, @@ -46,7 +48,8 @@ const pickConfig = ( apiDocsUrl: parsed.apiDocsUrl ?? null, posthogEnabled: parsed.posthogEnabled ?? false, posthogKey: parsed.posthogKey ?? null, - posthogHost: parsed.posthogHost ?? null, + posthogIngestionHost: parsed.posthogIngestionHost ?? null, + posthogUiHost: parsed.posthogUiHost ?? null, reoDevClientId: parsed.reoDevClientId ?? null, cloudEnabled: parsed.cloudEnabled ?? false, cloudBillingEnabled: parsed.cloudBillingEnabled ?? false, diff --git a/ui/lib/runtime-config.test.ts b/ui/lib/runtime-config.test.ts new file mode 100644 index 0000000000..412cc1e185 --- /dev/null +++ b/ui/lib/runtime-config.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("next/server", () => ({ connection: () => Promise.resolve() })); + +const POSTHOG_ENV_VARS = [ + "UI_POSTHOG_ENABLED", + "UI_POSTHOG_KEY", + "POSTHOG_KEY", + "UI_POSTHOG_HOST", + "POSTHOG_HOST", + "UI_POSTHOG_UI_HOST", +] as const; + +const importFresh = async () => { + vi.resetModules(); + return import("./runtime-config"); +}; + +describe("getRuntimePublicConfig PostHog hosts", () => { + beforeEach(() => { + for (const key of POSTHOG_ENV_VARS) vi.stubEnv(key, undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns separate ingestion and app hosts when PostHog is enabled", async () => { + // Given + vi.stubEnv("UI_POSTHOG_ENABLED", "true"); + vi.stubEnv("UI_POSTHOG_KEY", "phc_key"); + vi.stubEnv("UI_POSTHOG_HOST", "https://us.i.posthog.com"); + vi.stubEnv("UI_POSTHOG_UI_HOST", "https://us.posthog.com"); + + // When + const { getRuntimePublicConfig } = await importFresh(); + const config = await getRuntimePublicConfig(); + + // Then + expect(config.posthogIngestionHost).toBe("https://us.i.posthog.com"); + expect(config.posthogUiHost).toBe("https://us.posthog.com"); + }); + + it("keeps legacy PostHog config working without an explicit app host", async () => { + // Given + vi.stubEnv("POSTHOG_KEY", "phc_key"); + vi.stubEnv("POSTHOG_HOST", "https://posthog.internal.example"); + + // When + const { getRuntimePublicConfig } = await importFresh(); + const config = await getRuntimePublicConfig(); + + // Then + expect(config.posthogIngestionHost).toBe( + "https://posthog.internal.example", + ); + expect(config.posthogUiHost).toBeNull(); + }); + + it("returns null hosts when PostHog is disabled", async () => { + // Given + vi.stubEnv("UI_POSTHOG_HOST", "https://eu.i.posthog.com"); + vi.stubEnv("UI_POSTHOG_UI_HOST", "https://eu.posthog.com"); + + // When + const { getRuntimePublicConfig } = await importFresh(); + const config = await getRuntimePublicConfig(); + + // Then + expect(config.posthogIngestionHost).toBeNull(); + expect(config.posthogUiHost).toBeNull(); + }); +}); diff --git a/ui/lib/runtime-config.ts b/ui/lib/runtime-config.ts index 52635c44c8..29d38ca952 100644 --- a/ui/lib/runtime-config.ts +++ b/ui/lib/runtime-config.ts @@ -43,11 +43,12 @@ export async function getRuntimePublicConfig(): Promise { "UI_POSTHOG_KEY", "POSTHOG_KEY", ), - posthogHost: readGatedEnv( + posthogIngestionHost: readGatedEnv( "UI_POSTHOG_ENABLED", "UI_POSTHOG_HOST", "POSTHOG_HOST", ), + posthogUiHost: readGatedEnv("UI_POSTHOG_ENABLED", "UI_POSTHOG_UI_HOST"), reoDevClientId: readEnv("REO_DEV_CLIENT_ID"), cloudEnabled: readBoolEnv("UI_CLOUD_ENABLED"), // Install-level selector "legacy" | "metronome" | "false"; the client only diff --git a/ui/next.config.test.ts b/ui/next.config.test.ts index a0f39bbdcb..2154ea88f9 100644 --- a/ui/next.config.test.ts +++ b/ui/next.config.test.ts @@ -16,7 +16,9 @@ const ENABLED_POSTHOG_CONFIG = { cloudEnabled: true, posthogEnabled: true, posthogKey: "phc_key", - posthogHost: "https://eu.posthog.com", + posthogIngestionHost: "https://eu.i.posthog.com", + posthogUiHost: null, + posthogToolbarEnabled: false, }; const BASELINE_CSP = { @@ -85,7 +87,9 @@ describe("PostHog Content Security Policy", () => { cloudEnabled: false, posthogEnabled: false, posthogKey: null, - posthogHost: null, + posthogIngestionHost: null, + posthogUiHost: null, + posthogToolbarEnabled: false, }), ); @@ -104,14 +108,95 @@ describe("PostHog Content Security Policy", () => { expect(csp["img-src"]).toContain(POSTHOG_WILDCARD); expect(csp["frame-src"]).toContain(POSTHOG_WILDCARD); expect(csp["font-src"]).not.toContain(POSTHOG_WILDCARD); + expect(csp["style-src"]).not.toContain(POSTHOG_WILDCARD); + expect(csp["media-src"]).toBeUndefined(); + expect(csp["worker-src"]).toBeUndefined(); + expect(csp["frame-ancestors"]).toEqual(["'none'"]); expect(csp["default-src"]).not.toContain(POSTHOG_WILDCARD); }); + it("adds Toolbar permissions for a fully enabled Cloud development request", () => { + // Given + const toolbarConfig = { + ...ENABLED_POSTHOG_CONFIG, + posthogToolbarEnabled: true, + }; + + // When + const csp = parseCsp(getCspHeader(toolbarConfig)); + + // Then + expect(csp["style-src"]).toContain(POSTHOG_WILDCARD); + expect(csp["font-src"]).toContain(POSTHOG_WILDCARD); + expect(csp["media-src"]).toContain(POSTHOG_WILDCARD); + expect(csp["worker-src"]).toEqual(["'self'", "blob:", "data:"]); + expect(csp["frame-ancestors"]).toEqual(["'self'", POSTHOG_WILDCARD]); + }); + + it("allows the resolved UI origin for a self-hosted Toolbar", () => { + // Given + const selfHostedUiOrigin = "https://posthog.internal.example"; + const toolbarConfig = { + ...ENABLED_POSTHOG_CONFIG, + posthogIngestionHost: `${selfHostedUiOrigin}/ingest`, + posthogUiHost: `${selfHostedUiOrigin}/app/`, + posthogToolbarEnabled: true, + }; + + // When + const csp = parseCsp(getCspHeader(toolbarConfig)); + + // Then + expect(csp["script-src"]).toContain(selfHostedUiOrigin); + expect(csp["connect-src"]).toContain(selfHostedUiOrigin); + expect(csp["img-src"]).toContain(selfHostedUiOrigin); + expect(csp["style-src"]).toContain(selfHostedUiOrigin); + expect(csp["font-src"]).toContain(selfHostedUiOrigin); + expect(csp["media-src"]).toContain(selfHostedUiOrigin); + expect(csp["frame-src"]).toContain(selfHostedUiOrigin); + expect(csp["frame-ancestors"]).toContain(selfHostedUiOrigin); + }); + + it("does not add a non-HTTP PostHog UI host to the CSP", () => { + // Given + const unsafeUiHost = "data:text/plain,toolbar"; + const toolbarConfig = { + ...ENABLED_POSTHOG_CONFIG, + posthogUiHost: unsafeUiHost, + posthogToolbarEnabled: true, + }; + + // When + const csp = parseCsp(getCspHeader(toolbarConfig)); + + // Then + expect(Object.values(csp).flat()).not.toContain(unsafeUiHost); + expect(Object.values(csp).flat()).not.toContain("null"); + }); + + it("keeps Toolbar permissions closed when PostHog is not fully enabled", () => { + // Given + const toolbarConfig = { + ...ENABLED_POSTHOG_CONFIG, + posthogEnabled: false, + posthogToolbarEnabled: true, + }; + + // When + const csp = parseCsp(getCspHeader(toolbarConfig)); + + // Then + expect(Object.values(csp).flat()).not.toContain(POSTHOG_WILDCARD); + expect(csp["media-src"]).toBeUndefined(); + expect(csp["worker-src"]).toBeUndefined(); + expect(csp["frame-ancestors"]).toEqual(["'none'"]); + }); + it.each([ ["Cloud is disabled", { cloudEnabled: false }], ["PostHog is disabled", { posthogEnabled: false }], ["the key is missing", { posthogKey: null }], - ["the host is missing", { posthogHost: null }], + ["the host is missing", { posthogIngestionHost: null }], ])("omits PostHog permissions when %s", (_case, override) => { // Given const config = { ...ENABLED_POSTHOG_CONFIG, ...override }; diff --git a/ui/proxy.ts b/ui/proxy.ts index 2e6b330b17..137d108579 100644 --- a/ui/proxy.ts +++ b/ui/proxy.ts @@ -41,11 +41,13 @@ const withSecurityHeaders = (response: NextResponse): NextResponse => { "UI_POSTHOG_KEY", "POSTHOG_KEY", ), - posthogHost: readGatedEnv( + posthogIngestionHost: readGatedEnv( "UI_POSTHOG_ENABLED", "UI_POSTHOG_HOST", "POSTHOG_HOST", ), + posthogUiHost: readGatedEnv("UI_POSTHOG_ENABLED", "UI_POSTHOG_UI_HOST"), + posthogToolbarEnabled: process.env.NODE_ENV === "development", }), ); return response; diff --git a/ui/tests/runtime-config/runtime-config-page.ts b/ui/tests/runtime-config/runtime-config-page.ts index b05f754ee0..35d66672c3 100644 --- a/ui/tests/runtime-config/runtime-config-page.ts +++ b/ui/tests/runtime-config/runtime-config-page.ts @@ -18,7 +18,8 @@ export const RUNTIME_CONFIG_KEYS = [ "apiDocsUrl", "posthogEnabled", "posthogKey", - "posthogHost", + "posthogIngestionHost", + "posthogUiHost", "reoDevClientId", "cloudBillingEnabled", "cloudEnabled", diff --git a/ui/types/env.d.ts b/ui/types/env.d.ts index 8b5a1ce039..6815cb962d 100644 --- a/ui/types/env.d.ts +++ b/ui/types/env.d.ts @@ -64,6 +64,7 @@ declare global { /** @deprecated use UI_POSTHOG_HOST */ POSTHOG_HOST?: string; UI_POSTHOG_HOST?: string; + UI_POSTHOG_UI_HOST?: string; // TODO Reserved runtime public config (registered now; no UI consumer yet) REO_DEV_CLIENT_ID?: string;