From bdd70e7b903e9a839317afc4890d61dca470f21e Mon Sep 17 00:00:00 2001 From: Vijay Patil Date: Fri, 29 May 2026 17:15:10 +0530 Subject: [PATCH 1/4] security(xss): sanitize AI response summary inside AIMentorWidget --- package-lock.json | 17 ++++++++++++++--- package.json | 4 +++- src/components/AIMentorWidget.tsx | 10 +++++++--- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index ec3d08949..bcbc29f2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@vercel/speed-insights": "^2.0.0", "clsx": "^2.1.1", "date-fns": "^3.6.0", + "dompurify": "^3.1.6", "html-to-image": "^1.11.13", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.7", @@ -33,6 +34,7 @@ "@playwright/test": "1.60.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/dompurify": "^3.0.5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -3239,6 +3241,16 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -3346,8 +3358,8 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true + "devOptional": true, + "license": "MIT" }, "node_modules/@types/yargs": { "version": "17.0.35", @@ -5801,7 +5813,6 @@ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.5.tgz", "integrity": "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA==", "license": "(MPL-2.0 OR Apache-2.0)", - "optional": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } diff --git a/package.json b/package.json index 557293d96..230851ab4 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,14 @@ "react-dom": "^18", "recharts": "^2.12.7", "server-only": "^0.0.1", - "sonner": "^2.0.7" + "sonner": "^2.0.7", + "dompurify": "^3.1.6" }, "devDependencies": { "@playwright/test": "1.60.0", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", + "@types/dompurify": "^3.0.5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/src/components/AIMentorWidget.tsx b/src/components/AIMentorWidget.tsx index 0c8b150e4..28fe8b3cd 100644 --- a/src/components/AIMentorWidget.tsx +++ b/src/components/AIMentorWidget.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import DOMPurify from "dompurify"; interface Insight { id: string; @@ -171,9 +172,12 @@ export function AIMentorWidget() {

Weekly summary · AI

-

- {data.aiSummary} -

+

)} From b46d88a94ccb187560be44247ef8dd3ec80cbc6c Mon Sep 17 00:00:00 2001 From: Vijay Patil Date: Fri, 29 May 2026 17:31:01 +0530 Subject: [PATCH 2/4] fix(dashboard): make WeeklySummaryCard resilient to empty/partial API payloads --- src/components/WeeklySummaryCard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/WeeklySummaryCard.tsx b/src/components/WeeklySummaryCard.tsx index af03d0c71..56a62a571 100644 --- a/src/components/WeeklySummaryCard.tsx +++ b/src/components/WeeklySummaryCard.tsx @@ -26,9 +26,9 @@ export default function WeeklySummaryCard() { const [error, setError] = useState(null); const [isCollapsed, setIsCollapsed] = useState(false); - const maxCommits = summary ? Math.max(summary.commits.current, summary.commits.previous, 1) : 1; - const maxPRs = summary ? Math.max(summary.prs.thisWeek.merged, summary.prs.lastWeek.merged, 1) : 1; - const maxActiveDays = summary ? Math.max(summary.activeDays.thisWeek, summary.activeDays.lastWeek, 1) : 1; + const maxCommits = summary && summary.commits ? Math.max(summary.commits.current, summary.commits.previous, 1) : 1; + const maxPRs = summary && summary.prs && summary.prs.thisWeek && summary.prs.lastWeek ? Math.max(summary.prs.thisWeek.merged, summary.prs.lastWeek.merged, 1) : 1; + const maxActiveDays = summary && summary.activeDays ? Math.max(summary.activeDays.thisWeek, summary.activeDays.lastWeek, 1) : 1; const fetchSummary = useCallback(() => { setLoading(true); @@ -97,7 +97,7 @@ export default function WeeklySummaryCard() {

{error}
- ) : summary ? ( + ) : (summary && summary.commits && summary.prs && summary.activeDays) ? (
{/* Commits Comparison */}
From 354558aa653fc20f7d3cd55fabac56cb39f2baa2 Mon Sep 17 00:00:00 2001 From: Vijay Patil Date: Mon, 1 Jun 2026 07:43:17 +0530 Subject: [PATCH 3/4] test(security): add unit test suite for AIMentorWidget verifying safe HTML rendering and DOMPurify sanitization --- src/components/AIMentorWidget.tsx | 2 +- test/AIMentorWidget.test.ts | 165 ++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 1 deletion(-) create mode 100644 test/AIMentorWidget.test.ts diff --git a/src/components/AIMentorWidget.tsx b/src/components/AIMentorWidget.tsx index 28fe8b3cd..69d20f2b7 100644 --- a/src/components/AIMentorWidget.tsx +++ b/src/components/AIMentorWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import React, { useState, useEffect } from "react"; import DOMPurify from "dompurify"; interface Insight { diff --git a/test/AIMentorWidget.test.ts b/test/AIMentorWidget.test.ts new file mode 100644 index 000000000..2c393fa2d --- /dev/null +++ b/test/AIMentorWidget.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { AIMentorWidget } from "@/components/AIMentorWidget"; +import React from "react"; + +const mockAIData = { + data: { + insights: [ + { + id: "insight-1", + type: "consistency", + title: "High Consistency", + description: "You coded 5 days in a row!", + severity: "positive", + }, + ], + trend: { direction: "up", percentage: 25 }, + aiSummary: "

Weekly Focus

Keep up the great work in devtrack!

", + generatedAt: "2026-06-01T07:00:00.000Z", + }, +}; + +describe("AIMentorWidget Component", () => { + beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(mockAIData), + } as Response) + ) + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); + }); + + it("renders loader initially and then renders insights and trend data", async () => { + render(React.createElement(AIMentorWidget)); + + // Loader status should exist initially + expect(screen.getByRole("status")).toBeInTheDocument(); + + await waitFor(() => { + // Heading AI Mentor + expect(screen.getByText("AI Mentor")).toBeInTheDocument(); + // Trend text + expect(screen.getByText(/↑/i)).toBeInTheDocument(); + expect(screen.getByText(/25%/i)).toBeInTheDocument(); + // Insight card + expect(screen.getByText("High Consistency")).toBeInTheDocument(); + expect(screen.getByText("You coded 5 days in a row!")).toBeInTheDocument(); + }); + }); + + it("renders safe HTML formatting post-sanitization", async () => { + render(React.createElement(AIMentorWidget)); + + await waitFor(() => { + // 1. Check for header element

+ const heading = screen.getByRole("heading", { level: 3 }); + expect(heading).toBeInTheDocument(); + expect(heading.textContent).toBe("Weekly Focus"); + + // 2. Check for strong element + const strongElement = screen.getByText("great work"); + expect(strongElement.tagName).toBe("STRONG"); + + // 3. Check for code element + const codeElement = screen.getByText("devtrack"); + expect(codeElement.tagName).toBe("CODE"); + }); + }); + + it("sanitizes and strips malicious script tags and event handlers from HTML", async () => { + const maliciousData = { + data: { + ...mockAIData.data, + aiSummary: `

Safe Advice

Safe text inside strong`, + }, + }; + + vi.mocked(global.fetch).mockImplementationOnce(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(maliciousData), + } as Response) + ); + + render(React.createElement(AIMentorWidget)); + + await waitFor(() => { + // The safe elements should still be rendered correctly + expect(screen.getByRole("heading", { level: 3 }).textContent).toBe("Safe Advice"); + expect(screen.getByText("Safe text inside strong")).toBeInTheDocument(); + + // The script tag should be stripped from the DOM + const aiSummaryContainer = screen.getByText("Safe Advice").parentElement; + expect(aiSummaryContainer?.innerHTML).not.toContain("