From 4dd20799363b8d24a4da13aadc18219058ffaf40 Mon Sep 17 00:00:00 2001 From: "devintern-internal[bot]" <4622575+devintern-internal[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:23:16 +0700 Subject: [PATCH 1/5] feat: implement DEV-73 - Display update release notes as safe, formatted content --- .../renderer/src/components/ReleaseNotes.tsx | 196 ++++++++++++++++++ .../src/components/UpdateNotifier.test.tsx | 93 +++++++++ .../src/components/UpdateNotifier.tsx | 15 +- 3 files changed, 293 insertions(+), 11 deletions(-) create mode 100644 packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx diff --git a/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx b/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx new file mode 100644 index 0000000..f1ca58d --- /dev/null +++ b/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx @@ -0,0 +1,196 @@ +import { createElement } from "react"; +import type { MouseEvent, ReactNode } from "react"; + +const DROPPED_TAGS = new Set([ + "applet", + "audio", + "base", + "button", + "canvas", + "embed", + "form", + "frame", + "frameset", + "iframe", + "img", + "input", + "link", + "math", + "meta", + "noscript", + "object", + "picture", + "plaintext", + "script", + "select", + "source", + "style", + "svg", + "template", + "textarea", + "track", + "video", + "xmp", +]); + +const TAG_CLASSES: Record = { + article: "space-y-1.5", + blockquote: "my-2 border-l-2 border-border pl-3 italic text-muted-foreground", + code: "rounded bg-muted px-1 py-0.5 font-mono text-[0.9em]", + del: "text-muted-foreground line-through", + div: "space-y-1.5", + em: "italic", + h1: "mt-2 text-base font-semibold text-foreground", + h2: "mt-2 text-sm font-semibold text-foreground", + h3: "mt-2 text-sm font-medium text-foreground", + h4: "mt-1.5 text-xs font-medium text-foreground", + h5: "mt-1.5 text-xs font-medium text-foreground", + h6: "mt-1.5 text-xs font-medium text-foreground", + li: "pl-0.5", + ol: "my-1.5 list-decimal space-y-0.5 pl-5", + p: "my-1.5", + pre: "my-2 overflow-x-auto rounded-md bg-muted p-2 font-mono text-xs", + section: "space-y-1.5", + strong: "font-semibold text-foreground", + table: "my-2 w-full border-collapse text-left text-xs", + td: "border border-border px-2 py-1 align-top", + th: "border border-border bg-muted px-2 py-1 font-medium", + ul: "my-1.5 list-disc space-y-0.5 pl-5", +}; + +const ALLOWED_TAGS = new Set([ + ...Object.keys(TAG_CLASSES), + "a", + "b", + "br", + "hr", + "i", + "s", + "span", + "tbody", + "thead", + "tr", + "u", +]); + +/** Release-note links are external, so only absolute HTTP(S) URLs are usable. */ +export function isSafeReleaseNotesUrl(value: string): boolean { + const trimmed = value.trim(); + if (!/^https?:\/\//i.test(trimmed)) return false; + try { + const url = new URL(trimmed); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +interface RenderResult { + meaningful: boolean; + nodes: ReactNode[]; +} + +function renderNodes(nodes: NodeListOf, keyPrefix: string): RenderResult { + const output: ReactNode[] = []; + let meaningful = false; + + for (const [index, node] of Array.from(nodes).entries()) { + const key = `${keyPrefix}-${index}`; + if (node.nodeType === 3) { + const text = node.textContent ?? ""; + output.push(text); + meaningful ||= text.trim().length > 0; + continue; + } + if (node.nodeType !== 1) continue; + + const element = node as Element; + const tag = element.localName.toLowerCase(); + if (DROPPED_TAGS.has(tag)) continue; + + const children = renderNodes(element.childNodes, key); + if (!ALLOWED_TAGS.has(tag)) { + output.push(...children.nodes); + meaningful ||= children.meaningful; + continue; + } + + if (tag === "br") { + output.push(createElement("br", { key })); + continue; + } + if (tag === "hr") { + output.push(createElement("hr", { className: "my-2 border-border", key })); + meaningful = true; + continue; + } + if (tag === "a") { + const href = element.getAttribute("href")?.trim() ?? ""; + if (!isSafeReleaseNotesUrl(href)) { + output.push(...children.nodes); + meaningful ||= children.meaningful; + continue; + } + const onClick = (event: MouseEvent) => { + event.preventDefault(); + void window.pm.openExternal(href); + }; + output.push( + createElement( + "a", + { + className: "text-primary underline underline-offset-2 hover:text-primary/80", + href, + key, + onClick, + rel: "noreferrer noopener", + target: "_blank", + title: element.getAttribute("title") ?? undefined, + }, + children.nodes, + ), + ); + meaningful ||= children.meaningful; + continue; + } + + const normalizedTag = tag === "b" ? "strong" : tag === "i" ? "em" : tag; + output.push( + createElement(normalizedTag, { className: TAG_CLASSES[normalizedTag], key }, children.nodes), + ); + meaningful ||= children.meaningful; + } + + return { meaningful, nodes: output }; +} + +function renderReleaseNotes(html: string | null | undefined): RenderResult { + if (!html?.trim()) return { meaningful: false, nodes: [] }; + + try { + // A template parses malformed HTML using the browser's normal recovery + // rules, but its contents stay inert and are never mounted as raw HTML. + const template = document.createElement("template"); + template.innerHTML = html; + return renderNodes(template.content.childNodes, "release-note"); + } catch { + return { meaningful: false, nodes: [] }; + } +} + +export function ReleaseNotes({ html }: { html: string | null | undefined }) { + const rendered = renderReleaseNotes(html); + + return ( +
+ {rendered.meaningful ? ( + rendered.nodes + ) : ( +

Release notes are unavailable.

+ )} +
+ ); +} diff --git a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx index 7e74dd8..a37589e 100644 --- a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx +++ b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx @@ -192,6 +192,7 @@ describe("UpdateNotifier against fake window.pm", () => { let installUpdate: ReturnType; let dismissUpdateError: ReturnType; let checkForUpdates: ReturnType; + let openExternal: ReturnType; const available: UpdateStatus = { phase: "available", @@ -213,6 +214,7 @@ describe("UpdateNotifier against fake window.pm", () => { installUpdate = mock(async () => ok({ ...available, phase: "downloaded" as const })); dismissUpdateError = mock(async () => ok(available)); checkForUpdates = mock(async () => ok(available)); + openExternal = mock(async () => ok(null)); getUpdateStatus = mock(async () => ok(available)); const pm = { @@ -223,6 +225,7 @@ describe("UpdateNotifier against fake window.pm", () => { installUpdate, dismissUpdateError, checkForUpdates, + openExternal, } as unknown as PmDesktopApi; (domWindow as unknown as { pm: PmDesktopApi }).pm = pm; (globalThis.window as unknown as { pm: PmDesktopApi }).pm = pm; @@ -303,6 +306,96 @@ describe("UpdateNotifier against fake window.pm", () => { expect(snoozeUpdate).toHaveBeenCalledTimes(1); }); + test("renders formatted release notes, strips unsafe content, and opens safe links", async () => { + const notesStatus: UpdateStatus = { + ...available, + releaseNotes: ` +

Highlights

+

Fixed ticket sync. +

  • Faster startup
+ Full notes + Unsafe link + + + + `, + }; + getUpdateStatus.mockImplementation(async () => ok(notesStatus)); + queryClient.setQueryData(qk.updateStatus, notesStatus); + + await act(async () => { + root.render( + withQueryClient(createElement(UpdateNotifier, { hasBusyWork: false }), queryClient), + ); + await flushMicrotasks(); + }); + await act(async () => { + await flushMicrotasks(); + }); + + const notes = domWindow.document.querySelector('[data-testid="update-notifier-notes"]'); + expect(notes?.querySelector("h2")?.textContent).toBe("Highlights"); + expect(notes?.querySelector("strong")?.textContent).toBe("ticket sync"); + expect(notes?.querySelector("li")?.textContent).toBe("Faster startup"); + expect(notes?.querySelector("script, img, iframe")).toBeNull(); + expect(notes?.textContent).not.toContain("window.pwned"); + expect(notes?.textContent).not.toContain("embedded content"); + + const links = notes?.querySelectorAll("a"); + expect(links?.length).toBe(1); + expect(notes?.textContent).toContain("Unsafe link"); + expect(links?.[0]?.getAttribute("onclick")).toBeNull(); + expect(links?.[0]?.getAttribute("onmouseover")).toBeNull(); + expect(links?.[0]?.getAttribute("rel")).toBe("noreferrer noopener"); + + const safeLink = links?.[0]; + if (!safeLink) throw new Error("Expected a safe release-note link"); + await act(async () => { + safeLink.click(); + await flushMicrotasks(); + }); + expect(openExternal).toHaveBeenCalledWith("https://example.com/releases/0.3.0"); + }); + + test("shows a fallback when release notes are missing or fully removed", async () => { + const missingNotesStatus: UpdateStatus = { ...available, releaseNotes: undefined }; + getUpdateStatus.mockImplementation(async () => ok(missingNotesStatus)); + queryClient.setQueryData(qk.updateStatus, missingNotesStatus); + + await act(async () => { + root.render( + withQueryClient(createElement(UpdateNotifier, { hasBusyWork: false }), queryClient), + ); + await flushMicrotasks(); + }); + await act(async () => { + await flushMicrotasks(); + }); + + expect( + domWindow.document.querySelector('[data-testid="update-notifier-notes"]')?.textContent, + ).toBe("Release notes are unavailable."); + + const unsafeNotesStatus: UpdateStatus = { + ...available, + releaseNotes: "", + }; + await act(async () => { + queryClient.setQueryData(qk.updateStatus, unsafeNotesStatus); + await flushMicrotasks(); + }); + + expect( + domWindow.document.querySelector('[data-testid="update-notifier-notes"]')?.textContent, + ).toBe("Release notes are unavailable."); + expect( + domWindow.document.querySelector('[data-testid="update-notifier-install"]'), + ).not.toBeNull(); + expect( + domWindow.document.querySelector('[data-testid="update-notifier-later"]'), + ).not.toBeNull(); + }); + test("retry on error calls downloadUpdate when a version is available", async () => { const errorStatus: UpdateStatus = { phase: "error", diff --git a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.tsx b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.tsx index 8c75d35..8f9158f 100644 --- a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.tsx +++ b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.tsx @@ -19,19 +19,13 @@ import { } from "@/components/ui/dialog"; import { qk } from "../queries/keys.ts"; import { useUpdateStatus } from "../queries/useUpdateStatus.ts"; +import { ReleaseNotes } from "./ReleaseNotes.tsx"; export interface UpdateNotifierProps { /** True when any ticket has an agent/tracker operation in flight. */ hasBusyWork: boolean; } -function shortNotes(notes: string | null | undefined): string | null { - if (!notes) return null; - const trimmed = notes.replace(/\s+/g, " ").trim(); - if (!trimmed) return null; - return trimmed.length > 180 ? `${trimmed.slice(0, 177)}…` : trimmed; -} - /** Whether the status should show the interruptive update dialog. */ export function shouldShowUpdateDialog(status: UpdateStatus): boolean { if (status.phase === "disabled") return false; @@ -69,7 +63,6 @@ export function UpdateNotifier({ hasBusyWork }: UpdateNotifierProps) { return null; } - const notes = shortNotes(status.releaseNotes); const versionLine = status.availableVersion != null ? formatUpdateAvailableMessage(status.availableVersion, status.currentVersion) @@ -133,8 +126,8 @@ export function UpdateNotifier({ hasBusyWork }: UpdateNotifierProps) { > {title} - -
+ +
{status.phase === "error" && status.errorMessage ? (

{status.errorMessage}

) : status.phase === "downloading" ? ( @@ -142,7 +135,7 @@ export function UpdateNotifier({ hasBusyWork }: UpdateNotifierProps) { ) : ( <> {versionLine &&

{versionLine}

} - {notes &&

{notes}

} + {status.phase === "downloaded" && (

Restart to apply the update. Your settings and project preference are kept. From e5cd20c494f84394b178b62958ecfef4bccaf808 Mon Sep 17 00:00:00 2001 From: "devintern-internal[bot]" <4622575+devintern-internal[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:27:51 +0700 Subject: [PATCH 2/5] fix: address PR review feedback - Bound release-note HTML length before parsing.\n- Enforce traversal depth and node budgets with safe fallback behavior.\n- Cover oversized, deeply nested, and high-node-count release notes. --- .../renderer/src/components/ReleaseNotes.tsx | 42 +++++++++++++++++-- .../src/components/UpdateNotifier.test.tsx | 27 ++++++++++++ 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx b/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx index f1ca58d..e4cd04a 100644 --- a/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx +++ b/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx @@ -73,6 +73,10 @@ const ALLOWED_TAGS = new Set([ "u", ]); +const MAX_RELEASE_NOTES_HTML_LENGTH = 20_000; +const MAX_RELEASE_NOTES_DEPTH = 24; +const MAX_RELEASE_NOTES_NODES = 1_000; + /** Release-note links are external, so only absolute HTTP(S) URLs are usable. */ export function isSafeReleaseNotesUrl(value: string): boolean { const trimmed = value.trim(); @@ -90,11 +94,34 @@ interface RenderResult { nodes: ReactNode[]; } -function renderNodes(nodes: NodeListOf, keyPrefix: string): RenderResult { +interface RenderBudget { + exceeded: boolean; + remainingNodes: number; +} + +function renderNodes( + nodes: NodeListOf, + keyPrefix: string, + depth: number, + budget: RenderBudget, +): RenderResult { const output: ReactNode[] = []; let meaningful = false; - for (const [index, node] of Array.from(nodes).entries()) { + if (depth > MAX_RELEASE_NOTES_DEPTH) { + budget.exceeded = true; + return { meaningful, nodes: output }; + } + + for (let index = 0; index < nodes.length; index++) { + if (budget.remainingNodes === 0) { + budget.exceeded = true; + break; + } + budget.remainingNodes--; + + const node = nodes[index]; + if (!node) continue; const key = `${keyPrefix}-${index}`; if (node.nodeType === 3) { const text = node.textContent ?? ""; @@ -108,7 +135,8 @@ function renderNodes(nodes: NodeListOf, keyPrefix: string): RenderRes const tag = element.localName.toLowerCase(); if (DROPPED_TAGS.has(tag)) continue; - const children = renderNodes(element.childNodes, key); + const children = renderNodes(element.childNodes, key, depth + 1, budget); + if (budget.exceeded) break; if (!ALLOWED_TAGS.has(tag)) { output.push(...children.nodes); meaningful ||= children.meaningful; @@ -166,13 +194,19 @@ function renderNodes(nodes: NodeListOf, keyPrefix: string): RenderRes function renderReleaseNotes(html: string | null | undefined): RenderResult { if (!html?.trim()) return { meaningful: false, nodes: [] }; + if (html.length > MAX_RELEASE_NOTES_HTML_LENGTH) return { meaningful: false, nodes: [] }; try { // A template parses malformed HTML using the browser's normal recovery // rules, but its contents stay inert and are never mounted as raw HTML. const template = document.createElement("template"); template.innerHTML = html; - return renderNodes(template.content.childNodes, "release-note"); + const budget: RenderBudget = { + exceeded: false, + remainingNodes: MAX_RELEASE_NOTES_NODES, + }; + const rendered = renderNodes(template.content.childNodes, "release-note", 0, budget); + return budget.exceeded ? { meaningful: false, nodes: [] } : rendered; } catch { return { meaningful: false, nodes: [] }; } diff --git a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx index a37589e..e009a06 100644 --- a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx +++ b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx @@ -79,6 +79,7 @@ mock.module("@/components/ui/button", () => { const { formatDownloadLabel, shouldShowUpdateDialog, UpdateNotifier } = await import("./UpdateNotifier.tsx"); +const { ReleaseNotes } = await import("./ReleaseNotes.tsx"); function ok(value: T): IpcResult { return { ok: true, value }; @@ -396,6 +397,32 @@ describe("UpdateNotifier against fake window.pm", () => { ).not.toBeNull(); }); + test("bounds release-note input size, nesting depth, and rendered node count", async () => { + await act(async () => { + root.render(createElement(ReleaseNotes, { html: `

${"x".repeat(25_000)}

` })); + await flushMicrotasks(); + }); + + const notes = domWindow.document.querySelector('[data-testid="update-notifier-notes"]'); + expect(notes?.textContent).toBe("Release notes are unavailable."); + + await act(async () => { + root.render( + createElement(ReleaseNotes, { + html: `${"
".repeat(30)}Deep${"
".repeat(30)}`, + }), + ); + await flushMicrotasks(); + }); + expect(notes?.textContent).toBe("Release notes are unavailable."); + + await act(async () => { + root.render(createElement(ReleaseNotes, { html: "x".repeat(600) })); + await flushMicrotasks(); + }); + expect(notes?.textContent).toBe("Release notes are unavailable."); + }); + test("retry on error calls downloadUpdate when a version is available", async () => { const errorStatus: UpdateStatus = { phase: "error", From 71e8e1422c3064c5e415333d27ac9c1d95d3c04f Mon Sep 17 00:00:00 2001 From: "devintern-internal[bot]" <4622575+devintern-internal[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:34:33 +0700 Subject: [PATCH 3/5] chore(pm-desktop): bump version to 0.9.12 --- packages/pm-desktop/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pm-desktop/package.json b/packages/pm-desktop/package.json index 42c7ed8..2392a1f 100644 --- a/packages/pm-desktop/package.json +++ b/packages/pm-desktop/package.json @@ -1,7 +1,7 @@ { "name": "@devintern/pm-desktop", "productName": "DevIntern PM", - "version": "0.9.11", + "version": "0.9.12", "private": true, "description": "Desktop app for @devintern/pm — multi-ticket AI task creation for your tracker.", "author": "DevIntern ", From c73d87b88b01741eccb8a4d7a5229c71814db5b2 Mon Sep 17 00:00:00 2001 From: "devintern-internal[bot]" <4622575+devintern-internal[bot]@users.noreply.github.com> Date: Tue, 18 Aug 2026 16:42:27 +0700 Subject: [PATCH 4/5] fix: address PR review feedback Refresh bun.lock workspace manifest metadata after the pm-desktop version bump. Consolidate the React import to satisfy import/no-duplicates. Remove native navigation semantics from release-note links and add regression coverage. --- bun.lock | 5 ++-- .../renderer/src/components/ReleaseNotes.tsx | 30 ++++++++++--------- .../src/components/UpdateNotifier.test.tsx | 7 +++-- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/bun.lock b/bun.lock index e7a8188..3129342 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "devintern", @@ -35,7 +36,7 @@ }, "packages/code": { "name": "@getdevintern/code", - "version": "2.3.1", + "version": "2.3.2", "bin": { "devintern": "dist/index.js", }, @@ -141,7 +142,7 @@ }, "packages/pm-desktop": { "name": "@devintern/pm-desktop", - "version": "0.9.9", + "version": "0.9.12", "dependencies": { "@base-ui/react": "^1.7.0", "@devintern/agent-harness": "workspace:*", diff --git a/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx b/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx index e4cd04a..7dc84d8 100644 --- a/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx +++ b/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx @@ -1,5 +1,4 @@ -import { createElement } from "react"; -import type { MouseEvent, ReactNode } from "react"; +import React from "react"; const DROPPED_TAGS = new Set([ "applet", @@ -91,7 +90,7 @@ export function isSafeReleaseNotesUrl(value: string): boolean { interface RenderResult { meaningful: boolean; - nodes: ReactNode[]; + nodes: React.ReactNode[]; } interface RenderBudget { @@ -105,7 +104,7 @@ function renderNodes( depth: number, budget: RenderBudget, ): RenderResult { - const output: ReactNode[] = []; + const output: React.ReactNode[] = []; let meaningful = false; if (depth > MAX_RELEASE_NOTES_DEPTH) { @@ -144,11 +143,11 @@ function renderNodes( } if (tag === "br") { - output.push(createElement("br", { key })); + output.push(React.createElement("br", { key })); continue; } if (tag === "hr") { - output.push(createElement("hr", { className: "my-2 border-border", key })); + output.push(React.createElement("hr", { className: "my-2 border-border", key })); meaningful = true; continue; } @@ -159,21 +158,20 @@ function renderNodes( meaningful ||= children.meaningful; continue; } - const onClick = (event: MouseEvent) => { + const onClick = (event: React.MouseEvent) => { event.preventDefault(); void window.pm.openExternal(href); }; output.push( - createElement( - "a", + React.createElement( + "button", { - className: "text-primary underline underline-offset-2 hover:text-primary/80", - href, + className: + "cursor-pointer border-0 bg-transparent p-0 text-primary underline underline-offset-2 hover:text-primary/80", key, onClick, - rel: "noreferrer noopener", - target: "_blank", title: element.getAttribute("title") ?? undefined, + type: "button", }, children.nodes, ), @@ -184,7 +182,11 @@ function renderNodes( const normalizedTag = tag === "b" ? "strong" : tag === "i" ? "em" : tag; output.push( - createElement(normalizedTag, { className: TAG_CLASSES[normalizedTag], key }, children.nodes), + React.createElement( + normalizedTag, + { className: TAG_CLASSES[normalizedTag], key }, + children.nodes, + ), ); meaningful ||= children.meaningful; } diff --git a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx index e009a06..dc519e5 100644 --- a/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx +++ b/packages/pm-desktop/src/renderer/src/components/UpdateNotifier.test.tsx @@ -307,7 +307,7 @@ describe("UpdateNotifier against fake window.pm", () => { expect(snoozeUpdate).toHaveBeenCalledTimes(1); }); - test("renders formatted release notes, strips unsafe content, and opens safe links", async () => { + test("renders formatted release notes and opens safe links without native navigation", async () => { const notesStatus: UpdateStatus = { ...available, releaseNotes: ` @@ -342,12 +342,13 @@ describe("UpdateNotifier against fake window.pm", () => { expect(notes?.textContent).not.toContain("window.pwned"); expect(notes?.textContent).not.toContain("embedded content"); - const links = notes?.querySelectorAll("a"); + const links = notes?.querySelectorAll("button"); expect(links?.length).toBe(1); + expect(notes?.querySelector("a")).toBeNull(); expect(notes?.textContent).toContain("Unsafe link"); expect(links?.[0]?.getAttribute("onclick")).toBeNull(); expect(links?.[0]?.getAttribute("onmouseover")).toBeNull(); - expect(links?.[0]?.getAttribute("rel")).toBe("noreferrer noopener"); + expect(links?.[0]?.getAttribute("type")).toBe("button"); const safeLink = links?.[0]; if (!safeLink) throw new Error("Expected a safe release-note link"); From b302d6b6e9237254beadf85b108b97fb40297e2f Mon Sep 17 00:00:00 2001 From: "devintern-internal[bot]" <4622575+devintern-internal[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:45:43 +0700 Subject: [PATCH 5/5] fix(pm-desktop): sanitize release notes with DOMPurify --- bun.lock | 3 ++ packages/pm-desktop/package.json | 1 + .../renderer/src/components/ReleaseNotes.tsx | 35 +++++++++++-------- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/bun.lock b/bun.lock index 3129342..c49738a 100644 --- a/bun.lock +++ b/bun.lock @@ -153,6 +153,7 @@ "@tanstack/react-query": "^5.101.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "3.3.0", "electron-updater": "^6.6.2", "lucide-react": "^1.14.0", "posthog-node": "^5.48.0", @@ -1204,6 +1205,8 @@ "dmg-builder": ["dmg-builder@26.15.3", "", { "dependencies": { "app-builder-lib": "26.15.3", "builder-util": "26.15.3", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0" } }, "sha512-O3zJUFUYHJKgzPqioHxfxzBzlSC1eXCSr79gMSBKBP5AgjjpmrydMsMLotEg9fAJF36vdUncb+4ndRNxoPdlSQ=="], + "dompurify": ["dompurify@3.3.0", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ=="], + "dot-prop": ["dot-prop@6.0.1", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA=="], "dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], diff --git a/packages/pm-desktop/package.json b/packages/pm-desktop/package.json index 2392a1f..601cda5 100644 --- a/packages/pm-desktop/package.json +++ b/packages/pm-desktop/package.json @@ -39,6 +39,7 @@ "@tanstack/react-query": "^5.101.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dompurify": "3.3.0", "electron-updater": "^6.6.2", "lucide-react": "^1.14.0", "posthog-node": "^5.48.0", diff --git a/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx b/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx index 7dc84d8..07aa179 100644 --- a/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx +++ b/packages/pm-desktop/src/renderer/src/components/ReleaseNotes.tsx @@ -1,6 +1,7 @@ import React from "react"; +import DOMPurify from "dompurify"; -const DROPPED_TAGS = new Set([ +const DROPPED_TAGS = [ "applet", "audio", "base", @@ -30,7 +31,7 @@ const DROPPED_TAGS = new Set([ "track", "video", "xmp", -]); +]; const TAG_CLASSES: Record = { article: "space-y-1.5", @@ -57,7 +58,7 @@ const TAG_CLASSES: Record = { ul: "my-1.5 list-disc space-y-0.5 pl-5", }; -const ALLOWED_TAGS = new Set([ +const ALLOWED_TAGS = [ ...Object.keys(TAG_CLASSES), "a", "b", @@ -70,7 +71,9 @@ const ALLOWED_TAGS = new Set([ "thead", "tr", "u", -]); +]; + +const ALLOWED_TAG_SET = new Set(ALLOWED_TAGS); const MAX_RELEASE_NOTES_HTML_LENGTH = 20_000; const MAX_RELEASE_NOTES_DEPTH = 24; @@ -132,15 +135,12 @@ function renderNodes( const element = node as Element; const tag = element.localName.toLowerCase(); - if (DROPPED_TAGS.has(tag)) continue; + // DOMPurify owns sanitization; ignore anything outside its configured + // output contract rather than attempting to render it. + if (!ALLOWED_TAG_SET.has(tag)) continue; const children = renderNodes(element.childNodes, key, depth + 1, budget); if (budget.exceeded) break; - if (!ALLOWED_TAGS.has(tag)) { - output.push(...children.nodes); - meaningful ||= children.meaningful; - continue; - } if (tag === "br") { output.push(React.createElement("br", { key })); @@ -199,15 +199,20 @@ function renderReleaseNotes(html: string | null | undefined): RenderResult { if (html.length > MAX_RELEASE_NOTES_HTML_LENGTH) return { meaningful: false, nodes: [] }; try { - // A template parses malformed HTML using the browser's normal recovery - // rules, but its contents stay inert and are never mounted as raw HTML. - const template = document.createElement("template"); - template.innerHTML = html; + const sanitized = DOMPurify(window).sanitize(html, { + ALLOWED_ATTR: ["href", "title"], + ALLOWED_TAGS, + ALLOW_ARIA_ATTR: false, + ALLOW_DATA_ATTR: false, + FORBID_CONTENTS: DROPPED_TAGS, + FORBID_TAGS: DROPPED_TAGS, + RETURN_DOM_FRAGMENT: true, + }); const budget: RenderBudget = { exceeded: false, remainingNodes: MAX_RELEASE_NOTES_NODES, }; - const rendered = renderNodes(template.content.childNodes, "release-note", 0, budget); + const rendered = renderNodes(sanitized.childNodes, "release-note", 0, budget); return budget.exceeded ? { meaningful: false, nodes: [] } : rendered; } catch { return { meaningful: false, nodes: [] };