From c7f443662f87727458cab7bfb2d875ee99a5aeb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 6 Aug 2026 14:36:13 +0200 Subject: [PATCH] Escape JSON-LD payloads before inlining them in a script tag (#23865) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the one code scanner alert of the three that turned out to be a real vulnerability. ## The problem `JsonLd` inlined `JSON.stringify` output straight into a `` closes the tag and everything after it is parsed as markup. `JSON.stringify` does not escape it. ## Why it is reachable The breadcrumb payloads are not all static. Two values come from outside the repo: - `app.name` on `/apps/[slug]` — served by the marketplace API, which syncs it from the `displayName` field of an npm package manifest (`marketplace-catalog-sync.service.ts`). - `partner.name` on `/partners/profile/[slug]` — served by the partners API from partner-submitted profiles. Listing and vetting gate both, but that is a human review step, not an escaping control. There is no CSP backstop either: `next.config.ts` only sets `frame-ancestors 'none'`, no `script-src`. Everywhere else these names render as React text and are escaped. This was the only raw sink in `twenty-website`. ## Verification Rendered the exact markup the component emits in headless Chromium with a name of `Evil App``<img src=x onerror=...>'``: - before: the `ld+json` block is terminated early, an `` element is created, and the handler runs (page title changes). - after: the script block stays intact, no element is created, and `JSON.parse` of the payload deep-equals the input. ## The fix Escape `<`, `>` and `&` as JSON unicode sequences (`<` and friends). They parse back to the identical string, so consumers see unchanged structured data, but nothing in the payload can start a tag or a comment. U+2028/U+2029 are deliberately not escaped: they matter when a payload lands in a JavaScript context, and this one is parsed as JSON. Leaving them out keeps the source pure ASCII rather than carrying invisible separators. Covered by unit tests for the breakout attempt, the comment-opening case, round-trip equality, and the untouched-payload case. --- _Generated by [Claude Code](https://claude.ai/code/session_018sRaaxTucSdufjk6txdQE9)_ Review in cubic --- .../src/platform/seo/JsonLd.tsx | 4 ++- .../platform/seo/serialize-json-ld.test.ts | 35 +++++++++++++++++++ .../src/platform/seo/serialize-json-ld.ts | 18 ++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-website/src/platform/seo/serialize-json-ld.test.ts create mode 100644 packages/twenty-website/src/platform/seo/serialize-json-ld.ts diff --git a/packages/twenty-website/src/platform/seo/JsonLd.tsx b/packages/twenty-website/src/platform/seo/JsonLd.tsx index 5cc222ae4c..ccaaa7f531 100644 --- a/packages/twenty-website/src/platform/seo/JsonLd.tsx +++ b/packages/twenty-website/src/platform/seo/JsonLd.tsx @@ -1,3 +1,5 @@ +import { serializeJsonLd } from './serialize-json-ld'; + export type JsonLdProps = { data: Record; }; @@ -6,7 +8,7 @@ export function JsonLd({ data }: JsonLdProps) { return ( ', + }); + + expect(serialized).not.toContain(' { + expect(serializeJsonLd({ name: '