From e981211a2d89c2fa481f6b2824b6d0e244d9e7dd Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Mon, 4 May 2026 21:30:09 +0200 Subject: [PATCH] fix(notifications): validate webhook headers and show delivery health --- README.md | 20 ++++++++++++++++++ .../create-schedule-form/advanced-section.tsx | 4 +++- .../notification-forms/custom-form.tsx | 3 ++- .../notification-forms/generic-form.tsx | 17 +++++++++++---- .../notification-forms/gotify-form.tsx | 4 +++- .../notification-forms/ntfy-form.tsx | 5 ++++- .../routes/notification-details.tsx | 21 +++++++++++++++++++ app/schemas/notifications.ts | 9 +++++++- .../src/backup-hooks/__tests__/hooks.test.ts | 12 ++++++++++- packages/core/src/backup-hooks/index.ts | 10 ++++++++- 10 files changed, 94 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c0af7145..514fe9d7 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,26 @@ Zerobyte can be customized using environment variables. Below are the available | `RCLONE_CONFIG_DIR` | Path to the directory containing `rclone.conf` inside the container. Change this if running as a non-root user. | `/root/.config/rclone` | | `PROVISIONING_PATH` | Path to a JSON file with operator-managed repositories and volumes to sync at startup. | (none) | +### Webhook and Notification Network Policy + +Backup webhooks and outbound notification destinations that can target arbitrary network hosts are restricted by `WEBHOOK_ALLOWED_ORIGINS`. + +The allowlist matches exact origins only: scheme, host, and port must match. Paths are ignored, so `https://hooks.example.com/backups` allows any path on `https://hooks.example.com`, but it does not allow `http://hooks.example.com`, `https://hooks.example.com:8443`, or `https://other.example.com`. + +This policy applies to: + +- backup pre/post webhook URLs +- Generic HTTP notification URLs +- Gotify server URLs +- self-hosted ntfy server URLs +- custom Shoutrrr URLs that point at generic HTTP or SMTP network targets + +The public ntfy.sh service and fixed-provider notification services such as Slack, Discord, Pushover, and Telegram do not need `WEBHOOK_ALLOWED_ORIGINS`. + +Backup webhooks do not follow redirects. Add the final destination origin to `WEBHOOK_ALLOWED_ORIGINS` and configure that final URL directly. + +Webhook headers are stored as plain text and must use one `Key: Value` header per line. `WEBHOOK_TIMEOUT` controls backup pre/post webhook request timeouts; notification delivery uses the underlying provider sender behavior. + ### Provisioned Resources Zerobyte can sync operator-managed repositories and volumes from a JSON file at startup. This is useful when you want credentials or connection details to live in deployment-time configuration instead of being entered through the UI. diff --git a/app/client/modules/backups/components/create-schedule-form/advanced-section.tsx b/app/client/modules/backups/components/create-schedule-form/advanced-section.tsx index 0b6c3d33..b8528f73 100644 --- a/app/client/modules/backups/components/create-schedule-form/advanced-section.tsx +++ b/app/client/modules/backups/components/create-schedule-form/advanced-section.tsx @@ -31,7 +31,9 @@ const WebhookFields = ({ form, phase, urlPlaceholder, bodyPlaceholder, descripti - {description} + + {description} The URL origin must be listed in WEBHOOK_ALLOWED_ORIGINS; redirects are not followed. + )} diff --git a/app/client/modules/notifications/components/notification-forms/custom-form.tsx b/app/client/modules/notifications/components/notification-forms/custom-form.tsx index 165ba644..051462a6 100644 --- a/app/client/modules/notifications/components/notification-forms/custom-form.tsx +++ b/app/client/modules/notifications/components/notification-forms/custom-form.tsx @@ -31,7 +31,8 @@ export const CustomForm = ({ form }: Props) => { > Shoutrrr documentation -  for supported services and URL formats. +  for supported services and URL formats. Custom HTTP, generic, and SMTP network targets must be listed + in WEBHOOK_ALLOWED_ORIGINS. diff --git a/app/client/modules/notifications/components/notification-forms/generic-form.tsx b/app/client/modules/notifications/components/notification-forms/generic-form.tsx index 8f46fe21..9c8a13ae 100644 --- a/app/client/modules/notifications/components/notification-forms/generic-form.tsx +++ b/app/client/modules/notifications/components/notification-forms/generic-form.tsx @@ -15,7 +15,7 @@ const WebhookPreview = ({ values }: { values: Partial }) if (values.type !== "generic") return null; const contentType = values.contentType || "application/json"; - const headers = values.headers || []; + const headers = values.headers?.filter(Boolean) || []; const useJson = values.useJson; const titleKey = values.titleKey || "title"; const messageKey = values.messageKey || "message"; @@ -60,7 +60,7 @@ export const GenericForm = ({ form }: Props) => { - The target URL for the webhook. + The target origin must be listed in WEBHOOK_ALLOWED_ORIGINS. )} @@ -110,10 +110,19 @@ export const GenericForm = ({ form }: Props) => { {...field} placeholder="Authorization: Bearer token X-Custom-Header: value" value={Array.isArray(field.value) ? field.value.join("\n") : ""} - onChange={(e) => field.onChange(e.target.value.split("\n"))} + onChange={(e) => + field.onChange( + e.target.value + .split("\n") + .map((header) => header.trim()) + .filter(Boolean), + ) + } /> - One header per line in Key: Value format. + + One header per line in Key: Value format. Values are stored as plain text. + )} diff --git a/app/client/modules/notifications/components/notification-forms/gotify-form.tsx b/app/client/modules/notifications/components/notification-forms/gotify-form.tsx index 0c8126f8..78cfdc34 100644 --- a/app/client/modules/notifications/components/notification-forms/gotify-form.tsx +++ b/app/client/modules/notifications/components/notification-forms/gotify-form.tsx @@ -20,7 +20,9 @@ export const GotifyForm = ({ form }: Props) => { - Your self-hosted Gotify server URL. + + Your self-hosted Gotify server URL. Its origin must be listed in WEBHOOK_ALLOWED_ORIGINS. + )} diff --git a/app/client/modules/notifications/components/notification-forms/ntfy-form.tsx b/app/client/modules/notifications/components/notification-forms/ntfy-form.tsx index 16ecec98..db9a473a 100644 --- a/app/client/modules/notifications/components/notification-forms/ntfy-form.tsx +++ b/app/client/modules/notifications/components/notification-forms/ntfy-form.tsx @@ -21,7 +21,10 @@ export const NtfyForm = ({ form }: Props) => { - Leave empty to use ntfy.sh public service. + + Leave empty to use ntfy.sh public service. Self-hosted ntfy origins must be listed in + WEBHOOK_ALLOWED_ORIGINS. + )} diff --git a/app/client/modules/notifications/routes/notification-details.tsx b/app/client/modules/notifications/routes/notification-details.tsx index 314f695c..7340967d 100644 --- a/app/client/modules/notifications/routes/notification-details.tsx +++ b/app/client/modules/notifications/routes/notification-details.tsx @@ -41,6 +41,7 @@ import { Mail, AtSign, AlertCircle, + Clock, MessageSquare, Pencil, Server, @@ -298,6 +299,26 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st + + + + Delivery Health + +
+ } + label="Status" + value={getStatusLabel(data.enabled, data.status)} + /> + } label="Enabled" value={data.enabled ? "Yes" : "No"} /> + } + label="Last Checked" + value={data.lastChecked ? formatDateTime(data.lastChecked) : "Never"} + /> +
+
+ diff --git a/app/schemas/notifications.ts b/app/schemas/notifications.ts index 4ff14fb0..774536e1 100644 --- a/app/schemas/notifications.ts +++ b/app/schemas/notifications.ts @@ -14,6 +14,13 @@ export const NOTIFICATION_TYPES = { export type NotificationType = keyof typeof NOTIFICATION_TYPES; +const headerNamePattern = /^[A-Za-z0-9-]+$/; +const notificationHeaderSchema = z.string().refine((header) => { + const [key, value] = header.split(":", 2); + + return !!key && headerNamePattern.test(key.trim()) && (value?.trim().length ?? 0) > 0; +}, "Headers must use non-empty Key: Value format with valid header names"); + export const emailNotificationConfigSchema = z.object({ type: z.literal("email"), smtpHost: z.string().min(1), @@ -79,7 +86,7 @@ export const genericNotificationConfigSchema = z.object({ url: z.string().min(1), method: z.enum(["GET", "POST"]), contentType: z.string().optional(), - headers: z.array(z.string()).optional(), + headers: z.array(notificationHeaderSchema).optional(), useJson: z.boolean().optional(), titleKey: z.string().optional(), messageKey: z.string().optional(), diff --git a/packages/core/src/backup-hooks/__tests__/hooks.test.ts b/packages/core/src/backup-hooks/__tests__/hooks.test.ts index 6130f6f7..6a2e4adf 100644 --- a/packages/core/src/backup-hooks/__tests__/hooks.test.ts +++ b/packages/core/src/backup-hooks/__tests__/hooks.test.ts @@ -2,7 +2,7 @@ import { Effect } from "effect"; import { HttpResponse, http } from "msw"; import { setupServer } from "msw/node"; import { afterAll, afterEach, beforeAll, expect, test } from "vitest"; -import { runBackupLifecycle } from "../index.js"; +import { backupWebhookConfigSchema, runBackupLifecycle } from "../index.js"; const server = setupServer(); @@ -458,6 +458,16 @@ test("rejects oversized webhook request bodies and headers", async () => { expect(headersResult).toEqual({ status: "failed", error: "Webhook request headers exceed 8192 bytes" }); }); +test("rejects malformed webhook header lines", () => { + expect(() => backupWebhookConfigSchema.parse({ url: "http://localhost:8080/pre", headers: ["Malformed"] })).toThrow( + "Headers must use non-empty Key: Value format with valid header names", + ); + + expect(() => + backupWebhookConfigSchema.parse({ url: "http://localhost:8080/pre", headers: ["Bad Header: value"] }), + ).toThrow("Headers must use non-empty Key: Value format with valid header names"); +}); + test("cancels before the pre-backup webhook without running the backup", async () => { const abortController = new AbortController(); let backupRan = false; diff --git a/packages/core/src/backup-hooks/index.ts b/packages/core/src/backup-hooks/index.ts index 7e46b6e2..bb33356f 100644 --- a/packages/core/src/backup-hooks/index.ts +++ b/packages/core/src/backup-hooks/index.ts @@ -6,9 +6,15 @@ import { toErrorDetails, toMessage } from "../utils/index.js"; const MAX_BACKUP_WEBHOOK_BODY_BYTES = 64 * 1024; const MAX_BACKUP_WEBHOOK_HEADERS = 32; const MAX_BACKUP_WEBHOOK_HEADER_BYTES = 8 * 1024; +const HEADER_NAME_PATTERN = /^[A-Za-z0-9-]+$/; const getByteLength = (value: string) => new TextEncoder().encode(value).byteLength; const getUrlOrigin = (url: string) => (URL.canParse(url) ? new URL(url).origin : null); +const isValidHeaderLine = (header: string) => { + const [key, value] = header.split(":", 2); + + return !!key && HEADER_NAME_PATTERN.test(key.trim()) && (value?.trim().length ?? 0) > 0; +}; export const isAllowedWebhookUrl = (url: string, allowedOrigins: readonly string[]) => { const webhookOrigin = getUrlOrigin(url); @@ -17,7 +23,9 @@ export const isAllowedWebhookUrl = (url: string, allowedOrigins: readonly string export const backupWebhookConfigSchema = z.object({ url: z.url(), - headers: z.array(z.string()).optional(), + headers: z + .array(z.string().refine(isValidHeaderLine, "Headers must use non-empty Key: Value format with valid header names")) + .optional(), body: z.string().optional(), });