mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-05-25 00:49:32 -04:00
fix(notifications): validate webhook headers and show delivery health
This commit is contained in:
20
README.md
20
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.
|
||||
|
||||
@@ -31,7 +31,9 @@ const WebhookFields = ({ form, phase, urlPlaceholder, bodyPlaceholder, descripti
|
||||
<FormControl>
|
||||
<Input {...field} type="url" placeholder={urlPlaceholder} />
|
||||
</FormControl>
|
||||
<FormDescription>{description}</FormDescription>
|
||||
<FormDescription>
|
||||
{description} The URL origin must be listed in WEBHOOK_ALLOWED_ORIGINS; redirects are not followed.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,8 @@ export const CustomForm = ({ form }: Props) => {
|
||||
>
|
||||
Shoutrrr documentation
|
||||
</a>
|
||||
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.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@@ -15,7 +15,7 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
|
||||
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) => {
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://api.example.com/webhook" />
|
||||
</FormControl>
|
||||
<FormDescription>The target URL for the webhook.</FormDescription>
|
||||
<FormDescription>The target origin must be listed in WEBHOOK_ALLOWED_ORIGINS.</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>One header per line in Key: Value format.</FormDescription>
|
||||
<FormDescription>
|
||||
One header per line in Key: Value format. Values are stored as plain text.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -20,7 +20,9 @@ export const GotifyForm = ({ form }: Props) => {
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://gotify.example.com" />
|
||||
</FormControl>
|
||||
<FormDescription>Your self-hosted Gotify server URL.</FormDescription>
|
||||
<FormDescription>
|
||||
Your self-hosted Gotify server URL. Its origin must be listed in WEBHOOK_ALLOWED_ORIGINS.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -21,7 +21,10 @@ export const NtfyForm = ({ form }: Props) => {
|
||||
<FormControl>
|
||||
<Input {...field} placeholder="https://ntfy.example.com" />
|
||||
</FormControl>
|
||||
<FormDescription>Leave empty to use ntfy.sh public service.</FormDescription>
|
||||
<FormDescription>
|
||||
Leave empty to use ntfy.sh public service. Self-hosted ntfy origins must be listed in
|
||||
WEBHOOK_ALLOWED_ORIGINS.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
Mail,
|
||||
AtSign,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
Server,
|
||||
@@ -298,6 +299,26 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="px-6 py-6">
|
||||
<CardTitle className="flex items-center gap-2 mb-5">
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
Delivery Health
|
||||
</CardTitle>
|
||||
<div className="space-y-0 divide-y divide-border/50">
|
||||
<ConfigRow
|
||||
icon={<Bell className="h-4 w-4" />}
|
||||
label="Status"
|
||||
value={getStatusLabel(data.enabled, data.status)}
|
||||
/>
|
||||
<ConfigRow icon={<Settings className="h-4 w-4" />} label="Enabled" value={data.enabled ? "Yes" : "No"} />
|
||||
<ConfigRow
|
||||
icon={<Clock className="h-4 w-4" />}
|
||||
label="Last Checked"
|
||||
value={data.lastChecked ? formatDateTime(data.lastChecked) : "Never"}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={cn("px-6 py-6", { hidden: !data.lastError })}>
|
||||
<CardTitle className="flex items-center gap-2 mb-3 text-red-600">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user