From 84e37e4a093177286ede8f4a188b880a29ebd387 Mon Sep 17 00:00:00 2001 From: TheChosenOne-Sunyuchen <38416704+TheChosenOne-Sunyuchen@users.noreply.github.com> Date: Sun, 31 May 2026 21:47:20 +0800 Subject: [PATCH] fix: sanitize AI review HTML and verify iframe postMessage origin Addresses the two security findings reported in #3376. 1. ViewAiReviewView: marked.parse() output was passed straight to dangerouslySetInnerHTML. Since the markdown comes from the registry's ai_review_text response and marked does not sanitize by default, any HTML/script in that response executed in the user's DOM. The parsed HTML is now run through DOMPurify before rendering, which strips scripts and event-handler attributes while keeping safe markup (links, formatting). 2. RunFrameWithIframe: the message handler had no origin/source check and replied with postMessage(..., "*"), so any embedding page could send "runframe_ready_to_receive" and receive runFrameProps. The handler now only responds to messages whose source is our own iframe, and targets the iframe's resolved origin instead of "*". Origin resolution is extracted into resolveIframeTargetOrigin() with unit tests. --- bun.lock | 5 ++- .../AiReviewDialog/ViewAiReviewView.tsx | 4 ++- .../RunFrameWithIframe/RunFrameWithIframe.tsx | 12 ++++++- lib/utils/resolve-iframe-target-origin.ts | 20 ++++++++++++ package.json | 3 +- tests/resolve-iframe-target-origin.test.ts | 32 +++++++++++++++++++ 6 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 lib/utils/resolve-iframe-target-origin.ts create mode 100644 tests/resolve-iframe-target-origin.test.ts diff --git a/bun.lock b/bun.lock index 4ca755607..8345711c5 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@tscircuit/eval": "^0.0.874", "@tscircuit/solver-utils": "^0.0.7", + "dompurify": "^3.4.7", }, "devDependencies": { "@babel/standalone": "^7.26.10", @@ -997,7 +998,7 @@ "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], - "dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="], + "dompurify": ["dompurify@3.4.7", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2jBxDJY4RR06tQNy4w5FlFH7kfxsQZlufd0sbv+chfHCxeJwrFw2baUDsSwvBISD4K4RDbd0PTfy3uNXsR6siA=="], "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], @@ -2165,6 +2166,8 @@ "postcss-unique-selectors/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + "posthog-js/dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="], + "posthog-js/fflate": ["fflate@0.4.8", "", {}, "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA=="], "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], diff --git a/lib/components/AiReviewDialog/ViewAiReviewView.tsx b/lib/components/AiReviewDialog/ViewAiReviewView.tsx index 0be0961cb..a5a391b1a 100644 --- a/lib/components/AiReviewDialog/ViewAiReviewView.tsx +++ b/lib/components/AiReviewDialog/ViewAiReviewView.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react" import { Button } from "../ui/button" import { DialogHeader, DialogTitle } from "../ui/dialog" import { marked } from "marked" +import DOMPurify from "dompurify" import type { AiReview } from "./types" import { getRegistryKy } from "lib/utils/get-registry-ky" @@ -50,7 +51,8 @@ export const AiReviewViewView = ({ }, [aiReviewId]) const html = useMemo( - () => marked.parse(review.ai_review_text || "") as string, + () => + DOMPurify.sanitize(marked.parse(review.ai_review_text || "") as string), [review.ai_review_text], ) return ( diff --git a/lib/components/RunFrameWithIframe/RunFrameWithIframe.tsx b/lib/components/RunFrameWithIframe/RunFrameWithIframe.tsx index 9dcde34ff..bb941b76f 100644 --- a/lib/components/RunFrameWithIframe/RunFrameWithIframe.tsx +++ b/lib/components/RunFrameWithIframe/RunFrameWithIframe.tsx @@ -4,6 +4,7 @@ */ import { useEffect, useRef } from "react" import type { RunFrameProps } from "../RunFrame/RunFrameProps" +import { resolveIframeTargetOrigin } from "lib/utils/resolve-iframe-target-origin" export interface RunFrameWithIframeProps extends RunFrameProps { iframeUrl?: string @@ -16,14 +17,23 @@ export const RunFrameWithIframe = ({ const iframeRef = useRef(null) useEffect(() => { + const targetOrigin = resolveIframeTargetOrigin( + iframeUrl, + window.location.href, + ) + const handleMessage = (event: MessageEvent) => { + // Only respond to the ready signal coming from our own iframe, and never + // broadcast the props to an unknown origin. + if (event.source !== iframeRef.current?.contentWindow) return + if (!targetOrigin) return if (event.data?.runframe_type === "runframe_ready_to_receive") { iframeRef.current?.contentWindow?.postMessage( { runframe_type: "runframe_props_changed", runframe_props: runFrameProps, }, - "*", + targetOrigin, ) } } diff --git a/lib/utils/resolve-iframe-target-origin.ts b/lib/utils/resolve-iframe-target-origin.ts new file mode 100644 index 000000000..bf0a21bd4 --- /dev/null +++ b/lib/utils/resolve-iframe-target-origin.ts @@ -0,0 +1,20 @@ +/** + * Resolve the origin to use as the `targetOrigin` argument of + * `window.postMessage`. Using a specific origin (instead of "*") prevents the + * message payload from leaking to any page that happens to embed the iframe. + * + * `iframeUrl` may be absolute (e.g. "https://runframe.tscircuit.com/iframe.html") + * or relative (e.g. "/iframe.html"), so it is resolved against `baseUrl` before + * extracting the origin. Returns `null` when the URL cannot be parsed, letting + * callers decide how to handle the failure safely. + */ +export const resolveIframeTargetOrigin = ( + iframeUrl: string, + baseUrl?: string, +): string | null => { + try { + return new URL(iframeUrl, baseUrl).origin + } catch { + return null + } +} diff --git a/package.json b/package.json index f612e71cd..f37c4ec92 100644 --- a/package.json +++ b/package.json @@ -114,6 +114,7 @@ }, "dependencies": { "@tscircuit/eval": "^0.0.874", - "@tscircuit/solver-utils": "^0.0.7" + "@tscircuit/solver-utils": "^0.0.7", + "dompurify": "^3.4.7" } } diff --git a/tests/resolve-iframe-target-origin.test.ts b/tests/resolve-iframe-target-origin.test.ts new file mode 100644 index 000000000..ec36b4fee --- /dev/null +++ b/tests/resolve-iframe-target-origin.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { resolveIframeTargetOrigin } from "../lib/utils/resolve-iframe-target-origin" + +describe("resolveIframeTargetOrigin", () => { + test("returns the origin of an absolute iframe url", () => { + expect( + resolveIframeTargetOrigin("https://runframe.tscircuit.com/iframe.html"), + ).toBe("https://runframe.tscircuit.com") + }) + + test("strips path, query and hash, keeping only the origin", () => { + expect( + resolveIframeTargetOrigin("https://example.com:8080/a/b?c=1#d"), + ).toBe("https://example.com:8080") + }) + + test("resolves a relative url against the base url", () => { + expect( + resolveIframeTargetOrigin("/iframe.html", "https://app.tscircuit.com/x"), + ).toBe("https://app.tscircuit.com") + }) + + test("returns null for an unparseable url", () => { + expect(resolveIframeTargetOrigin("not a url")).toBeNull() + }) + + test("never returns the wildcard origin", () => { + expect( + resolveIframeTargetOrigin("https://runframe.tscircuit.com/iframe.html"), + ).not.toBe("*") + }) +})