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:
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div>
|
||||
<p>
|
||||
To harden Mealie against malicious content, <code><iframe></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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user