From 147f2669297857206a7e7aec2a63bf2403bccc68 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Mon, 4 May 2026 17:47:44 +0200 Subject: [PATCH] fix(notifications): enforce allowlist for custom Shoutrrr targets (#853) * fix(notifications): enforce allowlist for custom Shoutrrr targets * fix(notifications): enforce allowlist for email notification targets --- .../notification-target-policy.test.ts | 74 +++++++++ .../notifications.controller.test.ts | 23 +++ .../notifications/notifications.service.ts | 67 +------- .../utils/notification-target-policy.ts | 156 ++++++++++++++++++ 4 files changed, 259 insertions(+), 61 deletions(-) create mode 100644 app/server/modules/notifications/__tests__/notification-target-policy.test.ts create mode 100644 app/server/modules/notifications/utils/notification-target-policy.ts diff --git a/app/server/modules/notifications/__tests__/notification-target-policy.test.ts b/app/server/modules/notifications/__tests__/notification-target-policy.test.ts new file mode 100644 index 00000000..59fb7ac9 --- /dev/null +++ b/app/server/modules/notifications/__tests__/notification-target-policy.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "vitest"; +import { assertNotificationTargetAllowed } from "../utils/notification-target-policy"; + +describe("assertNotificationTargetAllowed", () => { + test("requires email SMTP targets to match the allowlist", () => { + const notificationConfig = { + type: "email" as const, + smtpHost: "smtp.example.com", + smtpPort: 587, + from: "backups@example.com", + to: ["admin@example.com"], + useTLS: true, + }; + + expect(() => assertNotificationTargetAllowed(notificationConfig, [])).toThrow( + "Add smtp://smtp.example.com:587 to WEBHOOK_ALLOWED_ORIGINS", + ); + + expect(() => assertNotificationTargetAllowed(notificationConfig, ["smtp://smtp.example.com:587"])).not.toThrow(); + expect(() => assertNotificationTargetAllowed(notificationConfig, ["smtp://smtp.example.com:25"])).toThrow(); + }); + + test("rejects notification types that are not classified by the SSRF policy", () => { + expect(() => assertNotificationTargetAllowed({ type: "future-networked" } as never, [])).toThrow( + 'Unsupported notification type "future-networked" for the SSRF policy.', + ); + }); + + test("allows fixed-provider custom Shoutrrr services without a network target allowlist", () => { + expect(() => + assertNotificationTargetAllowed({ type: "custom", shoutrrrUrl: "discord://token@webhook-id" }, []), + ).not.toThrow(); + }); + + test("requires custom generic targets to match the allowlist", () => { + expect(() => + assertNotificationTargetAllowed({ type: "custom", shoutrrrUrl: "generic://hooks.example.com/path" }, []), + ).toThrow("Add https://hooks.example.com to WEBHOOK_ALLOWED_ORIGINS"); + + expect(() => + assertNotificationTargetAllowed({ type: "custom", shoutrrrUrl: "generic://hooks.example.com/path" }, [ + "https://hooks.example.com", + ]), + ).not.toThrow(); + }); + + test("requires custom generic shortcut targets to match the allowlist", () => { + expect(() => + assertNotificationTargetAllowed({ type: "custom", shoutrrrUrl: "generic+http://127.0.0.1:8080/webhook" }, [ + "http://127.0.0.1:8080", + ]), + ).not.toThrow(); + }); + + test("requires custom smtp targets to match by scheme, host, and port", () => { + const notificationConfig = { + type: "custom" as const, + shoutrrrUrl: "smtp://127.0.0.1:2525/?from=test@example.com&to=admin@example.com", + }; + + expect(() => assertNotificationTargetAllowed(notificationConfig, [])).toThrow( + "Add smtp://127.0.0.1:2525 to WEBHOOK_ALLOWED_ORIGINS", + ); + + expect(() => assertNotificationTargetAllowed(notificationConfig, ["smtp://127.0.0.1:2525"])).not.toThrow(); + expect(() => assertNotificationTargetAllowed(notificationConfig, ["smtp://127.0.0.1:25"])).toThrow(); + }); + + test("rejects custom Shoutrrr schemes that are not classified by the SSRF policy", () => { + expect(() => + assertNotificationTargetAllowed({ type: "custom", shoutrrrUrl: "unknown://example.com/path" }, []), + ).toThrow('Custom Shoutrrr scheme "unknown" is not supported by the SSRF policy.'); + }); +}); diff --git a/app/server/modules/notifications/__tests__/notifications.controller.test.ts b/app/server/modules/notifications/__tests__/notifications.controller.test.ts index f79e7312..e595a1d0 100644 --- a/app/server/modules/notifications/__tests__/notifications.controller.test.ts +++ b/app/server/modules/notifications/__tests__/notifications.controller.test.ts @@ -121,5 +121,28 @@ describe("notifications security", () => { "Notification webhook URL origin is not allowed. Add https://hooks.example.com to WEBHOOK_ALLOWED_ORIGINS.", ); }); + + test("should return 400 for custom shoutrrr network targets outside the allowlist", async () => { + const res = await app.request("/api/v1/notifications/destinations", { + method: "POST", + headers: { + ...session.headers, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: "Blocked custom SMTP", + config: { + type: "custom", + shoutrrrUrl: "smtp://127.0.0.1:2525/?from=test@example.com&to=admin@example.com", + }, + }), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.message).toBe( + "Notification webhook URL origin is not allowed. Add smtp://127.0.0.1:2525 to WEBHOOK_ALLOWED_ORIGINS.", + ); + }); }); }); diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index ae553ebd..3a9138e9 100644 --- a/app/server/modules/notifications/notifications.service.ts +++ b/app/server/modules/notifications/notifications.service.ts @@ -7,18 +7,18 @@ import { type NotificationDestination, } from "../../db/schema"; import { logger, sanitizeSensitiveData } from "@zerobyte/core/node"; -import { isAllowedWebhookUrl } from "@zerobyte/core/backup-hooks"; import { sendNotification } from "../../utils/shoutrrr"; import { formatDuration } from "~/utils/utils"; import { buildShoutrrrUrl } from "./builders"; import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications"; import type { ResticBackupRunSummaryDto } from "@zerobyte/core/restic"; import { toMessage } from "../../utils/errors"; -import { config } from "~/server/core/config"; +import { config as serverConfig } from "~/server/core/config"; import { getOrganizationId } from "~/server/core/request-context"; import { formatBytes } from "~/utils/format-bytes"; import { decryptNotificationConfig, encryptNotificationConfig } from "./notification-config-secrets"; import { serverEvents } from "~/server/core/events"; +import { assertNotificationTargetAllowed } from "./utils/notification-target-policy"; const MAX_DELIVERY_ERROR_LENGTH = 2048; @@ -26,61 +26,6 @@ const formatDeliveryError = (error?: string) => { return sanitizeSensitiveData(error ?? "Unknown error").slice(0, MAX_DELIVERY_ERROR_LENGTH); }; -const getCustomShoutrrrWebhookUrl = (shoutrrrUrl: string) => { - if (!URL.canParse(shoutrrrUrl)) { - return null; - } - - const parsedUrl = new URL(shoutrrrUrl); - const protocol = parsedUrl.protocol.toLowerCase(); - - if (protocol === "generic:") { - const scheme = parsedUrl.searchParams.get("disabletls") === "yes" ? "http" : "https"; - return `${scheme}://${parsedUrl.host}`; - } - - if (protocol === "gotify:") { - const scheme = parsedUrl.searchParams.get("DisableTLS") === "true" ? "http" : "https"; - return `${scheme}://${parsedUrl.host}`; - } - - if (protocol === "ntfy:" && parsedUrl.hostname !== "ntfy.sh") { - const scheme = parsedUrl.searchParams.get("scheme") === "http" ? "http" : "https"; - return `${scheme}://${parsedUrl.host}`; - } - - return null; -}; - -const getNotificationWebhookUrl = (notificationConfig: NotificationConfig) => { - switch (notificationConfig.type) { - case "generic": - return notificationConfig.url; - case "gotify": - return notificationConfig.serverUrl; - case "ntfy": - return notificationConfig.serverUrl ?? null; - case "custom": - return getCustomShoutrrrWebhookUrl(notificationConfig.shoutrrrUrl); - default: - return null; - } -}; - -const assertNotificationWebhookOriginAllowed = (notificationConfig: NotificationConfig) => { - const webhookUrl = getNotificationWebhookUrl(notificationConfig); - if (!webhookUrl) { - return; - } - - if (!isAllowedWebhookUrl(webhookUrl, config.webhookAllowedOrigins)) { - const webhookOrigin = URL.canParse(webhookUrl) ? new URL(webhookUrl).origin : webhookUrl; - throw new BadRequestError( - `Notification webhook URL origin is not allowed. Add ${webhookOrigin} to WEBHOOK_ALLOWED_ORIGINS.`, - ); - } -}; - const listDestinations = async () => { const organizationId = getOrganizationId(); const destinations = await db.query.notificationDestinationsTable.findMany({ @@ -111,7 +56,7 @@ const createDestination = async (name: string, config: NotificationConfig) => { throw new BadRequestError("Name cannot be empty"); } - assertNotificationWebhookOriginAllowed(config); + assertNotificationTargetAllowed(config, serverConfig.webhookAllowedOrigins); const encryptedConfig = await encryptNotificationConfig(config); @@ -164,7 +109,7 @@ const updateDestination = async ( throw new BadRequestError("Invalid notification configuration"); } const newConfig = newConfigResult.data; - assertNotificationWebhookOriginAllowed(newConfig); + assertNotificationTargetAllowed(newConfig, serverConfig.webhookAllowedOrigins); const encryptedConfig = await encryptNotificationConfig(newConfig); updateData.config = encryptedConfig; @@ -223,7 +168,7 @@ const testDestination = async (id: number) => { try { const decryptedConfig = await decryptNotificationConfig(destination.config); - assertNotificationWebhookOriginAllowed(decryptedConfig); + assertNotificationTargetAllowed(decryptedConfig, serverConfig.webhookAllowedOrigins); const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); @@ -427,7 +372,7 @@ const sendBackupNotification = async ( for (const assignment of relevantAssignments) { try { const decryptedConfig = await decryptNotificationConfig(assignment.destination.config); - assertNotificationWebhookOriginAllowed(decryptedConfig); + assertNotificationTargetAllowed(decryptedConfig, serverConfig.webhookAllowedOrigins); const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); const result = await sendNotification({ shoutrrrUrl, title, body }); diff --git a/app/server/modules/notifications/utils/notification-target-policy.ts b/app/server/modules/notifications/utils/notification-target-policy.ts new file mode 100644 index 00000000..c79137e0 --- /dev/null +++ b/app/server/modules/notifications/utils/notification-target-policy.ts @@ -0,0 +1,156 @@ +import { BadRequestError } from "http-errors-enhanced"; +import type { NotificationConfig } from "~/schemas/notifications"; + +type ShoutrrrTargetGetter = (url: URL, scheme: string) => string | null; + +const getSearchParam = (url: URL, name: string) => { + const lowerName = name.toLowerCase(); + for (const [key, value] of url.searchParams.entries()) { + if (key.toLowerCase() === lowerName) { + return value; + } + } + return null; +}; + +const getHttpTarget = (url: URL, useHttp: boolean) => `${useHttp ? "http" : "https"}://${url.host}`; +const getSchemeTarget = (url: URL, scheme: string, defaultHost?: string) => { + const host = url.host || defaultHost; + return host ? `${scheme}://${host}` : null; +}; + +const fixedProviderShoutrrrSchemes = new Set([ + "discord", + "googlechat", + "hangouts", + "ifttt", + "join", + "logger", + "notifiarr", + "pushover", + "pushbullet", + "slack", + "teams", + "telegram", + "twilio", + "wecom", +]); + +const customShoutrrrTargets: Record = { + bark: (url) => getHttpTarget(url, getSearchParam(url, "scheme") === "http"), + generic: (url) => getHttpTarget(url, getSearchParam(url, "disabletls") === "yes"), + gotify: (url) => getHttpTarget(url, getSearchParam(url, "DisableTLS") === "true"), + lark: (url) => getHttpTarget(url, false), + matrix: (url) => getHttpTarget(url, getSearchParam(url, "disableTLS") === "yes"), + mattermost: (url) => getHttpTarget(url, getSearchParam(url, "disabletls") === "yes"), + mqtt: (url, scheme) => getSchemeTarget(url, scheme, "localhost"), + mqtts: (url, scheme) => getSchemeTarget(url, scheme, "localhost"), + ntfy: (url) => { + if (url.hostname === "ntfy.sh") { + return null; + } + + return getHttpTarget(url, getSearchParam(url, "scheme") === "http"); + }, + opsgenie: (url) => getHttpTarget(url, false), + pagerduty: (url) => getHttpTarget(url, false), + rocketchat: (url) => getHttpTarget(url, false), + signal: (url) => getHttpTarget(url, getSearchParam(url, "disabletls") === "yes"), + smtp: (url) => getSchemeTarget(url, "smtp"), + zulip: (url) => getHttpTarget(url, false), +}; + +const getComparableTarget = (target: string) => { + if (!URL.canParse(target)) { + return null; + } + + const url = new URL(target); + if (url.origin !== "null") { + return url.origin; + } + + return url.host ? `${url.protocol}//${url.host}` : null; +}; + +const isAllowedNotificationTarget = (target: string, allowedTargets: readonly string[]) => { + const comparableTarget = getComparableTarget(target); + return ( + comparableTarget !== null && + allowedTargets.some((allowedTarget) => getComparableTarget(allowedTarget) === comparableTarget) + ); +}; + +const assertTargetAllowed = (target: string, allowedTargets: readonly string[]) => { + if (!isAllowedNotificationTarget(target, allowedTargets)) { + throw new BadRequestError( + `Notification webhook URL origin is not allowed. Add ${getComparableTarget(target) ?? target} to WEBHOOK_ALLOWED_ORIGINS.`, + ); + } +}; + +const getCustomShoutrrrTarget = (shoutrrrUrl: string) => { + if (!URL.canParse(shoutrrrUrl)) { + throw new BadRequestError("Invalid custom Shoutrrr URL"); + } + + const parsedUrl = new URL(shoutrrrUrl); + const scheme = parsedUrl.protocol.slice(0, -1).toLowerCase(); + + if (scheme === "generic+http" || scheme === "generic+https") { + return `${scheme.slice("generic+".length)}://${parsedUrl.host}`; + } + + if (fixedProviderShoutrrrSchemes.has(scheme)) { + return null; + } + + const getTarget = customShoutrrrTargets[scheme]; + if (!getTarget) { + throw new BadRequestError(`Custom Shoutrrr scheme "${scheme}" is not supported by the SSRF policy.`); + } + + return getTarget(parsedUrl, scheme); +}; + +const getNotificationTarget = (notificationConfig: NotificationConfig) => { + switch (notificationConfig.type) { + case "email": { + const smtpTarget = new URL("smtp://placeholder"); + smtpTarget.hostname = notificationConfig.smtpHost; + smtpTarget.port = String(notificationConfig.smtpPort); + return `${smtpTarget.protocol}//${smtpTarget.host}`; + } + case "slack": + case "discord": + case "pushover": + case "telegram": + return null; + case "generic": + return notificationConfig.url; + case "gotify": + return notificationConfig.serverUrl; + case "ntfy": + return notificationConfig.serverUrl ?? null; + case "custom": + return getCustomShoutrrrTarget(notificationConfig.shoutrrrUrl); + default: { + const _exhaustive: never = notificationConfig; + throw new BadRequestError( + `Unsupported notification type "${(_exhaustive as NotificationConfig).type}" for the SSRF policy.`, + ); + } + } +}; + +export const assertNotificationTargetAllowed = ( + notificationConfig: NotificationConfig, + allowedTargets: readonly string[], +) => { + const target = getNotificationTarget(notificationConfig); + if (!target) { + return; + } + + assertTargetAllowed(target, allowedTargets); +};