Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
29 changes: 29 additions & 0 deletions src/__tests__/xss-regression.test.tsx
Original file line number Diff line number Diff line change
@@ -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 = `<iframe srcdoc="<img src=1 onerror=alert('XSS')>"></iframe>`;

describe("chat markdown XSS regression", () => {
it("neutralizes HTML-like chat input before send", () => {
expect(neutralizeHtmlMarkup(xssPayload)).toBe(
"&lt;iframe srcdoc=&quot;&lt;img src=1 onerror=alert(&#x27;XSS&#x27;)&gt;&quot;&gt;&lt;/iframe&gt;"
);
});

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(<SafeMarkdown>{xssPayload}</SafeMarkdown>);

expect(html).not.toContain("<iframe");
expect(html).not.toContain("<img");
expect(html).toContain("&lt;iframe");
expect(html).toContain("srcdoc=&quot;");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,35 +7,8 @@ import { FeedbackModal } from "../feedback-modal";
import { useChatStore } from "@/hooks/store/chat";
import { useLanguage } from "@/components/LanguageProvider";
import { cn } from "@/lib/utils";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import type { Components } from "react-markdown";
import { BetaLanguageNote } from "./beta-language-note";

const markdownComponents: Components = {
p: ({ children }) => <p className="m-0 leading-relaxed whitespace-pre-line">{children}</p>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{children}
</a>
),
pre: ({ children }) => (
<pre className="bg-muted/50 p-2 rounded-lg overflow-x-auto">{children}</pre>
),
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
return isInline ? (
<code className="bg-muted/50 rounded px-1 py-0.5" {...props}>{children}</code>
) : (
<code className={className} {...props}>{children}</code>
);
},
hr: () => (
<hr className="border-none h-px my-4 bg-primary/30 dark:bg-primary/40" />
),
};
import { SafeMarkdown } from "./safe-markdown";

export function CardBubble({ message }: { readonly message: CardMessage }) {
const { language } = useLanguage();
Expand Down Expand Up @@ -143,13 +116,7 @@ export function CardBubble({ message }: { readonly message: CardMessage }) {
) : null}

<div className={cn("prose prose-sm dark:prose-invert max-w-none text-base leading-relaxed text-foreground dark:text-[var(--aiBubbleText-dark)] break-words overflow-wrap-anywhere", message.isError && "text-red-600 dark:text-red-400 font-medium")}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={markdownComponents}
>
{message.body}
</ReactMarkdown>
<SafeMarkdown>{message.body}</SafeMarkdown>
</div>
<BetaLanguageNote language={responseLanguage} />

Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => <p className="m-0 leading-relaxed whitespace-pre-line">{children}</p>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{children}
</a>
),
pre: ({ children }) => (
<pre className="bg-muted/50 p-2 rounded-lg overflow-x-auto">{children}</pre>
),
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
return isInline ? (
<code className="bg-muted/50 rounded px-1 py-0.5" {...props}>{children}</code>
) : (
<code className={className} {...props}>{children}</code>
);
},
hr: () => (
<hr className="border-none h-px my-4 bg-primary/30 dark:bg-primary/40" />
),
};

export function SafeMarkdown({ children }: { readonly children: string }) {
return (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{children}
</ReactMarkdown>
);
}
Original file line number Diff line number Diff line change
@@ -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 }) => <p className="m-0 leading-relaxed">{children}</p>,
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{children}
</a>
),
pre: ({ children }) => (
<pre className="bg-muted/50 p-2 rounded-lg overflow-x-auto">{children}</pre>
),
code: ({ className, children, ...props }) => {
const match = /language-(\w+)/.exec(className || "");
const isInline = !match;
return isInline ? (
<code className="bg-muted/50 rounded px-1 py-0.5" {...props}>{children}</code>
) : (
<code className={className} {...props}>{children}</code>
);
},
hr: () => (
<hr className="border-none h-px my-4 bg-primary/30 dark:bg-primary/40" />
),
};
import { SafeMarkdown } from "./safe-markdown";

export function TextBubble({ message }: { message: TextMessage }) {
const { language } = useLanguage();
Expand All @@ -46,13 +19,7 @@ export function TextBubble({ message }: { message: TextMessage }) {
)}
>
<div className="prose prose-sm dark:prose-invert max-w-none leading-relaxed whitespace-pre-wrap break-words overflow-wrap-anywhere">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={markdownComponents}
>
{message.text}
</ReactMarkdown>
<SafeMarkdown>{message.text}</SafeMarkdown>
</div>
{!isUser ? <BetaLanguageNote language={responseLanguage} /> : null}
</div>
Expand Down
12 changes: 7 additions & 5 deletions src/hooks/store/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -551,12 +552,13 @@ export const useChatStore = create<ChatStore>((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: "",
Expand All @@ -577,7 +579,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
let streamingText = "";

const response = await apiService.sendUserQuery(
trimmed,
safeText,
currentSession,
language, // source
language, // target
Expand Down Expand Up @@ -648,7 +650,7 @@ export const useChatStore = create<ChatStore>((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)
Expand All @@ -666,7 +668,7 @@ export const useChatStore = create<ChatStore>((set, get) => ({
true,
false,
backendQid,
trimmed,
safeText,
language,
responseLanguage
);
Expand All @@ -681,7 +683,7 @@ export const useChatStore = create<ChatStore>((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)
Expand Down
18 changes: 18 additions & 0 deletions src/lib/security/html.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const HTML_TAG_PATTERN = /<\/?[a-z][\s\S]*>/i;
const HTML_ENTITY_PATTERN = /[&<>"']/g;

const HTML_ESCAPE_MAP: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#x27;",
};

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);
}
13 changes: 13 additions & 0 deletions src/lib/utils/fingerprint.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> => {
try {
const cached = localStorage.getItem("fingerprint_context");
Expand All @@ -11,6 +23,7 @@ export const getFingerprintId = async (): Promise<string | null> => {
}
}

ensureChromiumDevtoolsMetricsReporter();
const fp = await FingerprintJS.load();
const result = await fp.get();
return result.visitorId || null;
Expand Down
Loading