Skip to content
Open
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
4 changes: 4 additions & 0 deletions .codacy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
# flags non-literal fs paths as false positives.
exclude_paths:
- "apps/vanilla/scripts/link-sitesearch.mjs"
# XSS unit tests intentionally contain PoC HTML / javascript: payloads.
- "**/__tests__/**"
- "**/*.test.ts"
- "**/*.test.tsx"
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: markdown rendering, sanitized by marked */
/** biome-ignore-all lint/security/noDangerouslySetInnerHtml: markdown rendering sanitized via parseMarkdownToSafeHtml */
/** biome-ignore-all lint/suspicious/noArrayIndexKey: parts are stable during render */
"use client";

Expand All @@ -17,7 +17,7 @@ import {
ThumbsDown,
ThumbsUp,
} from "lucide-react";
import { marked } from "marked";
import { marked, type Tokens } from "marked";
import React from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
Expand All @@ -27,6 +27,144 @@ import {
useAskai,
} from "@/registry/experiences/highlight-to-askai/hooks/use-askai";

function escapeHtml(html: string): string {
return html
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}

function decodeUrlForSchemeCheck(value: string): string {
let current = value;
for (let i = 0; i < 3; i += 1) {
try {
const decoded = decodeURIComponent(current);
if (decoded === current) {
break;
}
current = decoded;
} catch {
break;
}
}
return current;
}

function stripControlsAndWhitespace(value: string): string {
let result = "";
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code > 0x20 && code !== 0x7f) {
result += value.charAt(i);
}
}
return result;
}

function sanitizeUrl(url: string | null | undefined): string {
if (!url) {
return "";
}

const trimmed = url.trim();
if (!trimmed) {
return "";
}

const normalized = stripControlsAndWhitespace(
decodeUrlForSchemeCheck(trimmed),
);
if (!normalized) {
return "";
}

if (normalized.startsWith("//")) {
return "";
}

if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(normalized)) {
return trimmed;
}

try {
const parsed = new URL(normalized);
if (
parsed.protocol === "http:" ||
parsed.protocol === "https:" ||
parsed.protocol === "mailto:"
) {
return normalized;
}
} catch {
return "";
}

return "";
}

/* eslint-disable xss/no-mixed-html -- marked renderer: interpolations escaped/sanitized */
const markdownRenderer = new marked.Renderer();

markdownRenderer.code = ({ text, lang = "", escaped }: Tokens.Code): string => {
const safeLang = /^[a-zA-Z0-9_-]+$/.test(lang) ? lang : "";
const languageClass = safeLang ? "language-" + safeLang : "";
const safeCode = escaped ? text : escapeHtml(text);
return (
'<pre><code class="' + languageClass + '">' + safeCode + "</code></pre>"
);
};

markdownRenderer.link = ({ href, title, text }: Tokens.Link): string => {
const safeHref = escapeHtml(sanitizeUrl(href));
const textEscaped = escapeHtml(text);
if (!safeHref) {
return textEscaped;
}
const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : "";
// href/text/title are sanitized + escaped above.
return (
'<a href="' +
safeHref +
'"' +
titleAttr +
' target="_blank" rel="noopener noreferrer">' +
textEscaped +
"</a>"
); // nosemgrep
};

markdownRenderer.image = ({ href, title, text }: Tokens.Image): string => {
const safeHref = escapeHtml(sanitizeUrl(href));
if (!safeHref) {
return escapeHtml(text);
}
const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : "";
// src/alt/title are sanitized + escaped above.
return (
'<img src="' +
safeHref +
'" alt="' +
escapeHtml(text) +
'"' +
titleAttr +
" />"
); // nosemgrep
};

markdownRenderer.html = ({ text }: Tokens.HTML | Tokens.Tag): string =>
escapeHtml(text);
/* eslint-enable xss/no-mixed-html */

function parseMarkdownToSafeHtml(content: string): string {
return marked.parse(content, {
gfm: true,
breaks: true,
renderer: markdownRenderer,
}) as string;
}

