mirror of
https://github.com/mealie-recipes/mealie.git
synced 2026-06-01 22:50:26 -04:00
fix: harden recipe content against stored XSS (chips, instructions, asset media) (#7719)
This commit is contained in:
@@ -48,6 +48,7 @@ export interface AppInfo {
|
||||
oidcRedirect: boolean;
|
||||
oidcProviderName: string;
|
||||
tokenTime: number;
|
||||
allowedIframeHosts?: string[];
|
||||
}
|
||||
export interface AppStartupInfo {
|
||||
isFirstLogin: boolean;
|
||||
|
||||
74
frontend/app/lib/sanitize/markdown.test.ts
Normal file
74
frontend/app/lib/sanitize/markdown.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
98
frontend/app/lib/sanitize/markdown.ts
Normal file
98
frontend/app/lib/sanitize/markdown.ts
Normal 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;
|
||||
}
|
||||
35
frontend/app/lib/sanitize/text.test.ts
Normal file
35
frontend/app/lib/sanitize/text.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
9
frontend/app/lib/sanitize/text.ts
Normal file
9
frontend/app/lib/sanitize/text.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user