From a12dd102692ee237e9695bd852dc894a2856b30e Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Fri, 10 Jan 2025 14:45:30 +0100 Subject: [PATCH] fix(security): restrict link protocols to http and https (#1888) --- packages/translation/src/lang/en.json | 1 + packages/validation/src/app.ts | 6 ++++- packages/validation/src/integration.ts | 5 +++- packages/validation/src/search-engine.ts | 2 +- packages/widgets/src/iframe/component.tsx | 31 ++++++++++++++++++++++- 5 files changed, 41 insertions(+), 4 deletions(-) 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)