type OnAskPayload = {
text: string;
html: string;
Expand Down Expand Up @@ -510,7 +648,10 @@ export function HighlightAskAI({
return <p key={index}>{part}</p>;
}
if (part.type === "text") {
const html = marked.parse(part.text || "");
// eslint-disable-next-line xss/no-mixed-html -- sanitized via parseMarkdownToSafeHtml
const html = parseMarkdownToSafeHtml(
part.text || "",
);
return (
<div
key={index}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,14 +229,84 @@ function escapeHtml(html: string): string {
.replace(/'/g, "&#39;");
}

function decodeUrlForSchemeCheck(value: string): string {
let current = value;
for (let i = 0; i < 3; i += 1) {
try {
const decoded = decodeURIComponent(current);
if (decoded === current) {
break;
}
current = decoded;
} catch {
break;
}
}
return current;
}

function stripControlsAndWhitespace(value: string): string {
let result = "";
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i);
if (code > 0x20 && code !== 0x7f) {
result += value.charAt(i);
}
}
return result;
}

function sanitizeUrl(url: string | null | undefined): string {
if (!url) {
return "";
}

const trimmed = url.trim();
if (!trimmed) {
return "";
}

const normalized = stripControlsAndWhitespace(
decodeUrlForSchemeCheck(trimmed),
);
if (!normalized) {
return "";
}

if (normalized.startsWith("//")) {
return "";
}

if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(normalized)) {
return trimmed;
}

try {
const parsed = new URL(normalized);
if (
parsed.protocol === "http:" ||
parsed.protocol === "https:" ||
parsed.protocol === "mailto:"
) {
return normalized;
}
} catch {
return "";
}

return "";
}

// ============================================================================
// Markdown Renderer
// ============================================================================

/* eslint-disable xss/no-mixed-html -- marked renderer: interpolations escaped/sanitized */
const markdownRenderer = new marked.Renderer();

markdownRenderer.code = ({ text, lang = "", escaped }: Tokens.Code): string => {
const languageClass = lang ? `language-${lang}` : "";
const safeLang = /^[a-zA-Z0-9_-]+$/.test(lang) ? lang : "";
const languageClass = safeLang ? `language-${safeLang}` : "";
const safeCode = escaped ? text : escapeHtml(text);
const encodedCode = encodeURIComponent(text);

Expand Down Expand Up @@ -265,13 +335,49 @@ markdownRenderer.code = ({ text, lang = "", escaped }: Tokens.Code): string => {
};

markdownRenderer.link = ({ href, title, text }: Tokens.Link): string => {
const titleAttr = title ? ` title="${escapeHtml(title)}"` : "";
const hrefAttr = href ? escapeHtml(href) : "";
const textContent = text || "";
const safeHref = escapeHtml(sanitizeUrl(href));
const textEscaped = escapeHtml(text);

return `<a href="${hrefAttr}" target="_blank" rel="noopener noreferrer"${titleAttr}>${textContent}</a>`;
if (!safeHref) {
return textEscaped;
}

const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : "";
// href/text/title are sanitized + escaped above.
return (
'<a href="' +
safeHref +
'" target="_blank" rel="noopener noreferrer"' +
titleAttr +
">" +
textEscaped +
"</a>"
); // nosemgrep
};

markdownRenderer.image = ({ href, title, text }: Tokens.Image): string => {
const safeHref = escapeHtml(sanitizeUrl(href));
if (!safeHref) {
return escapeHtml(text);
}

const titleAttr = title ? ' title="' + escapeHtml(title) + '"' : "";
// src/alt/title are sanitized + escaped above.
return (
'<img src="' +
safeHref +
'" alt="' +
escapeHtml(text) +
'"' +
titleAttr +
" />"
); // nosemgrep
};

markdownRenderer.html = ({ text }: Tokens.HTML | Tokens.Tag): string =>
escapeHtml(text);
/* eslint-enable xss/no-mixed-html */

// ============================================================================
// Icon Components
// ============================================================================
Expand Down Expand Up @@ -551,7 +657,8 @@ const MemoizedMarkdown = memo(function MemoizedMarkdown({
[&_hr]:border-none [&_hr]:border-t [&_hr]:border-border [&_hr]:my-6
[&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-md [&_img]:my-2
${className}`.trim()}
// biome-ignore lint/security/noDangerouslySetInnerHtml: its alright :)
// eslint-disable-next-line xss/no-mixed-html -- sanitized marked output
// biome-ignore lint/security/noDangerouslySetInnerHtml: HTML escaped via marked renderer (html/link/image)
dangerouslySetInnerHTML={{ __html: html }}
/>
);
Expand Down
Loading