diff --git a/lib/plausible/installation_support/checks/installation_v2.ex b/lib/plausible/installation_support/checks/installation_v2.ex new file mode 100644 index 0000000000..0928b845ee --- /dev/null +++ b/lib/plausible/installation_support/checks/installation_v2.ex @@ -0,0 +1,127 @@ +defmodule Plausible.InstallationSupport.Checks.InstallationV2 do + require Logger + + path = Application.app_dir(:plausible, "priv/tracker/installation_support/verifier-v2.js") + # On CI, the file might not be present for static checks so we create an empty one + File.touch!(path) + + @verifier_code File.read!(path) + @external_resource "priv/tracker/installation_support/verifier-v2.js" + + @function_check_timeout 10_000 + + # Puppeteer wrapper function that executes the vanilla JS verifier code + @puppeteer_wrapper_code """ + export default async function({ page, context }) { + try { + await page.setUserAgent(context.userAgent); + const response = await page.goto(context.url); + + await page.evaluate(() => { + #{@verifier_code} + }); + + return await page.evaluate(async ({ responseHeaders, debug, timeoutMs, cspHostToCheck }) => { + return await window.verifyPlausibleInstallation({ responseHeaders, debug, timeoutMs, cspHostToCheck }); + }, { + timeoutMs: context.timeoutMs, + responseHeaders: response.headers(), + debug: context.debug, + cspHostToCheck: context.cspHostToCheck + }); + } catch (error) { + return { + data: { + completed: false, + error: { + message: error?.message ?? JSON.stringify(error), + } + } + } + } + } + """ + + @moduledoc """ + Calls the browserless.io service (local instance can be spawned with `make browserless`) + and runs verifier script via the [function API](https://docs.browserless.io/HTTP-APIs/function). + """ + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: "We're verifying that your visitors are being counted correctly" + + @impl true + def perform(%State{url: url} = state) do + opts = [ + headers: %{content_type: "application/json"}, + body: + JSON.encode!(%{ + code: @puppeteer_wrapper_code, + context: %{ + cspHostToCheck: PlausibleWeb.Endpoint.host(), + timeoutMs: @function_check_timeout, + url: Plausible.InstallationSupport.URL.bust_url(url), + userAgent: Plausible.InstallationSupport.user_agent(), + debug: Application.get_env(:plausible, :environment) == "dev" + } + }), + retry: :transient, + retry_log_level: :warning, + max_retries: 2 + ] + + extra_opts = Application.get_env(:plausible, __MODULE__)[:req_opts] || [] + opts = Keyword.merge(opts, extra_opts) + + case Req.post(Plausible.InstallationSupport.browserless_function_api_endpoint(), opts) do + {:ok, %{body: body, status: status}} -> + handle_browserless_response(state, body, status) + + {:error, %{reason: reason}} -> + Logger.warning(warning_message("Browserless request error: #{inspect(reason)}", state)) + + put_diagnostics(state, service_error: reason) + end + end + + defp handle_browserless_response( + state, + %{"data" => %{"completed" => completed} = data}, + _status + ) do + if completed do + put_diagnostics( + state, + disallowed_by_csp: data["disallowedByCsp"], + plausible_is_on_window: data["plausibleIsOnWindow"], + plausible_is_initialized: data["plausibleIsInitialized"], + plausible_version: data["plausibleVersion"], + plausible_variant: data["plausibleVariant"], + test_event: data["testEvent"], + cookie_banner_likely: data["cookieBannerLikely"], + service_error: nil + ) + else + Logger.warning( + warning_message( + "Browserless function returned with completed: false, error.message: #{inspect(data["error"]["message"])}", + state + ) + ) + + put_diagnostics(state, service_error: data["error"]["message"]) + end + end + + defp handle_browserless_response(state, _body, status) do + error = "Unhandled browserless response with status: #{status}" + Logger.warning(warning_message(error, state)) + + put_diagnostics(state, service_error: error) + end + + defp warning_message(message, state) do + "[VERIFICATION v2] #{message} (data_domain='#{state.data_domain}')" + end +end diff --git a/lib/plausible/installation_support/checks/installation_v2_cache_bust.ex b/lib/plausible/installation_support/checks/installation_v2_cache_bust.ex new file mode 100644 index 0000000000..70cf3cc736 --- /dev/null +++ b/lib/plausible/installation_support/checks/installation_v2_cache_bust.ex @@ -0,0 +1,39 @@ +defmodule Plausible.InstallationSupport.Checks.InstallationV2CacheBust do + @moduledoc """ + If the output of previous checks can not be interpreted as successful, + as a last resort, we try to bust the cache of the site under test by adding a query parameter to the URL, + and running InstallationV2 again. + + Whatever the result from the rerun, that is what we use to interpret the installation. + + The idea is to make sure that any issues we detect will be about the latest version of their website. + + We also want to avoid reporting a successful installation if it took a special cache-busting action to make it work. + """ + + require Logger + alias Plausible.InstallationSupport + use Plausible.InstallationSupport.Check + + @impl true + def report_progress_as, do: "We're verifying that your visitors are being counted correctly" + + @impl true + def perform(%State{url: url} = state) do + if InstallationSupport.Verification.Checks.interpret_diagnostics(state) == + %InstallationSupport.Result{ok?: true} do + state + else + url_that_maybe_busts_cache = + Plausible.InstallationSupport.URL.bust_url(url) + + state_after_cache_bust = + Plausible.InstallationSupport.Checks.InstallationV2.perform(%{ + state + | url: url_that_maybe_busts_cache + }) + + put_diagnostics(state_after_cache_bust, diagnostics_are_from_cache_bust: true) + end + end +end diff --git a/lib/plausible/installation_support/legacy_verification/diagnostics.ex b/lib/plausible/installation_support/legacy_verification/diagnostics.ex index 10e1eb38c2..54204f1b9f 100644 --- a/lib/plausible/installation_support/legacy_verification/diagnostics.ex +++ b/lib/plausible/installation_support/legacy_verification/diagnostics.ex @@ -25,14 +25,7 @@ defmodule Plausible.InstallationSupport.LegacyVerification.Diagnostics do @type t :: %__MODULE__{} - defmodule Result do - @moduledoc """ - Diagnostics interpretation result. - """ - defstruct ok?: false, errors: [], recommendations: [] - @type t :: %__MODULE__{} - end - + alias Plausible.InstallationSupport.Result @spec interpret(t(), String.t()) :: Result.t() def interpret( %__MODULE__{ diff --git a/lib/plausible/installation_support/result.ex b/lib/plausible/installation_support/result.ex new file mode 100644 index 0000000000..71bf50cbb4 --- /dev/null +++ b/lib/plausible/installation_support/result.ex @@ -0,0 +1,7 @@ +defmodule Plausible.InstallationSupport.Result do + @moduledoc """ + Diagnostics interpretation result. + """ + defstruct ok?: false, errors: [], recommendations: [] + @type t :: %__MODULE__{} +end diff --git a/lib/plausible/installation_support/state.ex b/lib/plausible/installation_support/state.ex index c68917e250..6f5040caca 100644 --- a/lib/plausible/installation_support/state.ex +++ b/lib/plausible/installation_support/state.ex @@ -12,7 +12,9 @@ defmodule Plausible.InstallationSupport.State do assigns: %{}, diagnostics: %{} - @type diagnostics_type :: Plausible.InstallationSupport.LegacyVerification.Diagnostics.t() + @type diagnostics_type :: + Plausible.InstallationSupport.LegacyVerification.Diagnostics.t() + | Plausible.InstallationSupport.Verification.Diagnostics.t() @type t :: %__MODULE__{ url: String.t() | nil, diff --git a/lib/plausible/installation_support/verification/checks.ex b/lib/plausible/installation_support/verification/checks.ex new file mode 100644 index 0000000000..d69572713e --- /dev/null +++ b/lib/plausible/installation_support/verification/checks.ex @@ -0,0 +1,47 @@ +defmodule Plausible.InstallationSupport.Verification.Checks do + @moduledoc """ + Checks that are performed during tracker script installation verification. + + In async execution, each check notifies the caller by sending a message to it. + """ + alias Plausible.InstallationSupport.Verification + alias Plausible.InstallationSupport.{State, CheckRunner, Checks} + + require Logger + + @checks [ + Checks.InstallationV2, + Checks.InstallationV2CacheBust + ] + + def run(url, data_domain, installation_type, opts \\ []) do + checks = Keyword.get(opts, :checks, @checks) + report_to = Keyword.get(opts, :report_to, self()) + async? = Keyword.get(opts, :async?, true) + slowdown = Keyword.get(opts, :slowdown, 500) + + init_state = + %State{ + url: url, + data_domain: data_domain, + report_to: report_to, + diagnostics: %Verification.Diagnostics{ + selected_installation_type: installation_type + } + } + + CheckRunner.run(init_state, checks, + async?: async?, + report_to: report_to, + slowdown: slowdown + ) + end + + def interpret_diagnostics(%State{} = state) do + Verification.Diagnostics.interpret( + state.diagnostics, + state.data_domain, + state.url + ) + end +end diff --git a/lib/plausible/installation_support/verification/diagnostics.ex b/lib/plausible/installation_support/verification/diagnostics.ex new file mode 100644 index 0000000000..9b4d81e275 --- /dev/null +++ b/lib/plausible/installation_support/verification/diagnostics.ex @@ -0,0 +1,153 @@ +defmodule Plausible.InstallationSupport.Verification.Diagnostics do + @moduledoc """ + Module responsible for translating diagnostics to user-friendly errors and recommendations. + """ + require Logger + + # in this struct, nil means indeterminate + defstruct selected_installation_type: nil, + disallowed_by_csp: nil, + plausible_is_on_window: nil, + plausible_is_initialized: nil, + plausible_version: nil, + plausible_variant: nil, + diagnostics_are_from_cache_bust: nil, + test_event: nil, + cookie_banner_likely: nil, + service_error: nil + + @type t :: %__MODULE__{} + + alias Plausible.InstallationSupport.Result + + defmodule Error do + @moduledoc """ + Error that has compile-time enforced checks for the attributes. + """ + + @enforce_keys [:message, :recommendation] + defstruct [:message, :recommendation, :url] + + def new!(attrs) do + message = Map.fetch!(attrs, :message) + + if String.ends_with?(message, ".") do + raise ArgumentError, "Error message must not end with a period: #{inspect(message)}" + end + + if String.ends_with?(attrs[:recommendation], ".") do + raise ArgumentError, + "Error recommendation must not end with a period: #{inspect(attrs[:recommendation])}" + end + + if is_binary(attrs[:url]) and not String.starts_with?(attrs[:url], "https://plausible.io") do + raise ArgumentError, + "Recommendation url must start with 'https://plausible.io': #{inspect(attrs[:url])}" + end + + struct!(__MODULE__, attrs) + end + end + + @error_unexpected_domain Error.new!(%{ + message: "Plausible test event is not for this site", + recommendation: + "Please check that the snippet on your site matches the installation instructions exactly", + url: + "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + }) + @error_succeeds_only_after_cache_bust Error.new!(%{ + message: "We detected an issue with your site's cache", + recommendation: + "Please clear the cache for your site to ensure that your visitors will load the latest version of your site that has Plausible correctly installed", + url: + "https://plausible.io/docs/troubleshoot-integration#have-you-cleared-the-cache-of-your-site" + }) + @spec interpret(t(), String.t(), String.t()) :: Result.t() + def interpret( + %__MODULE__{ + plausible_is_on_window: true, + plausible_is_initialized: true, + test_event: %{ + "normalizedBody" => %{ + "domain" => domain + }, + "responseStatus" => response_status + }, + service_error: nil, + diagnostics_are_from_cache_bust: diagnostics_are_from_cache_bust + } = diagnostics, + expected_domain, + url + ) + when response_status in [200, 202] do + domain_is_expected? = domain == expected_domain + + cond do + domain_is_expected? and diagnostics_are_from_cache_bust -> + error(@error_succeeds_only_after_cache_bust) + + domain_is_expected? -> + success() + + not domain_is_expected? -> + error(@error_unexpected_domain) + + true -> + unknown_error(diagnostics, url) + end + end + + @error_csp_disallowed Error.new!(%{ + message: + "We encountered an issue with your site's Content Security Policy (CSP)", + recommendation: + "Please add plausible.io domain specifically to the allowed list of domains in your site's CSP", + url: + "https://plausible.io/docs/troubleshoot-integration#does-your-site-use-a-content-security-policy-csp" + }) + def interpret( + %__MODULE__{ + disallowed_by_csp: true, + service_error: nil + }, + _expected_domain, + _url + ) do + error(@error_csp_disallowed) + end + + def interpret(%__MODULE__{} = diagnostics, _expected_domain, url), + do: unknown_error(diagnostics, url) + + defp success() do + %Result{ok?: true} + end + + defp error(%Error{} = error) do + %Result{ + ok?: false, + errors: [error.message], + recommendations: [%{text: error.recommendation, url: error.url}] + } + end + + @unknown_error Error.new!(%{ + message: "Your Plausible integration is not working", + recommendation: + "Please manually check your integration to make sure that the Plausible snippet has been inserted correctly", + url: + "https://plausible.io/docs/troubleshoot-integration#how-to-manually-check-your-integration" + }) + defp unknown_error(diagnostics, url) do + Sentry.capture_message("Unhandled case for site verification (v2)", + extra: %{ + message: inspect(diagnostics), + url: url, + hash: :erlang.phash2(diagnostics) + } + ) + + error(@unknown_error) + end +end diff --git a/lib/plausible_web/live/components/verification.ex b/lib/plausible_web/live/components/verification.ex index 29748d3d95..6037ddd3d4 100644 --- a/lib/plausible_web/live/components/verification.ex +++ b/lib/plausible_web/live/components/verification.ex @@ -7,7 +7,7 @@ defmodule PlausibleWeb.Live.Components.Verification do use Plausible alias PlausibleWeb.Router.Helpers, as: Routes - alias Plausible.InstallationSupport.{State, LegacyVerification} + alias Plausible.InstallationSupport.{State, Result} import PlausibleWeb.Components.Generic @@ -21,7 +21,7 @@ defmodule PlausibleWeb.Live.Components.Verification do attr(:finished?, :boolean, default: false) attr(:success?, :boolean, default: false) attr(:verification_state, State, default: nil) - attr(:interpretation, LegacyVerification.Diagnostics.Result, default: nil) + attr(:interpretation, Result, default: nil) attr(:attempts, :integer, default: 0) attr(:flow, :string, default: "") attr(:installation_type, :string, default: nil) @@ -146,7 +146,8 @@ defmodule PlausibleWeb.Live.Components.Verification do <.focus_list> <:item :for={{diag, value} <- Map.from_struct(@verification_state.diagnostics)}> - {Phoenix.Naming.humanize(diag)}: {value} + {Phoenix.Naming.humanize(diag)}: + {to_string_value(value)} @@ -157,4 +158,7 @@ defmodule PlausibleWeb.Live.Components.Verification do """ end + + defp to_string_value(value) when is_binary(value), do: value + defp to_string_value(value), do: inspect(value) end diff --git a/lib/plausible_web/live/verification.ex b/lib/plausible_web/live/verification.ex index 0f86b3c2a4..42e574b982 100644 --- a/lib/plausible_web/live/verification.ex +++ b/lib/plausible_web/live/verification.ex @@ -7,7 +7,7 @@ defmodule PlausibleWeb.Live.Verification do use Plausible use PlausibleWeb, :live_view - alias Plausible.InstallationSupport.{State, LegacyVerification} + alias Plausible.InstallationSupport.{State, LegacyVerification, Verification} @component PlausibleWeb.Live.Components.Verification @slowdown_for_frequent_checking :timer.seconds(5) @@ -131,12 +131,24 @@ defmodule PlausibleWeb.Live.Verification do {:deny, _} -> :timer.sleep(@slowdown_for_frequent_checking) end + url_to_verify = "https://#{socket.assigns.domain}" + domain = socket.assigns.domain + installation_type = socket.assigns.installation_type + {:ok, pid} = - LegacyVerification.Checks.run( - "https://#{socket.assigns.domain}", - socket.assigns.domain, - report_to: report_to, - slowdown: socket.assigns.slowdown + if(FunWithFlags.enabled?(:scriptv2, for: socket.assigns.site), + do: + Verification.Checks.run(url_to_verify, domain, installation_type, + report_to: report_to, + slowdown: socket.assigns.slowdown + ), + else: + LegacyVerification.Checks.run( + url_to_verify, + domain, + report_to: report_to, + slowdown: socket.assigns.slowdown + ) ) {:noreply, assign(socket, checks_pid: pid, attempts: socket.assigns.attempts + 1)} @@ -152,7 +164,11 @@ defmodule PlausibleWeb.Live.Verification do end def handle_info({:all_checks_done, %State{} = state}, socket) do - interpretation = LegacyVerification.Checks.interpret_diagnostics(state) + interpretation = + if(FunWithFlags.enabled?(:scriptv2, for: socket.assigns.site), + do: Verification.Checks.interpret_diagnostics(state), + else: LegacyVerification.Checks.interpret_diagnostics(state) + ) if not socket.assigns.has_pageviews? do schedule_pageviews_check(socket) diff --git a/tracker/compiler/variants.json b/tracker/compiler/variants.json index a3a27d4247..7d7d24d113 100644 --- a/tracker/compiler/variants.json +++ b/tracker/compiler/variants.json @@ -42,6 +42,12 @@ "entry_point": "installation_support/verifier-v1.js", "output_path": "priv/tracker/installation_support/verifier-v1.js", "globals": {} + }, + { + "name": "verifier-v2.js", + "entry_point": "installation_support/verifier-v2.js", + "output_path": "priv/tracker/installation_support/verifier-v2.js", + "globals": {} } ], "legacyVariants": [ diff --git a/tracker/installation_support/check-disallowed-by-csp.js b/tracker/installation_support/check-disallowed-by-csp.js new file mode 100644 index 0000000000..a2c1cb8d66 --- /dev/null +++ b/tracker/installation_support/check-disallowed-by-csp.js @@ -0,0 +1,23 @@ +/** + * Checks if the CSP policy disallows the given host/domain. + * @param {Record} responseHeaders - Response headers with keys normalized to lowercase like { "x-foo": "bar" } + * @param {string} hostToCheck - Domain/host to check. Must be provided. + * @returns {boolean} + */ +export function checkDisallowedByCSP(responseHeaders, hostToCheck) { + if (!hostToCheck || typeof hostToCheck !== 'string') { + throw new Error('hostToCheck must be a non-empty string') + } + const policy = responseHeaders?.['content-security-policy'] + if (!policy) return false + + const directives = policy.split(';') + + const allowed = directives.some((directive) => { + const d = directive.trim() + // Check for the provided host/domain + return d.includes(hostToCheck) + }) + + return !allowed +} diff --git a/tracker/installation_support/verifier-v2.js b/tracker/installation_support/verifier-v2.js new file mode 100644 index 0000000000..4f8b1e09c0 --- /dev/null +++ b/tracker/installation_support/verifier-v2.js @@ -0,0 +1,220 @@ +/** @typedef {import('../test/support/types').VerifyV2Args} VerifyV2Args */ +/** @typedef {import('../test/support/types').VerifyV2Result} VerifyV2Result */ +import { checkCookieBanner } from './check-cookie-banner' +import { checkDisallowedByCSP } from './check-disallowed-by-csp' + +/** + * Function that verifies if Plausible is installed correctly. + * @param {VerifyV2Args} + * @returns {Promise} + */ + +async function verifyPlausibleInstallation({ + timeoutMs, + responseHeaders, + debug, + cspHostToCheck +}) { + function log(message) { + if (debug) console.log('[VERIFICATION v2]', message) + } + + const disallowedByCsp = checkDisallowedByCSP(responseHeaders, cspHostToCheck) + + const { stopRecording, getInterceptedFetch } = startRecordingEventFetchCalls() + + const { + plausibleIsInitialized, + plausibleIsOnWindow, + plausibleVersion, + plausibleVariant, + testEvent, + error: testPlausibleFunctionError + } = await testPlausibleFunction({ + timeoutMs + }) + + if (testPlausibleFunctionError) { + log( + `There was an error testing plausible function: ${testPlausibleFunctionError}` + ) + } + + stopRecording() + + const interceptedTestEvent = getInterceptedFetch('verification-agent-test') + + if (!interceptedTestEvent) { + log(`No test event request was among intercepted requests`) + } + + const diagnostics = { + disallowedByCsp, + plausibleIsOnWindow, + plausibleIsInitialized, + plausibleVersion, + plausibleVariant, + testEvent: { + ...testEvent, + requestUrl: interceptedTestEvent?.request?.url, + normalizedBody: interceptedTestEvent?.request?.normalizedBody, + responseStatus: interceptedTestEvent?.response?.status, + error: interceptedTestEvent?.error + }, + cookieBannerLikely: checkCookieBanner() + } + + log({ + diagnostics + }) + + return { + data: { + completed: true, + ...diagnostics + } + } +} + +function getNormalizedPlausibleEventBody(fetchOptions) { + try { + const body = JSON.parse(fetchOptions.body ?? '{}') + + let name = null + let domain = null + let version = null + + if ( + fetchOptions.method === 'POST' && + (typeof body?.n === 'string' || typeof body?.name === 'string') && + (typeof body?.d === 'string' || typeof body?.domain === 'string') + ) { + name = body?.n || body?.name + domain = body?.d || body?.domain + version = body?.v || body?.version + } + return name && domain ? { name, domain, version } : null + } catch (e) {} +} + +function startRecordingEventFetchCalls() { + const interceptions = new Map() + + const originalFetch = window.fetch + window.fetch = function (url, options = {}) { + let identifier = null + + const normalizedEventBody = getNormalizedPlausibleEventBody(options) + if (normalizedEventBody) { + identifier = normalizedEventBody.name + interceptions.set(identifier, { + request: { url, normalizedBody: normalizedEventBody } + }) + } + + return originalFetch + .apply(this, arguments) + .then(async (response) => { + const eventRequest = interceptions.get(identifier) + if (eventRequest) { + const responseClone = response.clone() + const body = await responseClone.text() + eventRequest.response = { status: response.status, body } + } + return response + }) + .catch((error) => { + const eventRequest = interceptions.get(identifier) + if (eventRequest) { + eventRequest.error = { + message: error?.message || 'Unknown error during fetch' + } + } + throw error + }) + } + return { + getInterceptedFetch: (identifier) => interceptions.get(identifier), + stopRecording: () => { + window.fetch = originalFetch + } + } +} + +function isPlausibleOnWindow() { + return !!window.plausible +} + +function isPlausibleInitialized() { + return window.plausible?.l +} + +function getPlausibleVersion() { + return window.plausible?.v +} + +function getPlausibleVariant() { + return window.plausible?.s +} + +async function testPlausibleFunction({ timeoutMs }) { + return new Promise(async (_resolve) => { + let plausibleIsOnWindow = isPlausibleOnWindow() + let plausibleIsInitialized = isPlausibleInitialized() + let plausibleVersion = getPlausibleVersion() + let plausibleVariant = getPlausibleVariant() + let testEvent = {} + + let resolved = false + + function resolve(additionalData) { + resolved = true + _resolve({ + plausibleIsInitialized, + plausibleIsOnWindow, + plausibleVersion, + plausibleVariant, + testEvent, + ...additionalData + }) + } + + const timeout = setTimeout(() => { + resolve({ + error: 'Test Plausible function timeout exceeded' + }) + }, timeoutMs) + + while (!plausibleIsOnWindow) { + if (isPlausibleOnWindow()) { + plausibleIsOnWindow = true + } + await delay(10) + } + + while (!plausibleIsInitialized) { + if (isPlausibleInitialized()) { + plausibleIsInitialized = true + plausibleVersion = getPlausibleVersion() + plausibleVariant = getPlausibleVariant() + } + await delay(10) + } + + window.plausible('verification-agent-test', { + callback: (testEventCallbackResult) => { + if (resolved) return + clearTimeout(timeout) + resolve({ + testEvent: { callbackResult: testEventCallbackResult } + }) + } + }) + }) +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +window.verifyPlausibleInstallation = verifyPlausibleInstallation diff --git a/tracker/test/installation_support/check-disallowed-by-csp.spec.js b/tracker/test/installation_support/check-disallowed-by-csp.spec.js new file mode 100644 index 0000000000..963077d7f2 --- /dev/null +++ b/tracker/test/installation_support/check-disallowed-by-csp.spec.js @@ -0,0 +1,40 @@ +import { test, expect } from '@playwright/test' +import { checkDisallowedByCSP } from '../../installation_support/check-disallowed-by-csp' + +const HOST_TO_CHECK = 'plausible.io' + +test.describe('checkDisallowedByCSP', () => { + test('returns false if no CSP header', () => { + expect(checkDisallowedByCSP({}, HOST_TO_CHECK)).toBe(false) + expect(checkDisallowedByCSP({foo: 'bar'}, HOST_TO_CHECK)).toBe(false) + }) + + test('returns false if CSP header is empty', () => { + expect(checkDisallowedByCSP({'content-security-policy': ''}, HOST_TO_CHECK)).toBe(false) + }) + + test('returns true if plausible.io is not allowed', () => { + const headers = {'content-security-policy': "default-src 'self' foo.local; example.com"} + expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(true) + }) + + test('returns false if plausible.io is allowed', () => { + const headers = {'content-security-policy': "default-src 'self' plausible.io; example.com"} + expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false) + }) + + test('returns false if plausible.io subdomain is allowed', () => { + const headers = {'content-security-policy': "default-src 'self' staging.plausible.io; example.com"} + expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false) + }) + + test('returns false if plausible.io is allowed with https', () => { + const headers = {'content-security-policy': "default-src 'self' https://plausible.io; example.com"} + expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(false) + }) + + test('returns true if plausible.io is not present in any directive', () => { + const headers = {'content-security-policy': "default-src 'self' foo.com; bar.com"} + expect(checkDisallowedByCSP(headers, HOST_TO_CHECK)).toBe(true) + }) +}) \ No newline at end of file diff --git a/tracker/test/installation_support/verifier-v1.spec.js b/tracker/test/installation_support/verifier-v1.spec.js index b7811d914e..87a79521a0 100644 --- a/tracker/test/installation_support/verifier-v1.spec.js +++ b/tracker/test/installation_support/verifier-v1.spec.js @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test' -import { verify } from '../support/installation-support-playwright-wrappers' +import { verifyV1 } from '../support/installation-support-playwright-wrappers' import { delay } from '../support/test-utils' import { initializePageDynamically } from '../support/initialize-page-dynamically' import { compileFile } from '../../compiler' @@ -29,7 +29,7 @@ test.describe('v1 verifier (basic diagnostics)', () => { scriptConfig: `` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(1) @@ -72,7 +72,7 @@ test.describe('v1 verifier (basic diagnostics)', () => { ` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN, debug: true}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN, debug: true}) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(1) @@ -87,7 +87,7 @@ test.describe('v1 verifier (basic diagnostics)', () => { scriptConfig: '' }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.plausibleInstalled).toBe(false) expect(result.data.callbackStatus).toBe(0) @@ -104,7 +104,7 @@ test.describe('v1 verifier (basic diagnostics)', () => { response: `` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(0) @@ -121,7 +121,7 @@ test.describe('v1 verifier (basic diagnostics)', () => { response: `` }) - const result = await verify(page, {url: url, expectedDataDomain: "example.com"}) + const result = await verifyV1(page, {url: url, expectedDataDomain: "example.com"}) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(1) @@ -138,7 +138,7 @@ test.describe('v1 verifier (basic diagnostics)', () => { response: `` }) - const result = await verify(page, {url: url, expectedDataDomain: "example.typo"}) + const result = await verifyV1(page, {url: url, expectedDataDomain: "example.typo"}) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(1) @@ -178,7 +178,7 @@ test.describe('v1 verifier (basic diagnostics)', () => { ` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.proxyLikely).toBe(false) }) @@ -201,7 +201,7 @@ test.describe('v1 verifier (basic diagnostics)', () => { ` }) - const result = await verify(page, {url: url, expectedDataDomain: "example.com"}) + const result = await verifyV1(page, {url: url, expectedDataDomain: "example.com"}) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.snippetsFoundInHead).toBe(2) @@ -218,7 +218,7 @@ test.describe('v1 verifier (basic diagnostics)', () => { scriptConfig: `` }) - const result = await verify(page, {url: url, expectedDataDomain: 'right.com'}) + const result = await verifyV1(page, {url: url, expectedDataDomain: 'right.com'}) expect(result.data.dataDomainMismatch).toBe(true) }) @@ -231,7 +231,7 @@ test.describe('v1 verifier (basic diagnostics)', () => { scriptConfig: `` }) - const result = await verify(page, {url: url, expectedDataDomain: 'right.com'}) + const result = await verifyV1(page, {url: url, expectedDataDomain: 'right.com'}) expect(result.data.dataDomainMismatch).toBe(false) }) @@ -249,7 +249,7 @@ test.describe('v1 verifier (window.plausible)', () => { scriptConfig: `` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.callbackStatus).toBe(404) @@ -263,7 +263,7 @@ test.describe('v1 verifier (window.plausible)', () => { scriptConfig: `` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.callbackStatus).toBe(0) @@ -279,7 +279,7 @@ test.describe('v1 verifier (window.plausible)', () => { scriptConfig: `` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.plausibleInstalled).toBe(true) expect(result.data.callbackStatus).toBe(-1) @@ -300,7 +300,7 @@ test.describe('v1 verifier (WordPress detection)', () => { ` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.wordpressPlugin).toBe(true) expect(result.data.wordpressLikely).toBe(true) @@ -321,7 +321,7 @@ test.describe('v1 verifier (WordPress detection)', () => { ` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.wordpressPlugin).toBe(false) expect(result.data.wordpressLikely).toBe(true) @@ -353,7 +353,7 @@ test.describe('v1 verifier (GTM detection)', () => { ` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.gtmLikely).toBe(true) }) @@ -392,7 +392,7 @@ test.describe('v1 verifier (cookieBanner detection)', () => { ` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.cookieBannerLikely).toBe(true) }) @@ -417,7 +417,7 @@ test.describe('v1 verifier (manualScriptExtension detection)', () => { ` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.manualScriptExtension).toBe(true) }) @@ -447,7 +447,7 @@ test.describe('v1 verifier (unknownAttributes detection)', () => { ` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.unknownAttributes).toBe(false) }) @@ -466,7 +466,7 @@ test.describe('v1 verifier (unknownAttributes detection)', () => { ` }) - const result = await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + const result = await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(result.data.unknownAttributes).toBe(true) }) @@ -484,7 +484,7 @@ test.describe('v1 verifier (logging)', () => { scriptConfig: `` }) - await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN, debug: true}) + await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN, debug: true}) expect(logs.find(str => str.includes('Starting snippet detection'))).toContain('[Plausible Verification] Starting snippet detection') expect(logs.find(str => str.includes('Checking for Plausible function'))).toContain('[Plausible Verification] Checking for Plausible function') @@ -501,7 +501,7 @@ test.describe('v1 verifier (logging)', () => { scriptConfig: `` }) - await verify(page, {url: url, expectedDataDomain: SOME_DOMAIN}) + await verifyV1(page, {url: url, expectedDataDomain: SOME_DOMAIN}) expect(logs.length).toBe(0) }) diff --git a/tracker/test/installation_support/verifier-v2.spec.ts b/tracker/test/installation_support/verifier-v2.spec.ts new file mode 100644 index 0000000000..2e134e0868 --- /dev/null +++ b/tracker/test/installation_support/verifier-v2.spec.ts @@ -0,0 +1,449 @@ +import { test, expect } from '@playwright/test' +import { executeVerifyV2 } from '../support/installation-support-playwright-wrappers' +import { initializePageDynamically } from '../support/initialize-page-dynamically' +import { mockManyRequests } from '../support/mock-many-requests' +import { LOCAL_SERVER_ADDR } from '../support/server' +import { tracker_script_version as version } from '../support/test-utils' + +const CSP_HOST_TO_CHECK = 'plausible.io' + +test.describe('installed plausible web variant', () => { + test('using provided snippet', async ({ page }, { testId }) => { + await mockManyRequests({ + page, + path: `https://plausible.io/api/event`, + awaitedRequestCount: 1, + fulfill: { + status: 202, + contentType: 'text/plain', + body: 'ok' + } + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + domain: 'example.com', + endpoint: `https://plausible.io/api/event`, + captureOnLocalhost: true + }, + bodyContent: '' + }) + + await page.goto(url) + + const result = await executeVerifyV2(page, { + responseHeaders: {}, + debug: true, + timeoutMs: 1000, + cspHostToCheck: CSP_HOST_TO_CHECK + }) + + expect(result).toEqual({ + data: { + completed: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'web', + testEvent: { + callbackResult: { status: 202 }, + requestUrl: 'https://plausible.io/api/event', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: 202, + error: undefined + }, + cookieBannerLikely: false + } + }) + }) + + test('using provided snippet and the events endpoint responds slower than the timeout', async ({ + page + }, { testId }) => { + await mockManyRequests({ + page, + path: `https://plausible.io/api/event`, + awaitedRequestCount: 1, + fulfill: { + status: 202, + contentType: 'text/plain', + body: 'ok' + }, + responseDelay: 2000 + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + domain: 'example.com', + endpoint: `https://plausible.io/api/event`, + captureOnLocalhost: true + }, + bodyContent: '' + }) + + await page.goto(url) + + const result = await executeVerifyV2(page, { + responseHeaders: {}, + debug: true, + timeoutMs: 1000, + cspHostToCheck: CSP_HOST_TO_CHECK + }) + + expect(result).toEqual({ + data: { + completed: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'web', + testEvent: { + callbackResult: undefined, + requestUrl: 'https://plausible.io/api/event', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: undefined, + error: undefined + }, + cookieBannerLikely: false + } + }) + }) + + test('using provided snippet and the events endpoint responds with 400', async ({ + page + }, { testId }) => { + await mockManyRequests({ + page, + path: `https://plausible.io/api/event`, + awaitedRequestCount: 1, + fulfill: { + status: 400, + contentType: 'text/plain', + body: 'Bad Request' + } + }) + + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + domain: 'example.com', + endpoint: `https://plausible.io/api/event`, + captureOnLocalhost: true + }, + bodyContent: '' + }) + + await page.goto(url) + + const result = await executeVerifyV2(page, { + responseHeaders: {}, + debug: true, + timeoutMs: 1000, + cspHostToCheck: CSP_HOST_TO_CHECK + }) + + expect(result).toEqual({ + data: { + completed: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'web', + testEvent: { + callbackResult: { status: 400 }, + requestUrl: 'https://plausible.io/api/event', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: 400, + error: undefined + }, + cookieBannerLikely: false + } + }) + }) + + test('using provided snippet and captureOnLocalhost: false', async ({ + page + }, { testId }) => { + const { url } = await initializePageDynamically(page, { + testId, + scriptConfig: { + domain: 'example.com/foobar', + endpoint: 'https://plausible.io/api/event', + captureOnLocalhost: false + }, + bodyContent: '' + }) + + await page.goto(url) + + const result = await executeVerifyV2(page, { + responseHeaders: {}, + debug: true, + timeoutMs: 1000, + cspHostToCheck: CSP_HOST_TO_CHECK + }) + + expect(result).toEqual({ + data: { + completed: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'web', + testEvent: { + callbackResult: undefined, + requestUrl: undefined, + normalizedBody: undefined, + responseStatus: undefined, + error: undefined + }, + cookieBannerLikely: false + } + }) + }) +}) + +test.describe('installed plausible esm variant', () => { + test('using `, + bodyContent: '' + }) + + await page.goto(url) + + const result = await executeVerifyV2(page, { + responseHeaders: {}, + debug: true, + timeoutMs: 1000, + cspHostToCheck: CSP_HOST_TO_CHECK + }) + + expect(result).toEqual({ + data: { + completed: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'npm', + testEvent: { + callbackResult: { status: 202 }, + requestUrl: 'https://plausible.io/api/event', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: 202, + error: undefined + }, + cookieBannerLikely: false + } + }) + }) + + test('using `, + bodyContent: '' + }) + + await page.goto(url) + + const result = await executeVerifyV2(page, { + responseHeaders: {}, + debug: true, + timeoutMs: 1000, + cspHostToCheck: CSP_HOST_TO_CHECK + }) + + expect(result).toEqual({ + data: { + completed: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'npm', + testEvent: { + callbackResult: { status: 202 }, + requestUrl: '/events', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: 202, + error: undefined + }, + cookieBannerLikely: false + } + }) + }) + + test('using `, + bodyContent: '' + }) + + await mockManyRequests({ + page, + path: `https://example.com/events`, + awaitedRequestCount: 1, + fulfill: { + status: 500, + contentType: 'text/plain', + body: 'Unknown error' + } + }) + + await page.goto(url) + + const result = await executeVerifyV2(page, { + responseHeaders: {}, + debug: true, + timeoutMs: 1000, + cspHostToCheck: CSP_HOST_TO_CHECK + }) + + expect(result).toEqual({ + data: { + completed: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'npm', + testEvent: { + callbackResult: { status: 500 }, + requestUrl: 'https://example.com/events', + normalizedBody: { + domain: 'example.com', + name: 'verification-agent-test', + version + }, + responseStatus: 500, + error: undefined + }, + cookieBannerLikely: false + } + }) + }) + + test('using `, + bodyContent: '' + }) + + await page.goto(url) + + const result = await executeVerifyV2(page, { + responseHeaders: {}, + debug: true, + timeoutMs: 1000, + cspHostToCheck: CSP_HOST_TO_CHECK + }) + + expect(result).toEqual({ + data: { + completed: true, + plausibleIsInitialized: true, + plausibleIsOnWindow: true, + disallowedByCsp: false, + plausibleVersion: version, + plausibleVariant: 'npm', + testEvent: { + callbackResult: { + error: expect.objectContaining({ message: 'Failed to fetch' }) + }, + requestUrl: 'invalid:/plausible.io/api/event', + normalizedBody: { + domain: 'example.com/foobar', + name: 'verification-agent-test', + version + }, + responseStatus: undefined, + error: { message: 'Failed to fetch' } + }, + cookieBannerLikely: false + } + }) + }) +}) diff --git a/tracker/test/support/installation-support-playwright-wrappers.js b/tracker/test/support/installation-support-playwright-wrappers.js deleted file mode 100644 index 02a3094edc..0000000000 --- a/tracker/test/support/installation-support-playwright-wrappers.js +++ /dev/null @@ -1,33 +0,0 @@ -import { compileFile } from '../../compiler/index.js' -import variantsFile from '../../compiler/variants.json' with { type: 'json' } - -const VERIFIER_V1_JS_VARIANT = variantsFile.manualVariants.find(variant => variant.name === 'verifier-v1.js') -const DETECTOR_JS_VARIANT = variantsFile.manualVariants.find(variant => variant.name === 'detector.js') - -export async function verify(page, context) { - const {url, expectedDataDomain} = context - const debug = context.debug ? true : false - - const verifierCode = await compileFile(VERIFIER_V1_JS_VARIANT, { returnCode: true }) - - await page.goto(url) - await page.evaluate(verifierCode) - - return await page.evaluate(async ({expectedDataDomain, debug}) => { - return await window.verifyPlausibleInstallation(expectedDataDomain, debug) - }, {expectedDataDomain, debug}) -} - -export async function detect(page, context) { - const {url, detectV1} = context - const debug = context.debug ? true : false - - const detectorCode = await compileFile(DETECTOR_JS_VARIANT, { returnCode: true }) - - await page.goto(url) - await page.evaluate(detectorCode) - - return await page.evaluate(async ({detectV1, debug}) => { - return await window.scanPageBeforePlausibleInstallation(detectV1, debug) - }, {detectV1, debug}) -} diff --git a/tracker/test/support/installation-support-playwright-wrappers.ts b/tracker/test/support/installation-support-playwright-wrappers.ts new file mode 100644 index 0000000000..6c2dd5b5e0 --- /dev/null +++ b/tracker/test/support/installation-support-playwright-wrappers.ts @@ -0,0 +1,92 @@ +import { compileFile } from '../../compiler/index.js' +import variantsFile from '../../compiler/variants.json' with { type: 'json' } +import { Page } from '@playwright/test' +import { VerifyV2Args, VerifyV2Result } from './types' + +const VERIFIER_V1_JS_VARIANT = variantsFile.manualVariants.find( + (variant) => variant.name === 'verifier-v1.js' +) +const VERIFIER_V2_JS_VARIANT = variantsFile.manualVariants.find( + (variant) => variant.name === 'verifier-v2.js' +) +const DETECTOR_JS_VARIANT = variantsFile.manualVariants.find( + (variant) => variant.name === 'detector.js' +) + +export async function executeVerifyV2( + page: Page, + { debug, responseHeaders, timeoutMs, cspHostToCheck }: VerifyV2Args +): Promise { + const verifierCode = (await compileFile(VERIFIER_V2_JS_VARIANT, { + returnCode: true + })) as string + + try { + await page.evaluate(verifierCode) + + return await page.evaluate( + async ({ responseHeaders, debug, timeoutMs, cspHostToCheck }) => { + return await (window as any).verifyPlausibleInstallation({ + responseHeaders, + debug, + timeoutMs, + cspHostToCheck + }) + }, + { responseHeaders, debug, timeoutMs, cspHostToCheck } + ) + } catch (error) { + return { + data: { + completed: false, + error: { + message: error?.message ?? JSON.stringify(error) + } + } + } + } +} + +export async function verifyV1(page, context) { + const { url, expectedDataDomain } = context + const debug = context.debug ? true : false + + const verifierCode = await compileFile(VERIFIER_V1_JS_VARIANT, { + returnCode: true + }) + + await page.goto(url) + await page.evaluate(verifierCode) + + return await page.evaluate( + async ({ expectedDataDomain, debug }) => { + return await (window as any).verifyPlausibleInstallation( + expectedDataDomain, + debug + ) + }, + { expectedDataDomain, debug } + ) +} + +export async function detect(page, context) { + const { url, detectV1 } = context + const debug = context.debug ? true : false + + const detectorCode = await compileFile(DETECTOR_JS_VARIANT, { + returnCode: true + }) + + await page.goto(url) + await page.evaluate(detectorCode) + + return await page.evaluate( + async ({ detectV1, debug }) => { + return await (window as any).scanPageBeforePlausibleInstallation( + detectV1, + debug + ) + }, + { detectV1, debug } + ) +} diff --git a/tracker/test/support/types.ts b/tracker/test/support/types.ts index 800af7cf74..ba20c215a4 100644 --- a/tracker/test/support/types.ts +++ b/tracker/test/support/types.ts @@ -11,3 +11,52 @@ export type ScriptConfig = { domain: string endpoint: string } & Partial + +export type VerifyV2Args = { + debug: boolean + responseHeaders: Record + timeoutMs: number + cspHostToCheck: string +} + +export type VerifyV2Result = { + data: + | { + completed: true + plausibleIsOnWindow: boolean + plausibleIsInitialized: boolean + plausibleVersion: number + plausibleVariant?: string + disallowedByCsp: boolean + cookieBannerLikely: boolean + testEvent: { + /** + * window.plausible (track) callback + */ + callbackResult?: any + /** + * intercepted fetch response status + */ + responseStatus?: number + /** + * error caught during intercepted fetch + */ + error?: { + message: string + } + /** + * intercepted fetch request url + */ + requestUrl?: string + /** + * intercepted fetch request body normalized + */ + normalizedBody?: { + domain: string + name: string + version?: number + } + } + } + | { completed: false; error: { message: string } } +}