fix: harden recipe content against stored XSS (chips, instructions, asset media) (#7719)

This commit is contained in:
Hayden
2026-05-31 11:14:16 -05:00
committed by GitHub
parent 48752bcd06
commit 2d8b74282a
15 changed files with 362 additions and 49 deletions

View File

@@ -48,6 +48,7 @@ export interface AppInfo {
oidcRedirect: boolean;
oidcProviderName: string;
tokenTime: number;
allowedIframeHosts?: string[];
}
export interface AppStartupInfo {
isFirstLogin: boolean;

View File

@@ -0,0 +1,74 @@
import { describe, expect, test } from "vitest";
import { sanitizeMarkdownHtml } from "./markdown";
describe("sanitizeMarkdownHtml", () => {
test("returns empty string for nullish input", () => {
expect(sanitizeMarkdownHtml(null)).toEqual("");
expect(sanitizeMarkdownHtml(undefined)).toEqual("");
expect(sanitizeMarkdownHtml("")).toEqual("");
});
test("keeps allowed formatting tags", () => {
const html = sanitizeMarkdownHtml("<p>Mix <strong>flour</strong> and <em>water</em></p>");
expect(html).toContain("<strong>flour</strong>");
expect(html).toContain("<em>water</em>");
});
test("strips script tags and event handlers", () => {
const html = sanitizeMarkdownHtml("<p onclick=\"alert(1)\">hi</p><script>alert(1)</script>");
expect(html).not.toContain("script");
expect(html).not.toContain("onclick");
expect(html).not.toContain("alert");
});
test("strips img onerror payloads", () => {
const html = sanitizeMarkdownHtml("<img src=x onerror=alert(1)>");
expect(html).not.toContain("onerror");
});
// Form controls must never render in user content.
test("strips form, input, and button elements", () => {
const html = sanitizeMarkdownHtml("<form action=/x><input name=p><button>go</button></form>");
expect(html).not.toContain("<form");
expect(html).not.toContain("<input");
expect(html).not.toContain("<button");
});
test("strips iframes when no allowed hosts are configured", () => {
const html = sanitizeMarkdownHtml("<iframe src=\"https://evil.example/x\"></iframe>", []);
expect(html).not.toContain("<iframe");
});
test("strips iframes whose src host is not allowlisted", () => {
const html = sanitizeMarkdownHtml(
"<iframe src=\"https://evil.example/x\"></iframe>",
["youtube.com"],
);
expect(html).not.toContain("<iframe");
});
test("strips non-https iframes even for an allowlisted host", () => {
const html = sanitizeMarkdownHtml(
"<iframe src=\"http://www.youtube.com/embed/abc\"></iframe>",
["youtube.com"],
);
expect(html).not.toContain("<iframe");
});
test("keeps iframes from an allowlisted host (incl. subdomains)", () => {
const html = sanitizeMarkdownHtml(
"<iframe src=\"https://www.youtube.com/embed/abc\"></iframe>",
["youtube.com"],
);
expect(html).toContain("<iframe");
expect(html).toContain("https://www.youtube.com/embed/abc");
});
test("does not allow a lookalike host to pass the suffix check", () => {
const html = sanitizeMarkdownHtml(
"<iframe src=\"https://notyoutube.com/embed/abc\"></iframe>",
["youtube.com"],
);
expect(html).not.toContain("<iframe");
});
});

View File

@@ -0,0 +1,98 @@
import DOMPurify from "isomorphic-dompurify";
enum DOMPurifyHook {
UponSanitizeAttribute = "uponSanitizeAttribute",
AfterSanitizeAttributes = "afterSanitizeAttributes",
}
const ALLOWED_STYLE_PROPERTIES = [
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
];
const BASE_ALLOWED_TAGS = [
"strong", "em", "b", "i", "u", "p", "code", "pre", "samp", "kbd", "var", "sub", "sup", "dfn", "cite",
"small", "address", "hr", "br", "id", "div", "span", "h1", "h2", "h3", "h4", "h5", "h6",
"ul", "ol", "li", "dl", "dt", "dd", "abbr", "a", "img", "blockquote",
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
];
const BASE_ALLOWED_ATTR = [
"href", "src", "alt", "height", "width", "class", "title",
"cite", "datetime", "name", "abbr", "target", "border", "start", "style",
];
// Attributes only meaningful on an <iframe>; added to the allowlist solely when iframe embeds
// are enabled via a configured host allowlist.
const IFRAME_ALLOWED_ATTR = ["allow", "allowfullscreen", "frameborder", "scrolling"];
/**
* Returns true if an iframe `src` points at one of the allowed hosts. Only https URLs are
* accepted, and a configured host matches the URL's hostname exactly or as a parent domain
* (e.g. "youtube.com" matches "www.youtube.com").
*/
function isAllowedIframeSrc(src: string, allowedHosts: string[]): boolean {
let url: URL;
try {
url = new URL(src);
}
catch {
return false;
}
if (url.protocol !== "https:") {
return false;
}
const hostname = url.hostname.toLowerCase();
return allowedHosts.some((host) => {
const allowed = host.toLowerCase();
return hostname === allowed || hostname.endsWith(`.${allowed}`);
});
}
/**
* Sanitizes pre-rendered HTML (from markdown) for display in user content such as recipe
* instructions, notes, and descriptions.
*
* Only the tags in `BASE_ALLOWED_TAGS` and attributes in `BASE_ALLOWED_ATTR` survive; everything
* else (scripts, event handlers, form controls, ...) is dropped. `style` attributes are filtered
* down to the properties in `ALLOWED_STYLE_PROPERTIES`. `<iframe>` is only kept when
* `allowedIframeHosts` is non-empty, and even then any iframe whose `src` is not an https URL on
* the host allowlist is removed.
*/
export function sanitizeMarkdownHtml(rawHtml: string | null | undefined, allowedIframeHosts: string[] = []): string {
if (!rawHtml) {
return "";
}
const allowIframe = allowedIframeHosts.length > 0;
DOMPurify.addHook(DOMPurifyHook.UponSanitizeAttribute, (_node, data) => {
if (data.attrName === "style") {
const styles = data.attrValue.split(";").filter((style) => {
const [property] = style.split(":");
return ALLOWED_STYLE_PROPERTIES.includes(property.trim().toLowerCase());
});
data.attrValue = styles.join(";");
}
});
if (allowIframe) {
DOMPurify.addHook(DOMPurifyHook.AfterSanitizeAttributes, (node) => {
if (node.nodeName === "IFRAME" && !isAllowedIframeSrc(node.getAttribute("src") || "", allowedIframeHosts)) {
node.parentNode?.removeChild(node);
}
});
}
const sanitized = DOMPurify.sanitize(rawHtml, {
ALLOWED_TAGS: allowIframe ? [...BASE_ALLOWED_TAGS, "iframe"] : BASE_ALLOWED_TAGS,
ALLOWED_ATTR: allowIframe ? [...BASE_ALLOWED_ATTR, ...IFRAME_ALLOWED_ATTR] : BASE_ALLOWED_ATTR,
});
Object.values(DOMPurifyHook).forEach((hook) => {
DOMPurify.removeHook(hook);
});
return sanitized;
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, test, vi } from "vitest";
import { truncateText } from "./text";
describe("truncateText", () => {
test("returns short text unchanged", () => {
expect(truncateText("Dinner")).toEqual("Dinner");
});
test("truncates long text with clamp", () => {
expect(truncateText("a".repeat(25))).toEqual(`${"a".repeat(20)}...`);
});
test("respects custom length and clamp", () => {
expect(truncateText("abcdef", 3, "~")).toEqual("abc~");
});
test("does not clamp text exactly at the length boundary", () => {
expect(truncateText("abcde", 5)).toEqual("abcde");
expect(truncateText("abcdef", 5)).toEqual("abcde...");
});
// Markup in the input must be treated as plain text and never parsed into the live document.
test("does not parse or execute HTML payloads", () => {
const createElement = vi.spyOn(document, "createElement");
const payload = "<img src=x onerror=alert(1)>";
const result = truncateText(payload);
// The payload is returned verbatim (truncated only by length), proving it is treated as text.
expect(result).toEqual(`${payload.slice(0, 20)}...`);
// No DOM element is constructed, so no <img> can fire its onerror handler.
expect(createElement).not.toHaveBeenCalled();
createElement.mockRestore();
});
});

View File

@@ -0,0 +1,9 @@
/**
* Truncates plain text to `length` characters, appending `clamp` when truncated.
*
* The input is treated strictly as text and is never parsed as HTML, so markup in the input is
* returned verbatim rather than interpreted.
*/
export function truncateText(text: string, length = 20, clamp = "..."): string {
return text.length > length ? text.slice(0, length) + clamp : text;
}