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
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { stripDangerousHtml } from "@/lib/sanitizeContent";
import { cn } from "@/lib/utils";
import {
FunctionCall,
Expand Down Expand Up @@ -271,7 +272,7 @@ function ChatBubble({
)}
>
<Streamdown shikiTheme={shikiTheme}>
{preserveLineBreaksForMarkdown(displayContent)}
{preserveLineBreaksForMarkdown(stripDangerousHtml(displayContent))}
</Streamdown>
</div>

Expand Down
3 changes: 2 additions & 1 deletion web/components/templates/requests/components/Realtime.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { stripDangerousHtml } from "@/lib/sanitizeContent";
import GlassHeader from "@/components/shared/universal/GlassHeader";
import { JsonRenderer } from "@/components/templates/requests/components/chatComponent/single/JsonRenderer";
import { logger } from "@/lib/telemetry/logger";
Expand Down Expand Up @@ -644,7 +645,7 @@ const SessionUpdate: React.FC<SessionUpdateProps> = ({ content }) => {
>
<div className="prose prose-sm dark:prose-invert prose-headings:text-slate-50 prose-p:text-slate-200 prose-a:text-cyan-200 hover:prose-a:text-cyan-100 prose-blockquote:border-slate-400 prose-blockquote:text-slate-300 prose-strong:text-white prose-em:text-slate-300 prose-code:text-yellow-200 prose-pre:bg-slate-800/50 prose-pre:text-slate-200 prose-ol:text-slate-200 prose-ul:text-slate-200 prose-li:text-slate-200 [&_ol>li::marker]:text-white [&_ul>li::marker]:text-white">
<Streamdown shikiTheme={shikiTheme}>
{preserveLineBreaksForMarkdown(sessionData.instructions)}
{preserveLineBreaksForMarkdown(stripDangerousHtml(sessionData.instructions))}
</Streamdown>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import AssistantToolCall from "./AssistantToolCall";

import MarkdownEditor from "@/components/shared/markdownEditor";
import { stripDangerousHtml } from "@/lib/sanitizeContent";
import { cn } from "@/lib/utils";
import {
FunctionCall,
Expand Down Expand Up @@ -67,7 +68,7 @@ export default function AssistantToolCalls({
content && (
<div className="w-full whitespace-pre-wrap break-words p-2 text-xs">
<Streamdown shikiTheme={shikiTheme}>
{preserveLineBreaksForMarkdown(content)}
{preserveLineBreaksForMarkdown(stripDangerousHtml(content))}
</Streamdown>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { stripDangerousHtml } from "@/lib/sanitizeContent";
import { MappedLLMRequest, Message } from "@helicone-package/llm-mapper/types";
import { isJson } from "../ChatMessage";
import { JsonRenderer } from "./JsonRenderer";
Expand Down Expand Up @@ -124,7 +125,7 @@ export default function TextMessage({
<>
<div className="w-full whitespace-pre-wrap break-words text-sm">
<Streamdown shikiTheme={shikiTheme}>
{preserveLineBreaksForMarkdown(displayContent)}
{preserveLineBreaksForMarkdown(stripDangerousHtml(displayContent))}
</Streamdown>
</div>
{annotations && annotations.length > 0 && (
Expand Down
62 changes: 62 additions & 0 deletions web/lib/sanitizeContent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Strips dangerous HTML tags from text content before markdown rendering.
* Preserves markdown syntax and safe HTML tags (like <b>, <em>, <a>, etc.).
*
* This prevents stored XSS via LLM outputs containing malicious HTML
* (e.g., <iframe srcdoc="<script>document.cookie</script>">).
*
* We use regex-based stripping rather than DOMPurify because DOMPurify
* parses input as HTML, which mangles markdown syntax (backticks,
* asterisks, brackets, etc.).
*/
export function stripDangerousHtml(text: string): string {
if (typeof text !== "string") return text;

const dangerousTags = [
"script",
"iframe",
"object",
"embed",
"style",
"form",
"input",
"button",
"textarea",
"select",
"applet",
"base",
"link",
"meta",
"svg",
"math",
];

let cleaned = text;

for (const tag of dangerousTags) {
// Remove paired tags with content: <tag ...>...</tag>
const pairedRegex = new RegExp(
`<\\s*${tag}[^>]*>[\\s\\S]*?<\\s*/\\s*${tag}\\s*>`,
"gi"
);
cleaned = cleaned.replace(pairedRegex, "");

// Remove self-closing or unclosed: <tag ... /> or <tag ...>
const selfClosingRegex = new RegExp(`<\\s*${tag}[^>]*/?>`, "gi");
cleaned = cleaned.replace(selfClosingRegex, "");
}

// Remove event handlers from remaining tags (onclick, onload, onerror, etc.)
cleaned = cleaned.replace(
/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi,
""
);

// Remove javascript: protocol in href/src/action attributes
cleaned = cleaned.replace(
/(href|src|action)\s*=\s*(?:"javascript:[^"]*"|'javascript:[^']*')/gi,
'$1=""'
);

return cleaned;
}
Loading