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

@@ -29,6 +29,7 @@
| --------------------------- | :-----: | ----------------------------------------------------------------------------------- |
| SECURITY_MAX_LOGIN_ATTEMPTS | 5 | Maximum times a user can provide an invalid password before their account is locked |
| SECURITY_USER_LOCKOUT_TIME | 24 | Time in hours for how long a users account is locked |
| ALLOWED_IFRAME_HOSTS | `""` | Comma-separated extra hostnames allowed as `<iframe>` sources in recipe content. Extends the built-in list of trusted video providers (YouTube, Vimeo). Subdomains are included automatically. Only `https` sources are permitted. Adding hosts here opts into rendering embeds from those origins to all viewers, including the public, so add only origins you trust. |
### Database

View File

@@ -0,0 +1,54 @@
<template>
<div>
<p>
To harden Mealie against malicious content, <code>&lt;iframe&gt;</code> embeds in recipe
instructions, notes, and descriptions are now restricted to a trusted set of hosts.
</p>
<div class="mb-2">
By default, embeds are allowed only from well-known video providers:
<ul class="ml-6">
<li>YouTube</li>
<li>Vimeo</li>
</ul>
</div>
<p>
Existing recipes that embed content from <strong>other</strong> hosts will no longer render
those embeds. The rest of the recipe is unaffected.
</p>
<div v-if="user?.admin">
<hr class="mt-2 mb-4">
<p>
As an admin, you can allow additional hosts with the <code>ALLOWED_IFRAME_HOSTS</code>
environment variable (comma-separated). It extends the built-in defaults, and only
<code>https</code> sources are permitted. See the configuration docs for details:
<br>
<v-btn
class="mt-2"
color="primary"
href="https://docs.mealie.io/documentation/getting-started/installation/backend-config/"
target="_blank"
>
Backend Configuration
</v-btn>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import type { AnnouncementMeta } from "~/composables/use-announcements";
const { user } = useMealieAuth();
</script>
<script lang="ts">
export const meta: AnnouncementMeta = {
title: "Recipe embeds restricted to trusted hosts",
};
</script>
<style scoped lang="css">
p {
padding-bottom: 8px;
}
</style>

View File

@@ -25,6 +25,7 @@
<script setup lang="ts">
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
import { truncateText as truncatePlainText } from "~/lib/sanitize/text";
export type UrlPrefixParam = "tags" | "categories" | "tools";
@@ -50,10 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
defineEmits(["item-selected"]);
function truncateText(text: string, length = 20, clamp = "...") {
if (!props.truncate) return text;
const node = document.createElement("div");
node.innerHTML = text;
const content = node.textContent || "";
return content.length > length ? content.slice(0, length) + clamp : content;
return truncatePlainText(text, length, clamp);
}
</script>

View File

@@ -4,12 +4,8 @@
</template>
<script setup lang="ts">
import DOMPurify from "isomorphic-dompurify";
import { marked } from "marked";
enum DOMPurifyHook {
UponSanitizeAttribute = "uponSanitizeAttribute",
}
import { sanitizeMarkdownHtml } from "~/lib/sanitize/markdown";
const props = defineProps({
source: {
@@ -18,48 +14,11 @@ const props = defineProps({
},
});
const ALLOWED_STYLE_TAGS = [
"background-color", "color", "font-style", "font-weight", "text-decoration", "text-align",
];
function sanitizeMarkdown(rawHtml: string | null | undefined): string {
if (!rawHtml) {
return "";
}
DOMPurify.addHook(DOMPurifyHook.UponSanitizeAttribute, (node, data) => {
if (data.attrName === "style") {
const styles = data.attrValue.split(";").filter((style) => {
const [property] = style.split(":");
return ALLOWED_STYLE_TAGS.includes(property.trim().toLowerCase());
});
data.attrValue = styles.join(";");
}
});
const sanitized = DOMPurify.sanitize(rawHtml, {
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", "iframe",
"del", "ins", "table", "thead", "tbody", "tfoot", "tr", "th", "td", "colgroup",
],
ALLOWED_ATTR: [
"href", "src", "alt", "height", "width", "class", "allow", "title", "allowfullscreen", "frameborder",
"scrolling", "cite", "datetime", "name", "abbr", "target", "border", "start", "style",
],
});
Object.values(DOMPurifyHook).forEach((hook) => {
DOMPurify.removeHook(hook);
});
return sanitized;
}
const { $appInfo } = useNuxtApp();
const value = computed(() => {
const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
return sanitizeMarkdown(rawHtml);
return sanitizeMarkdownHtml(rawHtml, $appInfo?.allowedIframeHosts ?? []);
});
</script>

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;
}

View File

