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:
@@ -29,6 +29,7 @@
|
|||||||
| --------------------------- | :-----: | ----------------------------------------------------------------------------------- |
|
| --------------------------- | :-----: | ----------------------------------------------------------------------------------- |
|
||||||
| SECURITY_MAX_LOGIN_ATTEMPTS | 5 | Maximum times a user can provide an invalid password before their account is locked |
|
| 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 |
|
| 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
|
### Database
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
import type { RecipeCategory, RecipeTag, RecipeTool } from "~/lib/api/types/recipe";
|
||||||
|
import { truncateText as truncatePlainText } from "~/lib/sanitize/text";
|
||||||
|
|
||||||
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
export type UrlPrefixParam = "tags" | "categories" | "tools";
|
||||||
|
|
||||||
@@ -50,10 +51,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
defineEmits(["item-selected"]);
|
defineEmits(["item-selected"]);
|
||||||
function truncateText(text: string, length = 20, clamp = "...") {
|
function truncateText(text: string, length = 20, clamp = "...") {
|
||||||
if (!props.truncate) return text;
|
if (!props.truncate) return text;
|
||||||
const node = document.createElement("div");
|
return truncatePlainText(text, length, clamp);
|
||||||
node.innerHTML = text;
|
|
||||||
const content = node.textContent || "";
|
|
||||||
return content.length > length ? content.slice(0, length) + clamp : content;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import DOMPurify from "isomorphic-dompurify";
|
|
||||||
import { marked } from "marked";
|
import { marked } from "marked";
|
||||||
|
import { sanitizeMarkdownHtml } from "~/lib/sanitize/markdown";
|
||||||
enum DOMPurifyHook {
|
|
||||||
UponSanitizeAttribute = "uponSanitizeAttribute",
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
source: {
|
source: {
|
||||||
@@ -18,48 +14,11 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const ALLOWED_STYLE_TAGS = [
|
const { $appInfo } = useNuxtApp();
|
||||||
"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 value = computed(() => {
|
const value = computed(() => {
|
||||||
const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
|
const rawHtml = marked.parse(props.source || "", { async: false, breaks: true });
|
||||||
return sanitizeMarkdown(rawHtml);
|
return sanitizeMarkdownHtml(rawHtml, $appInfo?.allowedIframeHosts ?? []);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface AppInfo {
|
|||||||
oidcRedirect: boolean;
|
oidcRedirect: boolean;
|
||||||
oidcProviderName: string;
|
oidcProviderName: string;
|
||||||
tokenTime: number;
|
tokenTime: number;
|
||||||
|
allowedIframeHosts?: string[];
|
||||||
}
|
}
|
||||||
export interface AppStartupInfo {
|
export interface AppStartupInfo {
|
||||||
isFirstLogin: boolean;
|
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;
|
||||||
|
}
|
||||||
@@ -33,6 +33,16 @@ class FeatureDetails(NamedTuple):
|
|||||||
return s
|
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[
|
MaskedNoneString = Annotated[
|
||||||
str | None,
|
str | None,
|
||||||
PlainSerializer(lambda x: None if x is None else "*****", return_type=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_SIGNUP: bool = False
|
||||||
ALLOW_PASSWORD_LOGIN: bool = True
|
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"
|
DAILY_SCHEDULE_TIME: str = "23:45"
|
||||||
"""Local server time, in HH:MM format. See `DAILY_SCHEDULE_TIME_UTC` for the parsed UTC equivalent"""
|
"""Local server time, in HH:MM format. See `DAILY_SCHEDULE_TIME_UTC` for the parsed UTC equivalent"""
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ def get_app_info(session: Session = Depends(generate_session)):
|
|||||||
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
|
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
|
||||||
allow_password_login=settings.ALLOW_PASSWORD_LOGIN,
|
allow_password_login=settings.ALLOW_PASSWORD_LOGIN,
|
||||||
token_time=settings.TOKEN_TIME,
|
token_time=settings.TOKEN_TIME,
|
||||||
|
allowed_iframe_hosts=settings.allowed_iframe_hosts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,13 @@ async def get_recipe_asset(recipe_id: UUID4, file_name: str):
|
|||||||
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
raise HTTPException(status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
if file.exists():
|
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:
|
else:
|
||||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class AppInfo(MealieModel):
|
|||||||
oidc_redirect: bool
|
oidc_redirect: bool
|
||||||
oidc_provider_name: str
|
oidc_provider_name: str
|
||||||
token_time: int
|
token_time: int
|
||||||
|
allowed_iframe_hosts: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
class AppTheme(MealieModel):
|
class AppTheme(MealieModel):
|
||||||
|
|||||||
@@ -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}"
|
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):
|
def test_recipe_image_upload(api_client: TestClient, unique_user: TestUser, recipe_ingredient_only: Recipe):
|
||||||
data_payload = {"extension": "jpg"}
|
data_payload = {"extension": "jpg"}
|
||||||
file_payload = {"image": data.images_test_image_1.read_bytes()}
|
file_payload = {"image": data.images_test_image_1.read_bytes()}
|
||||||
|
|||||||
@@ -27,6 +27,31 @@ def test_non_default_settings(monkeypatch):
|
|||||||
assert app_settings.DOCS_URL is None
|
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):
|
def test_default_connection_args(monkeypatch):
|
||||||
monkeypatch.setenv("DB_ENGINE", "sqlite")
|
monkeypatch.setenv("DB_ENGINE", "sqlite")
|
||||||
get_app_settings.cache_clear()
|
get_app_settings.cache_clear()
|
||||||
|
|||||||
Reference in New Issue
Block a user