diff --git a/app/server/modules/notifications/__tests__/notifications.controller.test.ts b/app/server/modules/notifications/__tests__/notifications.controller.test.ts index 06d529cf..f79e7312 100644 --- a/app/server/modules/notifications/__tests__/notifications.controller.test.ts +++ b/app/server/modules/notifications/__tests__/notifications.controller.test.ts @@ -97,5 +97,29 @@ describe("notifications security", () => { expect(res.status).toBe(400); }); + + test("should return 400 for notification webhook origins 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 webhook", + config: { + type: "generic", + url: "https://hooks.example.com/backup", + method: "POST", + }, + }), + }); + + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.message).toBe( + "Notification webhook URL origin is not allowed. Add https://hooks.example.com to WEBHOOK_ALLOWED_ORIGINS.", + ); + }); }); }); diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index 493979e6..38da4ce0 100644 --- a/app/server/modules/notifications/notifications.service.ts +++ b/app/server/modules/notifications/notifications.service.ts @@ -7,16 +7,73 @@ import { type NotificationDestination, } from "../../db/schema"; import { logger } 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 { getOrganizationId } from "~/server/core/request-context"; import { formatBytes } from "~/utils/format-bytes"; import { decryptNotificationConfig, encryptNotificationConfig } from "./notification-config-secrets"; +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({ @@ -47,6 +104,8 @@ const createDestination = async (name: string, config: NotificationConfig) => { throw new BadRequestError("Name cannot be empty"); } + assertNotificationWebhookOriginAllowed(config); + const encryptedConfig = await encryptNotificationConfig(config); const [created] = await db @@ -98,6 +157,7 @@ const updateDestination = async ( throw new BadRequestError("Invalid notification configuration"); } const newConfig = newConfigResult.data; + assertNotificationWebhookOriginAllowed(newConfig); const encryptedConfig = await encryptNotificationConfig(newConfig); updateData.config = encryptedConfig; @@ -132,6 +192,7 @@ const testDestination = async (id: number) => { const destination = await getDestination(id); const decryptedConfig = await decryptNotificationConfig(destination.config); + assertNotificationWebhookOriginAllowed(decryptedConfig); const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); @@ -329,6 +390,7 @@ const sendBackupNotification = async ( for (const assignment of relevantAssignments) { try { const decryptedConfig = await decryptNotificationConfig(assignment.destination.config); + assertNotificationWebhookOriginAllowed(decryptedConfig); const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); const result = await sendNotification({ shoutrrrUrl, title, body });