Compare commits
3 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d66a20686 | ||
|
|
ade31e7e95 | ||
|
|
6a47af58c7 |
17 changed files with 180 additions and 61 deletions
28
.github/workflows/release.yml
vendored
28
.github/workflows/release.yml
vendored
|
|
@ -79,34 +79,6 @@ jobs:
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build docker image
|
|
||||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
target: production
|
|
||||||
platforms: linux/amd64
|
|
||||||
push: false
|
|
||||||
load: true
|
|
||||||
tags: local/zerobyte:ci
|
|
||||||
build-args: |
|
|
||||||
APP_VERSION=${{ needs.determine-release-type.outputs.tagname }}
|
|
||||||
|
|
||||||
- name: Scan new image for vulnerabilities
|
|
||||||
if: needs.determine-release-type.outputs.release_type == 'release'
|
|
||||||
uses: anchore/scan-action@e1165082ffb1fe366ebaf02d8526e7c4989ea9d2 # v7
|
|
||||||
id: scan
|
|
||||||
with:
|
|
||||||
image: local/zerobyte:ci
|
|
||||||
fail-build: false
|
|
||||||
only-fixed: true
|
|
||||||
severity-cutoff: critical
|
|
||||||
|
|
||||||
- name: upload Anchore scan report
|
|
||||||
if: needs.determine-release-type.outputs.release_type == 'release'
|
|
||||||
uses: github/codeql-action/upload-sarif@c10b8064de6f491fea524254123dbe5e09572f13 # v4
|
|
||||||
with:
|
|
||||||
sarif_file: ${{ steps.scan.outputs.sarif }}
|
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6
|
||||||
|
|
|
||||||
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` |
|
| `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) |
|
| `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
|
### 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.
|
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>
|
<FormControl>
|
||||||
<Input {...field} type="url" placeholder={urlPlaceholder} />
|
<Input {...field} type="url" placeholder={urlPlaceholder} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>{description}</FormDescription>
|
<FormDescription>
|
||||||
|
{description} The URL origin must be listed in WEBHOOK_ALLOWED_ORIGINS; redirects are not followed.
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,8 @@ export const CustomForm = ({ form }: Props) => {
|
||||||
>
|
>
|
||||||
Shoutrrr documentation
|
Shoutrrr documentation
|
||||||
</a>
|
</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>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
|
||||||
if (values.type !== "generic") return null;
|
if (values.type !== "generic") return null;
|
||||||
|
|
||||||
const contentType = values.contentType || "application/json";
|
const contentType = values.contentType || "application/json";
|
||||||
const headers = values.headers || [];
|
const headers = values.headers?.filter(Boolean) || [];
|
||||||
const useJson = values.useJson;
|
const useJson = values.useJson;
|
||||||
const titleKey = values.titleKey || "title";
|
const titleKey = values.titleKey || "title";
|
||||||
const messageKey = values.messageKey || "message";
|
const messageKey = values.messageKey || "message";
|
||||||
|
|
@ -60,7 +60,7 @@ export const GenericForm = ({ form }: Props) => {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://api.example.com/webhook" />
|
<Input {...field} placeholder="https://api.example.com/webhook" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>The target URL for the webhook.</FormDescription>
|
<FormDescription>The target origin must be listed in WEBHOOK_ALLOWED_ORIGINS.</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -110,10 +110,19 @@ export const GenericForm = ({ form }: Props) => {
|
||||||
{...field}
|
{...field}
|
||||||
placeholder="Authorization: Bearer token X-Custom-Header: value"
|
placeholder="Authorization: Bearer token X-Custom-Header: value"
|
||||||
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
|
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>
|
</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 />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@ export const GotifyForm = ({ form }: Props) => {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://gotify.example.com" />
|
<Input {...field} placeholder="https://gotify.example.com" />
|
||||||
</FormControl>
|
</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 />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ export const NtfyForm = ({ form }: Props) => {
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} placeholder="https://ntfy.example.com" />
|
<Input {...field} placeholder="https://ntfy.example.com" />
|
||||||
</FormControl>
|
</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 />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import {
|
||||||
Mail,
|
Mail,
|
||||||
AtSign,
|
AtSign,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
Clock,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Pencil,
|
Pencil,
|
||||||
Server,
|
Server,
|
||||||
|
|
@ -298,6 +299,26 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 })}>
|
<Card className={cn("px-6 py-6", { hidden: !data.lastError })}>
|
||||||
<CardTitle className="flex items-center gap-2 mb-3 text-red-600">
|
<CardTitle className="flex items-center gap-2 mb-3 text-red-600">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ export const NOTIFICATION_TYPES = {
|
||||||
|
|
||||||
export type NotificationType = keyof typeof 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({
|
export const emailNotificationConfigSchema = z.object({
|
||||||
type: z.literal("email"),
|
type: z.literal("email"),
|
||||||
smtpHost: z.string().min(1),
|
smtpHost: z.string().min(1),
|
||||||
|
|
@ -79,7 +86,7 @@ export const genericNotificationConfigSchema = z.object({
|
||||||
url: z.string().min(1),
|
url: z.string().min(1),
|
||||||
method: z.enum(["GET", "POST"]),
|
method: z.enum(["GET", "POST"]),
|
||||||
contentType: z.string().optional(),
|
contentType: z.string().optional(),
|
||||||
headers: z.array(z.string()).optional(),
|
headers: z.array(notificationHeaderSchema).optional(),
|
||||||
useJson: z.boolean().optional(),
|
useJson: z.boolean().optional(),
|
||||||
titleKey: z.string().optional(),
|
titleKey: z.string().optional(),
|
||||||
messageKey: z.string().optional(),
|
messageKey: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -424,6 +424,7 @@ describe("backup execution - validation failures", () => {
|
||||||
describe("stop backup", () => {
|
describe("stop backup", () => {
|
||||||
test("should keep restic warning details when backup completes with read errors", async () => {
|
test("should keep restic warning details when backup completes with read errors", async () => {
|
||||||
const { resticBackupMock } = setup();
|
const { resticBackupMock } = setup();
|
||||||
|
const notificationSpy = vi.spyOn(notificationsService, "sendBackupNotification").mockResolvedValue();
|
||||||
const volume = await createTestVolume();
|
const volume = await createTestVolume();
|
||||||
const repository = await createTestRepository();
|
const repository = await createTestRepository();
|
||||||
const schedule = await createTestBackupSchedule({
|
const schedule = await createTestBackupSchedule({
|
||||||
|
|
@ -446,6 +447,11 @@ describe("stop backup", () => {
|
||||||
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
|
const updatedSchedule = await getScheduleByIdOrShortId(schedule.id);
|
||||||
expect(updatedSchedule.lastBackupStatus).toBe("warning");
|
expect(updatedSchedule.lastBackupStatus).toBe("warning");
|
||||||
expect(updatedSchedule.lastBackupError).toBe("error: open /mnt/data/private.db: permission denied");
|
expect(updatedSchedule.lastBackupError).toBe("error: open /mnt/data/private.db: permission denied");
|
||||||
|
expect(notificationSpy).toHaveBeenLastCalledWith(
|
||||||
|
schedule.id,
|
||||||
|
"warning",
|
||||||
|
expect.objectContaining({ error: "error: open /mnt/data/private.db: permission denied" }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should store restic diagnostic details instead of the generic summary on hard failure", async () => {
|
test("should store restic diagnostic details instead of the generic summary on hard failure", async () => {
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,7 @@ export async function finalizeSuccessfulBackup(
|
||||||
volumeName: ctx.volume.name,
|
volumeName: ctx.volume.name,
|
||||||
repositoryName: ctx.repository.name,
|
repositoryName: ctx.repository.name,
|
||||||
scheduleName: ctx.schedule.name,
|
scheduleName: ctx.schedule.name,
|
||||||
|
error: finalStatus === "warning" ? (warningDetails ?? undefined) : undefined,
|
||||||
summary: result ?? undefined,
|
summary: result ?? undefined,
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||||
import { assertNotificationTargetAllowed } from "../utils/notification-target-policy";
|
import { assertNotificationTargetAllowed } from "../utils/notification-target-policy";
|
||||||
|
|
||||||
describe("assertNotificationTargetAllowed", () => {
|
describe("assertNotificationTargetAllowed", () => {
|
||||||
test("requires email SMTP targets to match the allowlist", () => {
|
test("allows built-in email SMTP targets without the webhook allowlist", () => {
|
||||||
const notificationConfig = {
|
const notificationConfig = {
|
||||||
type: "email" as const,
|
type: "email" as const,
|
||||||
smtpHost: "smtp.example.com",
|
smtpHost: "smtp.example.com",
|
||||||
|
|
@ -12,12 +12,7 @@ describe("assertNotificationTargetAllowed", () => {
|
||||||
useTLS: true,
|
useTLS: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(() => assertNotificationTargetAllowed(notificationConfig, [])).toThrow(
|
expect(() => assertNotificationTargetAllowed(notificationConfig, [])).not.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", () => {
|
test("rejects notification types that are not classified by the SSRF policy", () => {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { createTestSession } from "~/test/helpers/auth";
|
||||||
import * as shoutrrr from "~/server/utils/shoutrrr";
|
import * as shoutrrr from "~/server/utils/shoutrrr";
|
||||||
import { notificationsService } from "../notifications.service";
|
import { notificationsService } from "../notifications.service";
|
||||||
import { serverEvents } from "~/server/core/events";
|
import { serverEvents } from "~/server/core/events";
|
||||||
|
import { cryptoUtils } from "~/server/utils/crypto";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
|
@ -84,3 +85,62 @@ describe("notificationsService.testDestination", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("notificationsService.updateDestination", () => {
|
||||||
|
test("updates metadata for an existing encrypted custom destination without revalidating the encrypted URL", async () => {
|
||||||
|
const { organizationId, user } = await createTestSession();
|
||||||
|
|
||||||
|
await withContext({ organizationId, userId: user.id }, async () => {
|
||||||
|
const resolveSecretSpy = vi.spyOn(cryptoUtils, "resolveSecret").mockResolvedValue("discord://token@webhookid");
|
||||||
|
|
||||||
|
const [destination] = await db
|
||||||
|
.insert(notificationDestinationsTable)
|
||||||
|
.values({
|
||||||
|
name: "Custom webhook",
|
||||||
|
type: "custom",
|
||||||
|
config: { type: "custom", shoutrrrUrl: "encv1:stored-secret" },
|
||||||
|
organizationId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const updated = await notificationsService.updateDestination(destination.id, { name: "Renamed webhook" });
|
||||||
|
|
||||||
|
expect(updated.name).toBe("Renamed webhook");
|
||||||
|
expect(updated.config).toMatchObject({ type: "custom", shoutrrrUrl: "encv1:stored-secret" });
|
||||||
|
expect(resolveSecretSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("updates metadata for an existing destination without requiring webhook allowlist when config is unchanged", async () => {
|
||||||
|
const { organizationId, user } = await createTestSession();
|
||||||
|
|
||||||
|
await withContext({ organizationId, userId: user.id }, async () => {
|
||||||
|
const existingConfig = {
|
||||||
|
type: "generic" as const,
|
||||||
|
url: "https://hooks.example.com/backup",
|
||||||
|
method: "POST" as const,
|
||||||
|
};
|
||||||
|
const [destination] = await db
|
||||||
|
.insert(notificationDestinationsTable)
|
||||||
|
.values({
|
||||||
|
name: "Generic webhook",
|
||||||
|
type: "generic",
|
||||||
|
config: existingConfig,
|
||||||
|
organizationId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
const updated = await notificationsService.updateDestination(destination.id, {
|
||||||
|
name: "Renamed webhook",
|
||||||
|
config: existingConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(updated.name).toBe("Renamed webhook");
|
||||||
|
expect(updated.config).toMatchObject({
|
||||||
|
type: "generic",
|
||||||
|
url: "https://hooks.example.com/backup",
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -104,16 +104,23 @@ const updateDestination = async (
|
||||||
updateData.enabled = updates.enabled;
|
updateData.enabled = updates.enabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newConfigResult = notificationConfigSchema.safeParse(updates.config || existing.config);
|
if (updates.config !== undefined) {
|
||||||
if (!newConfigResult.success) {
|
const newConfigResult = notificationConfigSchema.safeParse(updates.config);
|
||||||
throw new BadRequestError("Invalid notification configuration");
|
if (!newConfigResult.success) {
|
||||||
}
|
throw new BadRequestError("Invalid notification configuration");
|
||||||
const newConfig = newConfigResult.data;
|
}
|
||||||
assertNotificationTargetAllowed(newConfig, serverConfig.webhookAllowedOrigins);
|
const newConfig = newConfigResult.data;
|
||||||
|
const resolvedConfig = await decryptNotificationConfig(newConfig);
|
||||||
|
const existingConfig = await decryptNotificationConfig(existing.config);
|
||||||
|
|
||||||
const encryptedConfig = await encryptNotificationConfig(newConfig);
|
if (JSON.stringify(resolvedConfig) !== JSON.stringify(existingConfig)) {
|
||||||
updateData.config = encryptedConfig;
|
assertNotificationTargetAllowed(resolvedConfig, serverConfig.webhookAllowedOrigins);
|
||||||
updateData.type = newConfig.type;
|
|
||||||
|
const encryptedConfig = await encryptNotificationConfig(newConfig);
|
||||||
|
updateData.config = encryptedConfig;
|
||||||
|
updateData.type = newConfig.type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
.update(notificationDestinationsTable)
|
.update(notificationDestinationsTable)
|
||||||
|
|
|
||||||
|
|
@ -115,12 +115,7 @@ const getCustomShoutrrrTarget = (shoutrrrUrl: string) => {
|
||||||
|
|
||||||
const getNotificationTarget = (notificationConfig: NotificationConfig) => {
|
const getNotificationTarget = (notificationConfig: NotificationConfig) => {
|
||||||
switch (notificationConfig.type) {
|
switch (notificationConfig.type) {
|
||||||
case "email": {
|
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 "slack":
|
||||||
case "discord":
|
case "discord":
|
||||||
case "pushover":
|
case "pushover":
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Effect } from "effect";
|
||||||
import { HttpResponse, http } from "msw";
|
import { HttpResponse, http } from "msw";
|
||||||
import { setupServer } from "msw/node";
|
import { setupServer } from "msw/node";
|
||||||
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
|
import { afterAll, afterEach, beforeAll, expect, test } from "vitest";
|
||||||
import { runBackupLifecycle } from "../index.js";
|
import { backupWebhookConfigSchema, runBackupLifecycle } from "../index.js";
|
||||||
|
|
||||||
const server = setupServer();
|
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" });
|
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 () => {
|
test("cancels before the pre-backup webhook without running the backup", async () => {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
let backupRan = false;
|
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_BODY_BYTES = 64 * 1024;
|
||||||
const MAX_BACKUP_WEBHOOK_HEADERS = 32;
|
const MAX_BACKUP_WEBHOOK_HEADERS = 32;
|
||||||
const MAX_BACKUP_WEBHOOK_HEADER_BYTES = 8 * 1024;
|
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 getByteLength = (value: string) => new TextEncoder().encode(value).byteLength;
|
||||||
const getUrlOrigin = (url: string) => (URL.canParse(url) ? new URL(url).origin : null);
|
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[]) => {
|
export const isAllowedWebhookUrl = (url: string, allowedOrigins: readonly string[]) => {
|
||||||
const webhookOrigin = getUrlOrigin(url);
|
const webhookOrigin = getUrlOrigin(url);
|
||||||
|
|
@ -17,7 +23,9 @@ export const isAllowedWebhookUrl = (url: string, allowedOrigins: readonly string
|
||||||
|
|
||||||
export const backupWebhookConfigSchema = z.object({
|
export const backupWebhookConfigSchema = z.object({
|
||||||
url: z.url(),
|
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(),
|
body: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue