fix: trust iframe urls
This commit is contained in:
1 parent
c34629d39e
commit
55b62a6e2b
5 files changed
+15
-69
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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 <NoUrl />;
|
||||
if (!safeEmbedUrl) {
|
||||
return <UnsupportedProtocol />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" w="100%" gap={0}>
|
||||
@@ -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 (
|
||||
<Stack align="center" justify="center" h="100%">
|
||||
<IconProtocol />
|
||||
<Title order={4} ta="center">
|
||||
{t("error.unsupportedProtocol", {
|
||||
supportedProtocols: supportedProtocols.map((protocol) => protocol).join(", "),
|
||||
})}
|
||||
</Title>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export const getAllowedPermissions = (
|
||||
permissions: Omit<WidgetComponentProps<"iframe">["options"], "embedUrl" | "allowScrolling">,
|
||||
) => {
|
||||
|
||||
@@ -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"));
|
||||
@@ -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}`;
|
||||
};
|
||||
Reference in new issue
Block a user