diff --git a/server/openapi.json b/server/openapi.json index 627354c55..22498d691 100644 --- a/server/openapi.json +++ b/server/openapi.json @@ -1410,6 +1410,32 @@ "topic": { "type": "string", "minLength": 1 + }, + "ntfyAuthType": { + "type": "string", + "enum": ["none", "token", "basic"] + }, + "ntfyUsername": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "string", + "enum": [""] + } + ] } }, "required": ["notificationName", "type", "address", "topic"], @@ -1417,7 +1443,9 @@ "notificationName": "ntfy topic", "type": "ntfy", "address": "https://ntfy.sh", - "topic": "checkmate-alerts" + "topic": "checkmate-alerts", + "ntfyAuthType": "token", + "accessToken": "tk_your-ntfy-access-token" } } }, diff --git a/server/openapi/routes/notification.ts b/server/openapi/routes/notification.ts index 7da45adfb..5950ab4a3 100644 --- a/server/openapi/routes/notification.ts +++ b/server/openapi/routes/notification.ts @@ -78,7 +78,14 @@ const notificationVariantMeta: Record { + const authType = body.ntfyAuthType ?? "none"; + + if (authType === "none") { + if (body.accessToken) { + ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Select an authentication type to use an access token" }); + } + if (body.ntfyUsername) { + ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Select an authentication type to use a username" }); + } + return; + } + + if (authType === "token") { + if (!body.accessToken) { + ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Access token is required for token authentication" }); + } + if (body.ntfyUsername) { + ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Username is only used with basic authentication" }); + } + return; + } + + if (!body.ntfyUsername) { + ctx.addIssue({ code: "custom", path: ["ntfyUsername"], message: "Username is required for basic authentication" }); + } + if (!body.accessToken) { + ctx.addIssue({ code: "custom", path: ["accessToken"], message: "Password is required for basic authentication" }); + } +}; + export const createNotificationBodyValidation = z.discriminatedUnion("type", [ // Email notification z.object({ @@ -98,12 +133,17 @@ export const createNotificationBodyValidation = z.discriminatedUnion("type", [ twilioPhoneNumber: z.string().min(1, "Twilio phone number is required"), }), // ntfy notification - z.object({ - notificationName: z.string().min(1, "Notification name is required"), - type: z.literal("ntfy"), - address: z.url({ message: "Please enter a valid ntfy server URL" }), - topic: z.string().min(1, "Topic is required"), - }), + z + .object({ + notificationName: z.string().min(1, "Notification name is required"), + type: z.literal("ntfy"), + address: z.url({ message: "Please enter a valid ntfy server URL" }), + topic: z.string().min(1, "Topic is required"), + ntfyAuthType: z.enum(NtfyAuthTypes).optional(), + ntfyUsername: z.union([z.string(), z.literal("")]).optional(), + accessToken: z.union([z.string(), z.literal("")]).optional(), + }) + .superRefine(refineNtfyAuth), ]); export const testNotificationBodyValidation = createNotificationBodyValidation; diff --git a/server/src/domain/notifications/notification.model.ts b/server/src/domain/notifications/notification.model.ts index 518a4fc3f..b2dce9ebe 100755 --- a/server/src/domain/notifications/notification.model.ts +++ b/server/src/domain/notifications/notification.model.ts @@ -1,5 +1,6 @@ import { Schema, model, type Types } from "mongoose"; import type { Notification, NotificationChannel } from "@/domain/notifications/notification.type.js"; +import { NtfyAuthTypes } from "@/domain/notifications/notification.type.js"; interface NotificationDocument extends Omit { _id: Types.ObjectId; @@ -53,6 +54,8 @@ const NotificationSchema = new Schema( accountSid: { type: String }, twilioPhoneNumber: { type: String }, topic: { type: String }, + ntfyAuthType: { type: String, enum: NtfyAuthTypes }, + ntfyUsername: { type: String }, }, { timestamps: true, diff --git a/server/src/domain/notifications/notification.type.ts b/server/src/domain/notifications/notification.type.ts index 7c4c9f75d..a45d4c4f8 100644 --- a/server/src/domain/notifications/notification.type.ts +++ b/server/src/domain/notifications/notification.type.ts @@ -14,6 +14,11 @@ export const NotificationChannels = [ ] as const; export type NotificationChannel = (typeof NotificationChannels)[number]; +// ntfy servers accept either a bearer access token or HTTP basic credentials. +// Both store their secret in `accessToken`; basic auth pairs it with `ntfyUsername`. +export const NtfyAuthTypes = ["none", "token", "basic"] as const; +export type NtfyAuthType = (typeof NtfyAuthTypes)[number]; + export interface Notification { id: string; userId: string; @@ -28,6 +33,8 @@ export interface Notification { accountSid?: string; twilioPhoneNumber?: string; topic?: string; + ntfyAuthType?: NtfyAuthType; + ntfyUsername?: string; createdAt: string; updatedAt: string; } diff --git a/server/src/domain/notifications/providers/ntfy.ts b/server/src/domain/notifications/providers/ntfy.ts index 7ca351d87..b415f648b 100644 --- a/server/src/domain/notifications/providers/ntfy.ts +++ b/server/src/domain/notifications/providers/ntfy.ts @@ -11,12 +11,18 @@ export class NtfyProvider extends NotificationProvider { return false; } + const authHeaders = this.buildAuthHeaders(notification, "sendTestAlert"); + if (authHeaders === null) { + return false; + } + try { await got.post(this.buildTopicUrl(notification.address, notification.topic), { body: getTestMessage(), headers: { Title: "Checkmate test notification", Priority: "default", + ...authHeaders, }, ...this.gotRequestOptions(), }); @@ -38,6 +44,11 @@ export class NtfyProvider extends NotificationProvider { return false; } + const authHeaders = this.buildAuthHeaders(notification, "sendMessage"); + if (authHeaders === null) { + return false; + } + try { await got.post(this.buildTopicUrl(notification.address, notification.topic), { body: this.buildNtfyText(message), @@ -45,6 +56,7 @@ export class NtfyProvider extends NotificationProvider { Title: message.content.title, Priority: this.mapPriority(message.severity), Tags: this.mapTags(message.severity), + ...authHeaders, }, ...this.gotRequestOptions(), }); @@ -70,6 +82,42 @@ export class NtfyProvider extends NotificationProvider { return `${address.replace(/\/+$/, "")}/${encodeURIComponent(topic)}`; } + /** + * Returns the Authorization header for the configured auth type, or null when the + * credentials are incomplete. Null is treated as a send failure rather than falling + * back to an anonymous post, so a misconfigured channel surfaces instead of quietly + * publishing to a topic the server may leave world-readable. + */ + private buildAuthHeaders(notification: Partial, method: string): Record | null { + const { ntfyAuthType, accessToken, ntfyUsername } = notification; + + if (!ntfyAuthType || ntfyAuthType === "none") { + return {}; + } + + if (ntfyAuthType === "token") { + if (!accessToken) { + this.logMissingCredentials("access token", method); + return null; + } + return { Authorization: `Bearer ${accessToken}` }; + } + + if (!ntfyUsername || !accessToken) { + this.logMissingCredentials("username and password", method); + return null; + } + return { Authorization: `Basic ${Buffer.from(`${ntfyUsername}:${accessToken}`).toString("base64")}` }; + } + + private logMissingCredentials(missing: string, method: string): void { + this.logger.warn({ + message: `ntfy notification is configured for authentication but is missing its ${missing}`, + service: SERVICE_NAME, + method, + }); + } + private buildNtfyText(message: NotificationMessage): string { const lines = [ message.content.summary, diff --git a/server/test/unit/providers/notifications/ntfyProvider.test.ts b/server/test/unit/providers/notifications/ntfyProvider.test.ts index ffd95a0d7..d365584a1 100644 --- a/server/test/unit/providers/notifications/ntfyProvider.test.ts +++ b/server/test/unit/providers/notifications/ntfyProvider.test.ts @@ -109,4 +109,87 @@ describe("NtfyProvider", () => { expect(mockGotPost.mock.calls[0][1].body).toContain("/infrastructure/mon-1"); }); }); + + describe("authentication", () => { + const authHeaderOf = () => mockGotPost.mock.calls[0][1].headers.Authorization; + + it("sends no Authorization header when no auth type is configured", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification() as any, makeMessage()); + expect(authHeaderOf()).toBeUndefined(); + }); + + it("sends no Authorization header when auth type is none", async () => { + const { provider } = createProvider(); + await provider.sendMessage(makeNtfyNotification({ ntfyAuthType: "none" }) as any, makeMessage()); + expect(authHeaderOf()).toBeUndefined(); + }); + + it("sends a bearer token for token auth", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(true); + expect(authHeaderOf()).toBe("Bearer tk_secret"); + }); + + it("sends base64 encoded credentials for basic auth", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "s3cret" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(true); + expect(authHeaderOf()).toBe(`Basic ${Buffer.from("alice:s3cret").toString("base64")}`); + }); + + it("authenticates test alerts as well as alerts", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" }); + expect(await provider.sendTestAlert(notification)).toBe(true); + expect(authHeaderOf()).toBe("Bearer tk_secret"); + }); + + it("preserves the notification headers alongside the Authorization header", async () => { + const { provider } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "tk_secret" }); + await provider.sendMessage(notification as any, makeMessage()); + expect(mockGotPost.mock.calls[0][1].headers).toEqual( + expect.objectContaining({ + Title: "Monitor Down: Test Monitor", + Priority: "high", + Tags: "rotating_light", + Authorization: "Bearer tk_secret", + }) + ); + }); + + // A misconfigured channel must fail loudly rather than post anonymously to a topic + // the operator believes is protected. + it("refuses to send unauthenticated when token auth has no access token", async () => { + const { provider, logger } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "token", accessToken: "" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("refuses to send unauthenticated when basic auth has no username", async () => { + const { provider, logger } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "basic", accessToken: "s3cret" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("refuses to send unauthenticated when basic auth has no password", async () => { + const { provider, logger } = createProvider(); + const notification = makeNtfyNotification({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "" }); + expect(await provider.sendMessage(notification as any, makeMessage())).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalled(); + }); + + it("refuses to send an unauthenticated test alert when credentials are incomplete", async () => { + const { provider } = createProvider(); + expect(await provider.sendTestAlert(makeNtfyNotification({ ntfyAuthType: "token", accessToken: "" }))).toBe(false); + expect(mockGotPost).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/test/unit/validation/notificationValidation.test.ts b/server/test/unit/validation/notificationValidation.test.ts index 5cb2c1d59..2dd7ffc95 100644 --- a/server/test/unit/validation/notificationValidation.test.ts +++ b/server/test/unit/validation/notificationValidation.test.ts @@ -44,4 +44,69 @@ describe("notification validation", () => { expect(result.success).toBe(false); } ); + + describe("ntfy authentication", () => { + const parseNtfy = (overrides: Record) => + createNotificationBodyValidation.safeParse({ + notificationName: "ntfy alerts", + type: "ntfy", + address: "https://ntfy.example.com", + topic: "checkmate-alerts", + ...overrides, + }); + + const pathsOf = (result: ReturnType) => (result.success ? [] : result.error.issues.map((issue) => issue.path.join("."))); + + it("accepts an unauthenticated channel", () => { + expect(parseNtfy({}).success).toBe(true); + }); + + it("accepts an explicit auth type of none", () => { + expect(parseNtfy({ ntfyAuthType: "none" }).success).toBe(true); + }); + + it("accepts token auth with an access token", () => { + expect(parseNtfy({ ntfyAuthType: "token", accessToken: "tk_secret" }).success).toBe(true); + }); + + it("accepts basic auth with a username and password", () => { + expect(parseNtfy({ ntfyAuthType: "basic", ntfyUsername: "alice", accessToken: "s3cret" }).success).toBe(true); + }); + + it("rejects an unknown auth type", () => { + expect(parseNtfy({ ntfyAuthType: "oauth" }).success).toBe(false); + }); + + it("rejects token auth without an access token", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "token" }))).toContain("accessToken"); + }); + + it("rejects token auth carrying a username", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "token", accessToken: "tk_secret", ntfyUsername: "alice" }))).toContain("ntfyUsername"); + }); + + it("rejects basic auth without a username", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "basic", accessToken: "s3cret" }))).toContain("ntfyUsername"); + }); + + it("rejects basic auth without a password", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "basic", ntfyUsername: "alice" }))).toContain("accessToken"); + }); + + it("reports both fields when basic auth is entirely empty", () => { + expect(pathsOf(parseNtfy({ ntfyAuthType: "basic" }))).toEqual(expect.arrayContaining(["ntfyUsername", "accessToken"])); + }); + + // Credentials with auth off would be stored but never sent, which reads as protected + // while posting anonymously. + it("rejects credentials supplied without an auth type", () => { + expect(pathsOf(parseNtfy({ accessToken: "tk_secret" }))).toContain("accessToken"); + expect(pathsOf(parseNtfy({ ntfyUsername: "alice" }))).toContain("ntfyUsername"); + }); + + it("still requires a topic and a valid server URL", () => { + expect(parseNtfy({ topic: "" }).success).toBe(false); + expect(parseNtfy({ address: "not-a-url" }).success).toBe(false); + }); + }); });