fix(notifications): enforce allowlist for custom Shoutrrr targets (#853)
Some checks failed
Release Workflow / e2e-tests (push) Has been cancelled
Release Workflow / determine-release-type (push) Has been cancelled
Release Workflow / checks (push) Has been cancelled
Release Workflow / build-images (push) Has been cancelled
Release Workflow / publish-release (push) Has been cancelled
Release Workflow / request-docs-version-update (push) Has been cancelled

* fix(notifications): enforce allowlist for custom Shoutrrr targets

* fix(notifications): enforce allowlist for email notification targets
This commit is contained in:
Nico 2026-05-04 17:47:44 +02:00 committed by GitHub
parent 0351d5e0b9
commit 147f266929
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 259 additions and 61 deletions

View file

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

View file

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

View file

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

View file

@ -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<string, ShoutrrrTargetGetter> = {
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);
};