fix(notifications): validate notification webhook origins
This commit is contained in:
parent
8fd54383a8
commit
94d7fdc9a8
2 changed files with 86 additions and 0 deletions
|
|
@ -97,5 +97,29 @@ describe("notifications security", () => {
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
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.",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,73 @@ import {
|
||||||
type NotificationDestination,
|
type NotificationDestination,
|
||||||
} from "../../db/schema";
|
} from "../../db/schema";
|
||||||
import { logger } from "@zerobyte/core/node";
|
import { logger } from "@zerobyte/core/node";
|
||||||
|
import { isAllowedWebhookUrl } from "@zerobyte/core/backup-hooks";
|
||||||
import { sendNotification } from "../../utils/shoutrrr";
|
import { sendNotification } from "../../utils/shoutrrr";
|
||||||
import { formatDuration } from "~/utils/utils";
|
import { formatDuration } from "~/utils/utils";
|
||||||
import { buildShoutrrrUrl } from "./builders";
|
import { buildShoutrrrUrl } from "./builders";
|
||||||
import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications";
|
import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications";
|
||||||
import type { ResticBackupRunSummaryDto } from "@zerobyte/core/restic";
|
import type { ResticBackupRunSummaryDto } from "@zerobyte/core/restic";
|
||||||
import { toMessage } from "../../utils/errors";
|
import { toMessage } from "../../utils/errors";
|
||||||
|
import { config } from "~/server/core/config";
|
||||||
import { getOrganizationId } from "~/server/core/request-context";
|
import { getOrganizationId } from "~/server/core/request-context";
|
||||||
import { formatBytes } from "~/utils/format-bytes";
|
import { formatBytes } from "~/utils/format-bytes";
|
||||||
import { decryptNotificationConfig, encryptNotificationConfig } from "./notification-config-secrets";
|
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 listDestinations = async () => {
|
||||||
const organizationId = getOrganizationId();
|
const organizationId = getOrganizationId();
|
||||||
const destinations = await db.query.notificationDestinationsTable.findMany({
|
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");
|
throw new BadRequestError("Name cannot be empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertNotificationWebhookOriginAllowed(config);
|
||||||
|
|
||||||
const encryptedConfig = await encryptNotificationConfig(config);
|
const encryptedConfig = await encryptNotificationConfig(config);
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
|
|
@ -98,6 +157,7 @@ const updateDestination = async (
|
||||||
throw new BadRequestError("Invalid notification configuration");
|
throw new BadRequestError("Invalid notification configuration");
|
||||||
}
|
}
|
||||||
const newConfig = newConfigResult.data;
|
const newConfig = newConfigResult.data;
|
||||||
|
assertNotificationWebhookOriginAllowed(newConfig);
|
||||||
|
|
||||||
const encryptedConfig = await encryptNotificationConfig(newConfig);
|
const encryptedConfig = await encryptNotificationConfig(newConfig);
|
||||||
updateData.config = encryptedConfig;
|
updateData.config = encryptedConfig;
|
||||||
|
|
@ -132,6 +192,7 @@ const testDestination = async (id: number) => {
|
||||||
const destination = await getDestination(id);
|
const destination = await getDestination(id);
|
||||||
|
|
||||||
const decryptedConfig = await decryptNotificationConfig(destination.config);
|
const decryptedConfig = await decryptNotificationConfig(destination.config);
|
||||||
|
assertNotificationWebhookOriginAllowed(decryptedConfig);
|
||||||
|
|
||||||
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
||||||
|
|
||||||
|
|
@ -329,6 +390,7 @@ const sendBackupNotification = async (
|
||||||
for (const assignment of relevantAssignments) {
|
for (const assignment of relevantAssignments) {
|
||||||
try {
|
try {
|
||||||
const decryptedConfig = await decryptNotificationConfig(assignment.destination.config);
|
const decryptedConfig = await decryptNotificationConfig(assignment.destination.config);
|
||||||
|
assertNotificationWebhookOriginAllowed(decryptedConfig);
|
||||||
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
|
||||||
|
|
||||||
const result = await sendNotification({ shoutrrrUrl, title, body });
|
const result = await sendNotification({ shoutrrrUrl, title, body });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue