diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 59f4fc071..169df14d1 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -1280,6 +1280,7 @@
},
"error": {
"noUrl": "No iFrame URL provided",
+ "unsupportedProtocol": "The URL provided is using an unsupported protocol. Please use one of ({supportedProtocols})",
"noBrowerSupport": "Your Browser does not support iframes. Please update your browser."
}
},
diff --git a/packages/validation/src/app.ts b/packages/validation/src/app.ts
index 507c2f8bf..dfe1a749a 100644
--- a/packages/validation/src/app.ts
+++ b/packages/validation/src/app.ts
@@ -4,7 +4,11 @@ const manageAppSchema = z.object({
name: z.string().min(1).max(64),
description: z.string().max(512).nullable(),
iconUrl: z.string().min(1),
- href: z.string().nullable(),
+ href: z
+ .string()
+ .url()
+ .regex(/^https?:\/\//) // Only allow http and https for security reasons (javascript: is not allowed)
+ .nullable(),
});
const editAppSchema = manageAppSchema.and(z.object({ id: z.string() }));
diff --git a/packages/validation/src/integration.ts b/packages/validation/src/integration.ts
index ad22d8eeb..e9606a268 100644
--- a/packages/validation/src/integration.ts
+++ b/packages/validation/src/integration.ts
@@ -7,7 +7,10 @@ import { createSavePermissionsSchema } from "./permissions";
const integrationCreateSchema = z.object({
name: z.string().nonempty().max(127),
- url: z.string().url(),
+ url: z
+ .string()
+ .url()
+ .regex(/^https?:\/\//), // Only allow http and https for security reasons (javascript: is not allowed)
kind: zodEnumFromArray(integrationKinds),
secrets: z.array(
z.object({
diff --git a/packages/validation/src/search-engine.ts b/packages/validation/src/search-engine.ts
index dbf9b0f4b..720f5fb86 100644
--- a/packages/validation/src/search-engine.ts
+++ b/packages/validation/src/search-engine.ts
@@ -5,7 +5,7 @@ import type { SearchEngineType } from "@homarr/definitions";
const genericSearchEngine = z.object({
type: z.literal("generic" satisfies SearchEngineType),
- urlTemplate: z.string().min(1).startsWith("http").includes("%s"),
+ urlTemplate: z.string().min(1).startsWith("http").includes("%s"), // Only allow http and https for security reasons (javascript: is not allowed)
});
const fromIntegrationSearchEngine = z.object({
diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx
index b561bd060..3f32742c7 100644
--- a/packages/widgets/src/iframe/component.tsx
+++ b/packages/widgets/src/iframe/component.tsx
@@ -1,7 +1,7 @@
"use client";
import { Box, Stack, Text, Title } from "@mantine/core";
-import { IconBrowserOff } from "@tabler/icons-react";
+import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
@@ -15,6 +15,9 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
const allowedPermissions = getAllowedPermissions(permissions);
if (embedUrl.trim() === "") return ;
+ if (!isSupportedProtocol(embedUrl)) {
+ return ;
+ }
return (
@@ -31,6 +34,17 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
);
}
+const supportedProtocols = ["http", "https"];
+
+const isSupportedProtocol = (url: string) => {
+ try {
+ const parsedUrl = new URL(url);
+ return supportedProtocols.map((protocol) => `${protocol}:`).includes(`${parsedUrl.protocol}`);
+ } catch {
+ return false;
+ }
+};
+
const NoUrl = () => {
const t = useI18n();
@@ -42,6 +56,21 @@ const NoUrl = () => {
);
};
+const UnsupportedProtocol = () => {
+ const t = useI18n();
+
+ return (
+
+
+
+ {t("widget.iframe.error.unsupportedProtocol", {
+ supportedProtocols: supportedProtocols.map((protocol) => protocol).join(", "),
+ })}
+
+
+ );
+};
+
const getAllowedPermissions = (permissions: Omit["options"], "embedUrl">) => {
return objectEntries(permissions)
.filter(([_key, value]) => value)