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: {}