From 2d8b74282ae3eb05782532c8a12af95d7a6444cc Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sun, 31 May 2026 11:14:16 -0500 Subject: [PATCH] fix: harden recipe content against stored XSS (chips, instructions, asset media) (#7719) --- .../installation/backend-config.md | 1 + .../2026-05-31_1_iframe-embeds.vue | 54 ++++++++++ .../components/Domain/Recipe/RecipeChips.vue | 6 +- .../app/components/global/SafeMarkdown.vue | 47 +-------- frontend/app/lib/api/types/admin.ts | 1 + frontend/app/lib/sanitize/markdown.test.ts | 74 ++++++++++++++ frontend/app/lib/sanitize/markdown.ts | 98 +++++++++++++++++++ frontend/app/lib/sanitize/text.test.ts | 35 +++++++ frontend/app/lib/sanitize/text.ts | 9 ++ mealie/core/settings/settings.py | 23 +++++ mealie/routes/app/app_about.py | 1 + mealie/routes/media/media_recipe.py | 9 +- mealie/schema/admin/about.py | 1 + .../test_recipe_image_assets.py | 27 +++++ tests/unit_tests/test_config.py | 25 +++++ 15 files changed, 362 insertions(+), 49 deletions(-) create mode 100644 frontend/app/components/Domain/Announcement/Announcements/2026-05-31_1_iframe-embeds.vue create mode 100644 frontend/app/lib/sanitize/markdown.test.ts create mode 100644 frontend/app/lib/sanitize/markdown.ts create mode 100644 frontend/app/lib/sanitize/text.test.ts create mode 100644 frontend/app/lib/sanitize/text.ts diff --git a/docs/docs/documentation/getting-started/installation/backend-config.md b/docs/docs/documentation/getting-started/installation/backend-config.md index 27dae3885..6b030c0fc 100644 --- a/docs/docs/documentation/getting-started/installation/backend-config.md +++ b/docs/docs/documentation/getting-started/installation/backend-config.md @@ -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 `", []); + expect(html).not.toContain(" { + const html = sanitizeMarkdownHtml( + "", + ["youtube.com"], + ); + expect(html).not.toContain(" { + const html = sanitizeMarkdownHtml( + "", + ["youtube.com"], + ); + expect(html).not.toContain(" { + const html = sanitizeMarkdownHtml( + "", + ["youtube.com"], + ); + expect(html).toContain(" { + const html = sanitizeMarkdownHtml( + "", + ["youtube.com"], + ); + expect(html).not.toContain("; 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`. `