Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1410,14 +1410,42 @@
"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"],
"example": {
"notificationName": "ntfy topic",
"type": "ntfy",
"address": "https://ntfy.sh",
"topic": "checkmate-alerts"
"topic": "checkmate-alerts",
"ntfyAuthType": "token",
"accessToken": "tk_your-ntfy-access-token"
}
}
},
Expand Down
9 changes: 8 additions & 1 deletion server/openapi/routes/notification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,14 @@ const notificationVariantMeta: Record<string, { component: string; example: Reco
},
ntfy: {
component: "NtfyNotification",
example: { notificationName: "ntfy topic", type: "ntfy", address: "https://ntfy.sh", topic: "checkmate-alerts" },
example: {
notificationName: "ntfy topic",
type: "ntfy",
address: "https://ntfy.sh",
topic: "checkmate-alerts",
ntfyAuthType: "token",
accessToken: "tk_your-ntfy-access-token",
},
},
};

Expand Down
52 changes: 46 additions & 6 deletions server/src/api/validation/notificationValidation.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,44 @@
import { z } from "zod";
import { NtfyAuthTypes } from "@/domain/notifications/notification.type.js";

//****************************************
// Notification Validations
//****************************************

// ntfy stores its secret in accessToken for both auth types: the bearer token for
// "token", the password for "basic". Reject partially filled credentials so a channel
// can't be saved looking authenticated while posting anonymously.
const refineNtfyAuth = (body: { ntfyAuthType?: string; ntfyUsername?: string; accessToken?: string }, ctx: z.RefinementCtx) => {
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({
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions server/src/domain/notifications/notification.model.ts
Original file line number Diff line number Diff line change
@@ -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<Notification, "id" | "userId" | "teamId" | "createdAt" | "updatedAt"> {
_id: Types.ObjectId;
Expand Down Expand Up @@ -53,6 +54,8 @@ const NotificationSchema = new Schema<NotificationDocument>(
accountSid: { type: String },
twilioPhoneNumber: { type: String },
topic: { type: String },
ntfyAuthType: { type: String, enum: NtfyAuthTypes },
ntfyUsername: { type: String },
},
{
timestamps: true,
Expand Down
7 changes: 7 additions & 0 deletions server/src/domain/notifications/notification.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,6 +33,8 @@ export interface Notification {
accountSid?: string;
twilioPhoneNumber?: string;
topic?: string;
ntfyAuthType?: NtfyAuthType;
ntfyUsername?: string;
createdAt: string;
updatedAt: string;
}
Expand Down
48 changes: 48 additions & 0 deletions server/src/domain/notifications/providers/ntfy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand All @@ -38,13 +44,19 @@ 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),
headers: {
Title: message.content.title,
Priority: this.mapPriority(message.severity),
Tags: this.mapTags(message.severity),
...authHeaders,
},
...this.gotRequestOptions(),
});
Expand All @@ -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<Notification>, method: string): Record<string, string> | 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,
Expand Down
83 changes: 83 additions & 0 deletions server/test/unit/providers/notifications/ntfyProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
Loading
Loading