From b0246780f3e77e738bc197f905f780e95b16821c Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 2 Aug 2026 22:01:35 -0700 Subject: [PATCH 1/2] fix(analytics): carry Clicky site ID on the loader's data-id attribute The deployed CSP restricts script-src to 'self' plus allowlisted origins, so the inline clicky_site_ids initializer was blocked on every page load and Clicky never received a site ID. Clicky's loader reads document.currentScript.getAttribute("data-id") itself, so the ID now rides on the async loader tag and no inline script is injected. Legacy inline initializers are stripped unconditionally from served documents. --- .../javachat/web/ClickyAnalyticsInjector.java | 40 +++++++++++-------- .../javachat/web/SeoControllerTest.java | 20 ++++++++++ 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java b/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java index 5b53dca6..a3ca10a9 100644 --- a/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java +++ b/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java @@ -7,11 +7,14 @@ import org.springframework.stereotype.Component; /** - * Injects or removes Clicky analytics script tags from server-rendered HTML documents. + * Injects or removes the Clicky analytics loader in server-rendered HTML documents. * - *

When Clicky analytics is enabled, this component appends the site-ID initializer - * and the async script loader to the document {@code }. When disabled, it strips - * any existing Clicky tags to prevent double-injection from cached templates. + *

The site ID rides on the loader tag's {@code data-id} attribute instead of an + * inline initializer script: the deployed CSP restricts {@code script-src} to + * {@code 'self'} plus allowlisted origins, so any inline script is blocked and would + * leave Clicky without a site ID. Clicky's loader reads the attribute itself via + * {@code document.currentScript.getAttribute("data-id")} and pushes it into + * {@code clicky_site_ids} (verified against https://static.getclicky.com/js). * *

Owns all Clicky-specific DOM mutations so that controllers remain free of * analytics concerns. @@ -20,8 +23,7 @@ public class ClickyAnalyticsInjector { private static final String CLICKY_SCRIPT_URL = "https://static.getclicky.com/js"; - private static final String CLICKY_INITIALIZER_TEMPLATE = - "var clicky_site_ids = clicky_site_ids || []; clicky_site_ids.push(%d);"; + private static final String CLICKY_SITE_ID_ATTRIBUTE = "data-id"; private final boolean clickyEnabled; private final long clickySiteId; @@ -37,34 +39,38 @@ public ClickyAnalyticsInjector(AppProperties appProperties) { } /** - * Applies Clicky analytics to the document: injects tags when enabled, removes them when disabled. + * Applies Clicky analytics to the document: ensures a CSP-safe loader tag when enabled, + * removes all Clicky tags when disabled. Legacy inline {@code clicky_site_ids} + * initializers are stripped unconditionally because the CSP blocks them either way. * * @param document the Jsoup document whose {@code } will be modified in place */ public void applyTo(Document document) { + removeLegacyInitializers(document); Element existingClickyLoader = document.head().selectFirst("script[src=\"" + CLICKY_SCRIPT_URL + "\"]"); if (!clickyEnabled) { - removeClickyTags(document, existingClickyLoader); + if (existingClickyLoader != null) { + existingClickyLoader.remove(); + } return; } if (existingClickyLoader != null) { + existingClickyLoader.attr(CLICKY_SITE_ID_ATTRIBUTE, Long.toString(clickySiteId)); return; } - String initializer = String.format(CLICKY_INITIALIZER_TEMPLATE, clickySiteId); - document.head().appendElement("script").text(initializer); - document.head().appendElement("script").attr("async", "").attr("src", CLICKY_SCRIPT_URL); + document.head() + .appendElement("script") + .attr("async", "") + .attr(CLICKY_SITE_ID_ATTRIBUTE, Long.toString(clickySiteId)) + .attr("src", CLICKY_SCRIPT_URL); } - private void removeClickyTags(Document document, Element existingLoader) { - if (existingLoader != null) { - existingLoader.remove(); - } + private void removeLegacyInitializers(Document document) { document.head().select("script").forEach(scriptTag -> { - String scriptBody = scriptTag.html(); - if (scriptBody != null && scriptBody.contains("clicky_site_ids")) { + if (scriptTag.html().contains("clicky_site_ids")) { scriptTag.remove(); } }); diff --git a/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java b/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java index f93d45c3..f32bf267 100644 --- a/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java @@ -1,7 +1,9 @@ package com.williamcallahan.javachat.web; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -95,6 +97,24 @@ void serves_contact_with_specific_metadata() throws Exception { assertMetaContent(htmlDocument, "property", "og:url", "https://example.com/contact"); } + /** + * The deployed CSP blocks inline scripts, so the Clicky site ID must travel on the + * loader tag's {@code data-id} attribute — never as an inline initializer script. + */ + @Test + void injects_clicky_loader_without_inline_initializer() throws Exception { + Document htmlDocument = loadSeoDocument("/"); + + Element clickyLoader = htmlDocument.head().selectFirst("script[src=\"https://static.getclicky.com/js\"]"); + assertNotNull(clickyLoader, "Missing Clicky loader script tag"); + assertEquals("101501246", clickyLoader.attr("data-id")); + assertTrue(clickyLoader.hasAttr("async"), "Clicky loader must load asynchronously"); + + boolean inlineInitializerPresent = htmlDocument.head().select("script").stream() + .anyMatch(scriptTag -> scriptTag.html().contains("clicky_site_ids")); + assertFalse(inlineInitializerPresent, "Inline Clicky initializer violates the CSP"); + } + private Document loadSeoDocument(String path) throws Exception { MvcResult mvcOutcome = mvc.perform(get(path)).andExpect(status().isOk()).andReturn(); return Jsoup.parse(mvcOutcome.getResponse().getContentAsString()); From 73a0f790f371fe6e0f3aeb6e53d4528e9d4550b5 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sun, 2 Aug 2026 22:01:52 -0700 Subject: [PATCH 2/2] fix(frontend): run Zod in jitless mode under the eval-less CSP The deployed CSP has no 'unsafe-eval', so Zod's JIT fast path can never activate; its cached allowsEval probe still called new Function("") once per page load, which browsers surface as a securitypolicyviolation even though Zod swallows the throw. Configuring jitless before any schema is built skips the probe and keeps parsing on the interpreter path the CSP already forces. --- frontend/src/lib/validation/schemas.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/frontend/src/lib/validation/schemas.ts b/frontend/src/lib/validation/schemas.ts index 11da4795..2f3d11f5 100644 --- a/frontend/src/lib/validation/schemas.ts +++ b/frontend/src/lib/validation/schemas.ts @@ -9,6 +9,13 @@ import { z } from "zod/v4"; +// The deployed CSP (`app.content-security-policy`) has no 'unsafe-eval', so +// Zod's JIT fast path can never activate; without `jitless` its cached +// `allowsEval` probe calls `new Function("")` once per page load, which the +// browser reports as a `securitypolicyviolation` even though Zod swallows the +// throw (zod/v4/core/util.cjs `allowsEval`). +z.config({ jitless: true }); + // ============================================================================= // SSE Stream Event Schemas // =============================================================================