diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts
index 1723b2e3a..bef2e91cf 100644
--- a/apps/nextjs/next.config.ts
+++ b/apps/nextjs/next.config.ts
@@ -25,8 +25,9 @@ const nextConfig: NextConfig = {
typescript: { ignoreBuildErrors: true },
/**
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
+ * isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
*/
- serverExternalPackages: ["dockerode"],
+ serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForDev: true,
diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json
index 33464a8bf..189e0e299 100644
--- a/apps/nextjs/package.json
+++ b/apps/nextjs/package.json
@@ -75,6 +75,7 @@
"dotenv": "^17.2.3",
"flag-icons": "^7.5.0",
"glob": "^11.0.3",
+ "isomorphic-dompurify": "^2.32.0",
"jotai": "^2.15.1",
"mantine-react-table": "2.0.0-beta.9",
"next": "16.0.1",
diff --git a/apps/nextjs/src/app/api/user-medias/[id]/route.ts b/apps/nextjs/src/app/api/user-medias/[id]/route.ts
index f4ad85ba8..e3401c976 100644
--- a/apps/nextjs/src/app/api/user-medias/[id]/route.ts
+++ b/apps/nextjs/src/app/api/user-medias/[id]/route.ts
@@ -1,6 +1,7 @@
import { notFound } from "next/navigation";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
+import DOMPurify from "isomorphic-dompurify";
import { db, eq } from "@homarr/db";
import { medias } from "@homarr/db/schema";
@@ -19,11 +20,24 @@ export async function GET(_req: NextRequest, props: { params: Promise<{ id: stri
notFound();
}
+ let content = new Uint8Array(image.content);
+
+ // Sanitize SVG content to prevent XSS attacks
+ if (image.contentType === "image/svg+xml" || image.contentType === "image/svg") {
+ const svgText = new TextDecoder().decode(content);
+ const sanitized = DOMPurify.sanitize(svgText, {
+ USE_PROFILES: { svg: true, svgFilters: true },
+ });
+ content = new TextEncoder().encode(sanitized);
+ }
+
const headers = new Headers();
headers.set("Content-Type", image.contentType);
- headers.set("Content-Length", image.content.length.toString());
+ headers.set("Content-Length", content.length.toString());
+ headers.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
+ headers.set("X-Content-Type-Options", "nosniff");
- return new NextResponse(new Uint8Array(image.content), {
+ return new NextResponse(content, {
status: 200,
headers,
});
diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx
index ae8a2bcfa..05ab587a0 100644
--- a/packages/widgets/src/iframe/component.tsx
+++ b/packages/widgets/src/iframe/component.tsx
@@ -11,8 +11,9 @@ import classes from "./component.module.css";
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n();
- const { embedUrl, ...permissions } = options;
+ const { embedUrl, allowScrolling, ...permissions } = options;
const allowedPermissions = getAllowedPermissions(permissions);
+ const sandboxFlags = getSandboxFlags(permissions);
if (embedUrl.trim() === "") return ;
if (!isSupportedProtocol(embedUrl)) {
@@ -27,7 +28,8 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
src={embedUrl}
title="widget iframe"
allow={allowedPermissions.join(" ")}
- scrolling={options.allowScrolling ? "yes" : "no"}
+ scrolling={allowScrolling ? "yes" : "no"}
+ sandbox={sandboxFlags.join(" ")}
>
{t("widget.iframe.error.noBrowerSupport")}
@@ -80,6 +82,22 @@ const getAllowedPermissions = (
.map(([key]) => permissionMapping[key]);
};
+const getSandboxFlags = (
+ permissions: Omit["options"], "embedUrl" | "allowScrolling">,
+) => {
+ const baseSandbox = ["allow-scripts", "allow-same-origin", "allow-forms", "allow-popups"];
+
+ if (permissions.allowFullScreen) {
+ baseSandbox.push("allow-presentation");
+ }
+
+ if (permissions.allowPayment) {
+ baseSandbox.push("allow-popups-to-escape-sandbox");
+ }
+
+ return baseSandbox;
+};
+
const permissionMapping = {
allowAutoPlay: "autoplay",
allowCamera: "camera",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c71b6bdcc..6452099ac 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -286,6 +286,9 @@ importers:
glob:
specifier: ^11.0.3
version: 11.0.3
+ isomorphic-dompurify:
+ specifier: ^2.32.0
+ version: 2.32.0(postcss@8.5.6)
jotai:
specifier: ^2.15.1
version: 2.15.1(@babel/core@7.26.0)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0)
@@ -2467,6 +2470,9 @@ packages:
'@acemir/cssom@0.9.19':
resolution: {integrity: sha512-Pp2gAQXPZ2o7lt4j0IMwNRXqQ3pagxtDj5wctL5U2Lz4oV0ocDNlkgx4DpxfyKav4S/bePuI+SMqcBSUHLy9kg==}
+ '@acemir/cssom@0.9.23':
+ resolution: {integrity: sha512-2kJ1HxBKzPLbmhZpxBiTZggjtgCwKg1ma5RHShxvd6zgqhDEdEkzpiwe7jLkI2p2BrZvFCXIihdoMkl1H39VnA==}
+
'@actions/core@1.11.1':
resolution: {integrity: sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==}
@@ -5989,6 +5995,10 @@ packages:
resolution: {integrity: sha512-zDMqXh8Vs1CdRYZQ2M633m/SFgcjlu8RB8b/1h82i+6vpArF507NSYIWJHGlJaTWoS+imcnctmEz43txhbVkOw==}
engines: {node: '>=20'}
+ cssstyle@5.3.3:
+ resolution: {integrity: sha512-OytmFH+13/QXONJcC75QNdMtKpceNk3u8ThBjyyYjkEcy/ekBwR1mMAuNvi3gdBPW3N5TlCzQ0WZw8H0lN/bDw==}
+ engines: {node: '>=20'}
+
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -6257,6 +6267,9 @@ packages:
dompurify@3.2.6:
resolution: {integrity: sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==}
+ dompurify@3.3.0:
+ resolution: {integrity: sha512-r+f6MYR1gGN1eJv0TVQbhA7if/U7P87cdPl3HN5rikqaBSBxLiCb/b9O+2eG0cxz0ghyU+mU1QkbsOwERMYlWQ==}
+
dot-case@2.1.1:
resolution: {integrity: sha512-HnM6ZlFqcajLsyudHq7LeeLDr2rFAVYtDv/hV5qchQEidSck8j9OPUsXY9KwJv/lHMtYlX4DjRQqwFYa+0r8Ug==}
@@ -7689,6 +7702,10 @@ packages:
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
engines: {node: '>=16'}
+ isomorphic-dompurify@2.32.0:
+ resolution: {integrity: sha512-4i6G4ICY57wQpiaNd6WcwhHUAqGDAJGWRlfWKLunBchJjtF2HV4eUeJtUupoEddbnnxYUiRhqfd9e4aDYR7ROA==}
+ engines: {node: '>=20.19.5'}
+
isomorphic-fetch@3.0.0:
resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==}
@@ -7785,6 +7802,15 @@ packages:
canvas:
optional: true
+ jsdom@27.2.0:
+ resolution: {integrity: sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
jsep@1.4.0:
resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==}
engines: {node: '>= 10.16.0'}
@@ -11111,6 +11137,8 @@ snapshots:
'@acemir/cssom@0.9.19': {}
+ '@acemir/cssom@0.9.23': {}
+
'@actions/core@1.11.1':
dependencies:
'@actions/exec': 1.1.1
@@ -15072,6 +15100,14 @@ snapshots:
transitivePeerDependencies:
- postcss
+ cssstyle@5.3.3(postcss@8.5.6):
+ dependencies:
+ '@asamuzakjp/css-color': 4.0.4
+ '@csstools/css-syntax-patches-for-csstree': 1.0.14(postcss@8.5.6)
+ css-tree: 3.1.0
+ transitivePeerDependencies:
+ - postcss
+
csstype@3.1.3: {}
d3-array@3.2.4:
@@ -15319,6 +15355,10 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
+ dompurify@3.3.0:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
dot-case@2.1.1:
dependencies:
no-case: 2.3.2
@@ -17018,6 +17058,17 @@ snapshots:
isexe@3.1.1:
optional: true
+ isomorphic-dompurify@2.32.0(postcss@8.5.6):
+ dependencies:
+ dompurify: 3.3.0
+ jsdom: 27.2.0(postcss@8.5.6)
+ transitivePeerDependencies:
+ - bufferutil
+ - canvas
+ - postcss
+ - supports-color
+ - utf-8-validate
+
isomorphic-fetch@3.0.0:
dependencies:
node-fetch: 2.7.0
@@ -17144,6 +17195,34 @@ snapshots:
- supports-color
- utf-8-validate
+ jsdom@27.2.0(postcss@8.5.6):
+ dependencies:
+ '@acemir/cssom': 0.9.23
+ '@asamuzakjp/dom-selector': 6.7.4
+ cssstyle: 5.3.3(postcss@8.5.6)
+ data-urls: 6.0.0
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 4.0.0
+ http-proxy-agent: 7.0.2
+ https-proxy-agent: 7.0.6
+ is-potential-custom-element-name: 1.0.1
+ parse5: 8.0.0
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.0
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.0
+ whatwg-encoding: 3.1.1
+ whatwg-mimetype: 4.0.0
+ whatwg-url: 15.1.0
+ ws: 8.18.3
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - bufferutil
+ - postcss
+ - supports-color
+ - utf-8-validate
+
jsep@1.4.0: {}
jsesc@3.0.2: {}