fix(notifications): validate notification webhook origins

This commit is contained in:
Nicolas Meienberger 2026-05-01 18:22:53 +02:00
parent 8fd54383a8
commit 94d7fdc9a8
No known key found for this signature in database
2 changed files with 86 additions and 0 deletions

View file

@ -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.",
);
});
});
});

View file

@ -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 });