@@ -33,6 +33,16 @@ class FeatureDetails(NamedTuple):
return s
DEFAULT_ALLOWED_IFRAME_HOSTS = [
"youtube.com",
"youtube-nocookie.com",
"vimeo.com",
"player.vimeo.com",
]
"""Secure-by-default hostnames permitted as `<iframe>` sources in user content. Limited to
well-known video providers. Subdomains of these hosts are also allowed (e.g. `www.youtube.com`)."""
MaskedNoneString = Annotated[
str | None,
PlainSerializer(lambda x: None if x is None else "*****", return_type=str | None),
@@ -150,6 +160,19 @@ class AppSettings(AppLoggingSettings):
ALLOW_SIGNUP: bool = False
ALLOW_PASSWORD_LOGIN: bool = True
ALLOWED_IFRAME_HOSTS: str = ""
"""Comma-separated list of additional hostnames allowed as `<iframe>` sources in user content
(recipe instructions, notes, descriptions). Extends `DEFAULT_ALLOWED_IFRAME_HOSTS`. Subdomains of
a listed host are also allowed. Adding hosts is opt-in to riskier behavior; the defaults are
limited to well-known video providers."""
@property
def allowed_iframe_hosts(self) -> list[str]:
"""The full set of hostnames permitted as `<iframe>` sources, secure defaults plus any
admin-configured additions via `ALLOWED_IFRAME_HOSTS`."""
extra = [host.strip().lower() for host in self.ALLOWED_IFRAME_HOSTS.split(",") if host.strip()]
return list(dict.fromkeys(DEFAULT_ALLOWED_IFRAME_HOSTS + extra))
DAILY_SCHEDULE_TIME: str = "23:45"
"""Local server time, in HH:MM format. See `DAILY_SCHEDULE_TIME_UTC` for the parsed UTC equivalent"""

View File

@@ -43,6 +43,7 @@ def get_app_info(session: Session = Depends(generate_session)):
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
allow_password_login=settings.ALLOW_PASSWORD_LOGIN,
token_time=settings.TOKEN_TIME,
allowed_iframe_hosts=settings.allowed_iframe_hosts,
)

View File

@@ -58,6 +58,13 @@ async def get_recipe_asset(recipe_id: UUID4, file_name: str):
raise HTTPException(status.HTTP_400_BAD_REQUEST)
if file.exists():
return FileResponse(file, filename=file.name, content_disposition_type="attachment")
# Force download and disable MIME sniffing so uploaded assets cannot be
# served as active content in Mealie's origin.
return FileResponse(
file,
filename=file.name,
content_disposition_type="attachment",
headers={"X-Content-Type-Options": "nosniff"},
)
else:
raise HTTPException(status.HTTP_404_NOT_FOUND)

View File

@@ -22,6 +22,7 @@ class AppInfo(MealieModel):
oidc_redirect: bool
oidc_provider_name: str
token_time: int
allowed_iframe_hosts: list[str] = []
class AppTheme(MealieModel):

View File

@@ -104,6 +104,33 @@ def test_recipe_asset_dangerous_extension_blocked(
assert response.status_code == 400, f"expected 400 for extension={ext}, got {response.status_code}"
def test_recipe_asset_served_as_attachment(
api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe
):
"""Assets must be served as downloads with MIME sniffing disabled so uploaded files cannot
execute as active content in Mealie's origin."""
recipe = recipe_ingredient_only
payload = {"name": random_string(10), "icon": "mdi-file", "extension": "txt"}
file_payload = {"file": b"<script>alert(1)</script>"}
response = api_client.post(
f"/api/recipes/{recipe.slug}/assets",
data=payload,
files=file_payload,
headers=unique_user.token,
)
assert response.status_code == 200
recipe_response = api_client.get(f"/api/recipes/{recipe.slug}", headers=unique_user.token).json()
recipe_id = recipe_response["id"]
file_name = recipe_response["assets"][0]["fileName"]
media_response = api_client.get(f"/api/media/recipes/{recipe_id}/assets/{file_name}")
assert media_response.status_code == 200
assert "attachment" in media_response.headers["content-disposition"].lower()
assert media_response.headers["x-content-type-options"] == "nosniff"
def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
data_payload = {"extension": "jpg"}
file_payload = {"image": data.images_test_image_1.read_bytes()}

View File

@@ -27,6 +27,31 @@ def test_non_default_settings(monkeypatch):
assert app_settings.DOCS_URL is None
def test_allowed_iframe_hosts_defaults(monkeypatch):
monkeypatch.delenv("ALLOWED_IFRAME_HOSTS", raising=False)
get_app_settings.cache_clear()
app_settings = get_app_settings()
# Secure defaults are always present and never empty (empty would disable iframe embeds).
assert "youtube.com" in app_settings.allowed_iframe_hosts
assert "vimeo.com" in app_settings.allowed_iframe_hosts
def test_allowed_iframe_hosts_extends_defaults(monkeypatch):
monkeypatch.setenv("ALLOWED_IFRAME_HOSTS", " Example.com , trusted.tld ,, ")
get_app_settings.cache_clear()
app_settings = get_app_settings()
hosts = app_settings.allowed_iframe_hosts
# Configured hosts are normalized, blanks dropped, and defaults retained.
assert "example.com" in hosts
assert "trusted.tld" in hosts
assert "youtube.com" in hosts
assert "" not in hosts
# No duplicates.
assert len(hosts) == len(set(hosts))
def test_default_connection_args(monkeypatch):
monkeypatch.setenv("DB_ENGINE", "sqlite")
get_app_settings.cache_clear()