fix(notifications): enforce allowlist for email notification targets

This commit is contained in:
Nicolas Meienberger 2026-05-04 17:16:49 +02:00 committed by Nico
parent 68a9816496
commit 17d9aa091a
2 changed files with 41 additions and 2 deletions

View file

@ -2,6 +2,30 @@ 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" }, []),

View file

@ -115,6 +115,17 @@ const getCustomShoutrrrTarget = (shoutrrrUrl: string) => {
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":
@ -123,8 +134,12 @@ const getNotificationTarget = (notificationConfig: NotificationConfig) => {
return notificationConfig.serverUrl ?? null;
case "custom":
return getCustomShoutrrrTarget(notificationConfig.shoutrrrUrl);
default:
return null;
default: {
const _exhaustive: never = notificationConfig;
throw new BadRequestError(
`Unsupported notification type "${(_exhaustive as NotificationConfig).type}" for the SSRF policy.`,
);
}
}
};