diff --git a/package.json b/package.json index 2fbaacf..0d97606 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-phone-number-input": "^3.4.14", - "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", diff --git a/src/__tests__/xss-regression.test.tsx b/src/__tests__/xss-regression.test.tsx new file mode 100644 index 0000000..ec15402 --- /dev/null +++ b/src/__tests__/xss-regression.test.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; + +import { SafeMarkdown } from "@/components/screens-component/chat-screen/components/bubbles/safe-markdown"; +import { neutralizeHtmlMarkup } from "@/lib/security/html"; + +const xssPayload = ``; + +describe("chat markdown XSS regression", () => { + it("neutralizes HTML-like chat input before send", () => { + expect(neutralizeHtmlMarkup(xssPayload)).toBe( + "<iframe srcdoc="<img src=1 onerror=alert('XSS')>"></iframe>" + ); + }); + + it("does not alter normal comparison text", () => { + expect(neutralizeHtmlMarkup("soil pH < 5.5 needs lime")).toBe("soil pH < 5.5 needs lime"); + }); + + it("renders submitted HTML as text instead of DOM nodes", () => { + const html = renderToStaticMarkup({xssPayload}); + + expect(html).not.toContain("

{children}

, - a: ({ href, children }) => ( - - {children} - - ), - pre: ({ children }) => ( -
{children}
- ), - code: ({ className, children, ...props }) => { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !match; - return isInline ? ( - {children} - ) : ( - {children} - ); - }, - hr: () => ( -
- ), -}; +import { SafeMarkdown } from "./safe-markdown"; export function CardBubble({ message }: { readonly message: CardMessage }) { const { language } = useLanguage(); @@ -143,13 +116,7 @@ export function CardBubble({ message }: { readonly message: CardMessage }) { ) : null}
- - {message.body} - + {message.body}
diff --git a/src/components/screens-component/chat-screen/components/bubbles/safe-markdown.tsx b/src/components/screens-component/chat-screen/components/bubbles/safe-markdown.tsx new file mode 100644 index 0000000..53a7759 --- /dev/null +++ b/src/components/screens-component/chat-screen/components/bubbles/safe-markdown.tsx @@ -0,0 +1,35 @@ +import ReactMarkdown from "react-markdown"; +import type { Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; + +const markdownComponents: Components = { + p: ({ children }) =>

{children}

, + a: ({ href, children }) => ( + + {children} + + ), + pre: ({ children }) => ( +
{children}
+ ), + code: ({ className, children, ...props }) => { + const match = /language-(\w+)/.exec(className || ""); + const isInline = !match; + return isInline ? ( + {children} + ) : ( + {children} + ); + }, + hr: () => ( +
+ ), +}; + +export function SafeMarkdown({ children }: { readonly children: string }) { + return ( + + {children} + + ); +} diff --git a/src/components/screens-component/chat-screen/components/bubbles/text-bubble.tsx b/src/components/screens-component/chat-screen/components/bubbles/text-bubble.tsx index f7f94f3..d84a358 100644 --- a/src/components/screens-component/chat-screen/components/bubbles/text-bubble.tsx +++ b/src/components/screens-component/chat-screen/components/bubbles/text-bubble.tsx @@ -1,35 +1,8 @@ import { cn } from "@/lib/utils"; import { type TextMessage } from "./chat-types"; -import ReactMarkdown from "react-markdown"; -import remarkGfm from "remark-gfm"; -import rehypeRaw from "rehype-raw"; -import type { Components } from "react-markdown"; import { useLanguage } from "@/components/LanguageProvider"; import { BetaLanguageNote } from "./beta-language-note"; - -const markdownComponents: Components = { - p: ({ children }) =>

{children}

, - a: ({ href, children }) => ( - - {children} - - ), - pre: ({ children }) => ( -
{children}
- ), - code: ({ className, children, ...props }) => { - const match = /language-(\w+)/.exec(className || ""); - const isInline = !match; - return isInline ? ( - {children} - ) : ( - {children} - ); - }, - hr: () => ( -
- ), -}; +import { SafeMarkdown } from "./safe-markdown"; export function TextBubble({ message }: { message: TextMessage }) { const { language } = useLanguage(); @@ -46,13 +19,7 @@ export function TextBubble({ message }: { message: TextMessage }) { )} >
- - {message.text} - + {message.text}
{!isUser ? : null} diff --git a/src/hooks/store/chat/index.ts b/src/hooks/store/chat/index.ts index 1095da3..4b122e7 100644 --- a/src/hooks/store/chat/index.ts +++ b/src/hooks/store/chat/index.ts @@ -14,6 +14,7 @@ import { getFingerprintId } from "@/lib/utils"; import { shuffle, randomPick } from "@/lib/qa-utils"; import type { ToastType } from "@/components/screens-component/chat-screen/components/toast"; import { environment } from "@/lib/config/environment"; +import { neutralizeHtmlMarkup } from "@/lib/security/html"; export type ApiNotification = { notification_id: string; @@ -551,12 +552,13 @@ export const useChatStore = create((set, get) => ({ sendText: async (text, language, t) => { const trimmed = text.trim(); if (!trimmed) return; + const safeText = neutralizeHtmlMarkup(trimmed); const responseLanguage = getResponseLanguage(language); get().stopTTS(); await get().fetchLocation(t); - const userMessage = makeUserMessage(trimmed); + const userMessage = makeUserMessage(safeText); set((state) => ({ messages: [...state.messages, userMessage], draft: "", @@ -577,7 +579,7 @@ export const useChatStore = create((set, get) => ({ let streamingText = ""; const response = await apiService.sendUserQuery( - trimmed, + safeText, currentSession, language, // source language, // target @@ -648,7 +650,7 @@ export const useChatStore = create((set, get) => ({ qid: telemetryQid, session_id: currentSession, error_text: "Rate limit error (429)", - question_text: trimmed + question_text: safeText }) .catch((telemetryError) => console.warn("Backend telemetry error relay failed", telemetryError) @@ -666,7 +668,7 @@ export const useChatStore = create((set, get) => ({ true, false, backendQid, - trimmed, + safeText, language, responseLanguage ); @@ -681,7 +683,7 @@ export const useChatStore = create((set, get) => ({ qid: telemetryQid, session_id: currentSession, error_text: String(error), - question_text: trimmed + question_text: safeText }) .catch((telemetryError) => console.warn("Backend telemetry error relay failed", telemetryError) diff --git a/src/lib/security/html.ts b/src/lib/security/html.ts new file mode 100644 index 0000000..129c4fc --- /dev/null +++ b/src/lib/security/html.ts @@ -0,0 +1,18 @@ +const HTML_TAG_PATTERN = /<\/?[a-z][\s\S]*>/i; +const HTML_ENTITY_PATTERN = /[&<>"']/g; + +const HTML_ESCAPE_MAP: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +export function neutralizeHtmlMarkup(value: string): string { + if (!HTML_TAG_PATTERN.test(value)) { + return value; + } + + return value.replace(HTML_ENTITY_PATTERN, (char) => HTML_ESCAPE_MAP[char] ?? char); +} diff --git a/src/lib/utils/fingerprint.ts b/src/lib/utils/fingerprint.ts index 7f89b15..708153e 100644 --- a/src/lib/utils/fingerprint.ts +++ b/src/lib/utils/fingerprint.ts @@ -1,5 +1,17 @@ import FingerprintJS from "@fingerprintjs/fingerprintjs"; +const ensureChromiumDevtoolsMetricsReporter = () => { + if (typeof window === "undefined") return; + + const chromiumWindow = window as Window & { + __chromium_devtools_metrics_reporter?: unknown; + }; + + if (typeof chromiumWindow.__chromium_devtools_metrics_reporter !== "function") { + chromiumWindow.__chromium_devtools_metrics_reporter = () => undefined; + } +}; + export const getFingerprintId = async (): Promise => { try { const cached = localStorage.getItem("fingerprint_context"); @@ -11,6 +23,7 @@ export const getFingerprintId = async (): Promise => { } } + ensureChromiumDevtoolsMetricsReporter(); const fp = await FingerprintJS.load(); const result = await fp.get(); return result.visitorId || null;