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
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:
parent
0351d5e0b9
commit
147f266929
4 changed files with 259 additions and 61 deletions
|
|
@ -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.');
|
||||
});
|
||||
});
|
||||
|
|
@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
Loading…
Reference in a new issue