From 55b62a6e2bb3b8ddcf8e64d62894d7d06adc0f7d Mon Sep 17 00:00:00 2001 From: ajnart Date: Wed, 26 Aug 2026 17:35:39 +0200 Subject: [PATCH] fix: trust iframe urls --- apps/docs/docs/widgets/iframe/index.mdx | 2 -- packages/widgets/src/iframe/component.spec.ts | 8 +---- packages/widgets/src/iframe/component.tsx | 32 ++----------------- packages/widgets/src/iframe/index.ts | 23 +++++++------ packages/widgets/src/iframe/url.ts | 19 ----------- 5 files changed, 15 insertions(+), 69 deletions(-) delete mode 100644 packages/widgets/src/iframe/url.ts diff --git a/apps/docs/docs/widgets/iframe/index.mdx b/apps/docs/docs/widgets/iframe/index.mdx index 5761d0296..5f410a7f3 100644 --- a/apps/docs/docs/widgets/iframe/index.mdx +++ b/apps/docs/docs/widgets/iframe/index.mdx @@ -66,8 +66,6 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai - **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page - **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni -- **Same-origin page:** `/path/to/page` resolves from the current Homarr origin. Root-relative URLs are useful when Homarr and - the embedded page share a reverse-proxy host. ### Configuration diff --git a/packages/widgets/src/iframe/component.spec.ts b/packages/widgets/src/iframe/component.spec.ts index a5f3b5835..8c7dd134d 100644 --- a/packages/widgets/src/iframe/component.spec.ts +++ b/packages/widgets/src/iframe/component.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getAllowedPermissions, getFrameTitle, getSandboxFlags, isSupportedProtocol } from "./component"; +import { getAllowedPermissions, getFrameTitle, getSandboxFlags } from "./component"; const permissions = { allowFullScreen: true, @@ -18,12 +18,6 @@ describe("iframe policy", () => { expect(getSandboxFlags(permissions)).toContain("allow-modals"); }); - it("accepts only HTTP protocols", () => { - expect(isSupportedProtocol("https://example.com")).toBe(true); - expect(isSupportedProtocol("javascript:alert(1)")).toBe(false); - expect(isSupportedProtocol("not a url")).toBe(false); - }); - it("creates a display title without credentials, paths, or query parameters", () => { expect(getFrameTitle("https://user:secret@example.com/private?token=sensitive")).toBe("example.com"); }); diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx index eef89b172..3c311b88e 100644 --- a/packages/widgets/src/iframe/component.tsx +++ b/packages/widgets/src/iframe/component.tsx @@ -1,26 +1,21 @@ "use client"; import { Box, Stack, Text, Title } from "@mantine/core"; -import { IconBrowserOff, IconProtocol } from "@tabler/icons-react"; +import { IconBrowserOff } from "@tabler/icons-react"; import { objectEntries } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import classes from "./component.module.css"; -import { getSafeIframeUrl } from "./url"; export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) { const t = useI18n("widget.iframe"); const { embedUrl, allowScrolling, ...permissions } = options; - const safeEmbedUrl = getSafeIframeUrl(embedUrl); const allowedPermissions = getAllowedPermissions(permissions); const sandboxFlags = getSandboxFlags(permissions); if (embedUrl.trim() === "") return ; - if (!safeEmbedUrl) { - return ; - } return ( @@ -29,8 +24,8 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro loading="lazy" style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined} className={classes.iframe} - src={safeEmbedUrl} - title={getFrameTitle(safeEmbedUrl)} + src={embedUrl} + title={getFrameTitle(embedUrl)} allow={allowedPermissions} scrolling={allowScrolling ? "yes" : "no"} sandbox={sandboxFlags.join(" ")} @@ -42,12 +37,6 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro ); } -const supportedProtocols = ["http", "https"]; - -export const isSupportedProtocol = (url: string) => { - return getSafeIframeUrl(url) !== undefined; -}; - const NoUrl = () => { const t = useI18n("widget.iframe"); @@ -59,21 +48,6 @@ const NoUrl = () => { ); }; -const UnsupportedProtocol = () => { - const t = useI18n("widget.iframe"); - - return ( - - - - {t("error.unsupportedProtocol", { - supportedProtocols: supportedProtocols.map((protocol) => protocol).join(", "), - })} - - - ); -}; - export const getAllowedPermissions = ( permissions: Omit["options"], "embedUrl" | "allowScrolling">, ) => { diff --git a/packages/widgets/src/iframe/index.ts b/packages/widgets/src/iframe/index.ts index 512f3278b..810a0b03b 100644 --- a/packages/widgets/src/iframe/index.ts +++ b/packages/widgets/src/iframe/index.ts @@ -2,7 +2,6 @@ import { IconBrowser, IconExternalLink } from "@tabler/icons-react"; import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; -import { getSafeIframeUrl } from "./url"; const createOptions = () => optionsBuilder.from((factory) => ({ @@ -23,17 +22,17 @@ export const { definition, componentLoader } = createWidgetDefinition("iframe", supportsAdvancedFocus: false, icon: IconBrowser, contextActions: ({ options }) => { - const embedUrl = getSafeIframeUrl(options.embedUrl); - return embedUrl - ? [ - { - key: "open-iframe", - label: "widget.common.openInNewTab.label", - icon: IconExternalLink, - onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"), - }, - ] - : []; + const embedUrl = options.embedUrl; + if (typeof embedUrl !== "string" || embedUrl.trim() === "") return []; + + return [ + { + key: "open-iframe", + label: "widget.common.openInNewTab.label", + icon: IconExternalLink, + onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"), + }, + ]; }, createOptions, }).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/iframe/url.ts b/packages/widgets/src/iframe/url.ts deleted file mode 100644 index a76e5da42..000000000 --- a/packages/widgets/src/iframe/url.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { getSafeApplicationUrl } from "../common/application-url"; - -const relativeUrlBase = "https://homarr.invalid"; - -export const getSafeIframeUrl = (value: unknown): string | undefined => { - const absoluteUrl = getSafeApplicationUrl(value); - if (absoluteUrl) return absoluteUrl; - if (typeof value !== "string") return undefined; - - const path = value.trim(); - if (!path.startsWith("/")) return undefined; - - const resolvedUrl = getSafeApplicationUrl(path, { baseUrl: relativeUrlBase }); - if (!resolvedUrl) return undefined; - - const parsedUrl = new URL(resolvedUrl); - if (parsedUrl.origin !== relativeUrlBase) return undefined; - return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`; -};