Merge v142/webhook-discord into release/v1.42.0

This commit is contained in:
Marc-André Bombeck committed 2026-10-09 14:22:13 +02:00
commit 745bcb64b7
16 files changed
+336 -32

No files matched your search

+1 -2
View File
@@ -42,8 +42,7 @@ const NOT_GRANTABLE_REFUSAL =
* GET: current config (header value redacted — only presence flagged).
* PUT: upsert config.
*
* Covers Gotify / Discord / Slack / Matrix-bridge / Home Assistant in one
* channel: the user supplies a URL and an optional shared-secret header. SSRF
* Covers Discord / Slack / Gotify / Home Assistant / n8n in one channel: the user supplies a URL and an optional shared-secret header. SSRF
* is enforced at input time (the schema's target predicate) and again at
* dispatch time (`safeFetch` with the connect-time pin); a private origin
* passes both only when the operator listed it in
@@ -0,0 +1,150 @@
/**
* The webhook test button against a Discord webhook URL, through the real
* sender with only the network mocked.
*
* The mocked target answers the way Discord does: 400 with code 50006
* ("Cannot send an empty message") for a body without `content` or `embeds`,
* 204 otherwise. The generic body used to carry neither, so the test button
* and every reminder failed against a Discord URL the settings card names.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((value: string) => `encrypted:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^encrypted:/, "")),
}));
const safeFetchMock = vi.fn();
vi.mock("@/lib/safe-fetch", () => ({
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
SafeFetchError: class SafeFetchError extends Error {
kind: string;
constructor(message: string, kind: string) {
super(message);
this.kind = kind;
}
},
}));
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
recordPushAttempt: vi.fn(),
recordPushAttemptForPayload: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST as postTest } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const DISCORD_URL =
"https://discord.com/api/webhooks/123456789012345678/AbCdEfGhIjKlMnOpQrStUvWxYz0123456789";
/** Answers the way Discord's execute-webhook endpoint does. */
function discordAnswer(_url: string, init: { body: string }): Response {
const body = JSON.parse(init.body) as Record<string, unknown>;
const content = typeof body.content === "string" ? body.content : "";
if (content.trim().length === 0 && body.embeds === undefined) {
return new Response(
JSON.stringify({ message: "Cannot send an empty message", code: 50006 }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
if (content.length > 2000) {
return new Response(
JSON.stringify({ message: "Invalid Form Body", code: 50035 }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
return new Response(null, { status: 204 });
}
const POST = postTest as (request: Request) => Promise<Response>;
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" },
} as never);
vi.mocked(prisma.notificationChannel.findUnique).mockResolvedValue({
config: `encrypted:${JSON.stringify({ url: DISCORD_URL })}`,
} as never);
safeFetchMock.mockImplementation(
async (url: string, init: { body: string }) => discordAnswer(url, init),
);
});
describe("POST /api/settings/webhook/test — a Discord webhook URL", () => {
it("delivers: the generic body carries the content Discord requires", async () => {
const response = await POST(
new Request("http://localhost/api/settings/webhook/test", {
method: "POST",
}),
);
expect(response.status).toBe(200);
expect((await response.json()).data).toEqual({ sent: true });
expect(safeFetchMock).toHaveBeenCalledTimes(1);
const [url, init] = safeFetchMock.mock.calls[0] as [
string,
{ body: string },
];
expect(url).toBe(DISCORD_URL);
const sent = JSON.parse(init.body);
expect(sent.content).toBe(
"HealthLog Test\nHealthLog: Connection successful! Webhook notifications are active.",
);
expect(sent.allowed_mentions).toEqual({ parse: [] });
});
it("passes Discord's own error through when the target still refuses", async () => {
safeFetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({ message: "Unknown Webhook", code: 10015 }),
{ status: 404, headers: { "Content-Type": "application/json" } },
),
);
const response = await POST(
new Request("http://localhost/api/settings/webhook/test", {
method: "POST",
}),
);
expect(response.status).toBe(502);
const json = await response.json();
expect(json.meta.upstreamStatus).toBe(404);
expect(json.meta.upstreamBody).toContain("Unknown Webhook");
});
});
@@ -71,8 +71,8 @@ export function NotificationChannelsPanel() {
<WebPushCard />
</div>
)}
{/* v1.17.1 — generic outbound webhook (Gotify / Discord / Slack /
Matrix / Home Assistant in one channel). */}
{/* v1.17.1 — generic outbound webhook (Discord / Slack / Gotify /
Home Assistant / n8n in one channel). */}
<div id="webhook" className="scroll-mt-28">
<WebhookCard isAuthenticated={isAuthenticated} />
</div>
+5 -5
View File
@@ -209,11 +209,11 @@ export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
{t("settings.webhookFormatGotify")}
</option>
</NativeSelect>
{format === "gotify" && (
<p className="text-muted-foreground text-xs">
{t("settings.webhookFormatGotifyHint")}
</p>
)}
<p className="text-muted-foreground text-xs">
{format === "gotify"
? t("settings.webhookFormatGotifyHint")
: t("settings.webhookFormatGenericHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="webhook-header-name">
@@ -43,7 +43,7 @@ vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
) => recordPushAttemptMock(attempt),
}));
import { sendViaWebhook } from "../webhook";
import { buildWebhookBody, sendViaWebhook } from "../webhook";
import { SafeFetchError } from "@/lib/safe-fetch";
const config = {
@@ -345,30 +345,39 @@ function sentBody(): string {
return (safeFetchMock.mock.calls[0][1] as { body: string }).body;
}
// The chat-target fields appended after the four original ones (v1.42.0).
const CHAT_TAIL =
',"content":"Title\\nBody","text":"Title\\nBody","allowed_mentions":{"parse":[]}}';
describe("sendViaWebhook — generic body stays byte for byte", () => {
// Literal strings, not re-parsed objects: a Home Assistant or n8n rule
// that matches on the raw body must keep matching after the format choice
// was added.
// was added. The four original fields keep their values and their order;
// the Discord and Slack fields only follow them.
it.each([
[
"a routine event",
{},
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"default"}',
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"default"' +
CHAT_TAIL,
],
[
"a medication reminder",
{ eventType: "MEDICATION_REMINDER" },
'{"title":"Title","message":"Body","eventType":"MEDICATION_REMINDER","priority":"high"}',
'{"title":"Title","message":"Body","eventType":"MEDICATION_REMINDER","priority":"high"' +
CHAT_TAIL,
],
[
"an urgent event",
{ urgent: true },
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"urgent"}',
'{"title":"Title","message":"Body","eventType":"SYSTEM_ALERT","priority":"urgent"' +
CHAT_TAIL,
],
[
"a discreet cycle event",
{ eventType: "CYCLE_PERIOD_SOON", discreet: true },
'{"title":"Title","message":"Body","eventType":"reminder","priority":"default"}',
'{"title":"Title","message":"Body","eventType":"reminder","priority":"default"' +
CHAT_TAIL,
],
])("%s", async (_label, over, expected) => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
@@ -382,6 +391,107 @@ describe("sendViaWebhook — generic body stays byte for byte", () => {
});
});
describe("buildWebhookBody — Discord and Slack", () => {
const generic = { format: "generic" as const };
it("carries content for Discord and text for Slack, as plain title and message", () => {
const body = JSON.parse(
buildWebhookBody(
generic,
payload({ title: "Reminder", message: "<b>Take</b> your dose" }),
),
);
expect(body.content).toBe("Reminder\nTake your dose");
expect(body.text).toBe("Reminder\nTake your dose");
expect(body).toMatchObject({
title: "Reminder",
message: "Take your dose",
eventType: "SYSTEM_ALERT",
priority: "default",
});
});
it("tells Discord to resolve no mentions", () => {
const body = JSON.parse(
buildWebhookBody(
generic,
payload({ message: "@everyone @here <@123456789012345678>" }),
),
);
expect(body.allowed_mentions).toEqual({ parse: [] });
// The text itself is left alone; the empty parse list is what disarms it.
expect(body.content).toContain("@everyone");
});
it("keeps content within Discord's 2000 characters, ending in an ellipsis", () => {
const long = "x".repeat(5000);
const body = JSON.parse(
buildWebhookBody(generic, payload({ message: long })),
);
expect(Array.from(body.content as string)).toHaveLength(2000);
expect(body.content.startsWith("Title\nxxx")).toBe(true);
expect(body.content.endsWith("\u2026")).toBe(true);
// Slack and the relay fields keep the whole message.
expect(body.text).toBe(`Title\n${long}`);
expect(body.message).toBe(long);
});
it("leaves content alone at exactly 2000 characters", () => {
const message = "y".repeat(2000 - "Title\n".length);
const body = JSON.parse(buildWebhookBody(generic, payload({ message })));
expect(body.content).toBe(`Title\n${message}`);
expect(body.content).not.toContain("\u2026");
});
it("never splits a surrogate pair when it cuts", () => {
const body = JSON.parse(
buildWebhookBody(
generic,
payload({
eventType: "SYSTEM_ALERT",
message: "\u{1F600}".repeat(3000),
}),
),
);
const points = Array.from(body.content as string);
expect(points).toHaveLength(2000);
expect(body.content).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/);
});
it("puts only the masked text into content and text in discreet mode", () => {
// The dispatcher masks title and message upstream; the body must not
// reintroduce the cycle event anywhere.
const raw = buildWebhookBody(
generic,
payload({
eventType: "CYCLE_PERIOD_SOON",
discreet: true,
title: "HealthLog",
message: "HealthLog reminder",
}),
);
const body = JSON.parse(raw);
expect(raw).not.toContain("CYCLE");
expect(body.eventType).toBe("reminder");
expect(body.content).toBe("HealthLog\nHealthLog reminder");
expect(body.text).toBe("HealthLog\nHealthLog reminder");
});
it("leaves the Gotify body without the chat fields", () => {
const body = JSON.parse(buildWebhookBody({ format: "gotify" }, payload()));
expect(body).not.toHaveProperty("content");
expect(body).not.toHaveProperty("text");
expect(body).not.toHaveProperty("allowed_mentions");
});
});
describe("sendViaWebhook — Gotify format (#947)", () => {
const gotify = {
url: "https://gotify.example.com/message",
+35 -6
View File
@@ -57,8 +57,9 @@ const GOTIFY_PRIORITY: Record<PriorityBand, number> = {
* The request body for the configured format.
*
* Discreet mode (cycle privacy) is honoured the same way in both: the title
* and message arrive already masked upstream, and the event name a relay
* could route on is replaced by the generic `reminder`.
* and message arrive already masked upstream, every text field (including the
* generic body's `content` and `text`) is built from them, and the event name
* a relay could route on is replaced by the generic `reminder`.
*/
export function buildWebhookBody(
config: Pick<WebhookChannelConfig, "format">,
@@ -83,22 +84,50 @@ export function buildWebhookBody(
});
}
// Generic envelope. Discord and Slack ignore unknown keys, so one shape
// covers the common JSON relays. Unchanged since v1.18.4: an existing
// Home Assistant or n8n rule parses these exact fields.
// Generic envelope. The first four fields are unchanged since v1.18.4, in
// the same order: an existing Home Assistant or n8n rule parses them.
// Discord and Slack do not read them. Discord answers 400 "Cannot send an
// empty message" unless the body carries `content` (or `embeds`), and a
// Slack incoming webhook needs `text`. Both get the same plain text,
// built from the already masked title and message, so discreet mode
// holds. `allowed_mentions` with an empty `parse` stops Discord from
// resolving `@everyone`, `@here` or `<@id>` that a message might contain.
// Each target ignores the keys meant for the others.
const text = [title, message].filter((part) => part.length > 0).join("\n");
return JSON.stringify({
title,
message,
eventType,
priority: band,
content: truncateCodePoints(text, DISCORD_CONTENT_LIMIT),
text,
allowed_mentions: { parse: [] },
});
}
/** Discord rejects a `content` longer than 2000 characters. */
const DISCORD_CONTENT_LIMIT = 2000;
/**
* Cut `value` to at most `limit` code points, the last one an ellipsis when
* anything was dropped. Counts code points, not UTF-16 units, so a cut never
* splits a surrogate pair.
*/
function truncateCodePoints(value: string, limit: number): string {
const points = Array.from(value);
if (points.length <= limit) return value;
return `${points
.slice(0, limit - 1)
.join("")
.trimEnd()}\u2026`;
}
/**
* Send a notification via a generic outbound webhook (v1.17.1).
*
* The user supplies a URL and optionally one custom header, and chooses the
* body shape: HealthLog's generic JSON envelope, or the body Gotify's
* body shape: HealthLog's generic JSON envelope (which a Discord or Slack
* webhook URL also accepts as is), or the body Gotify's
* `POST /message` binds (see `buildWebhookBody`). Gotify takes its app token
* as an `X-Gotify-Key` header or a `token` query parameter. The body is plain
* text (no markdown — hard rule); `title`/`message` are stripped of HTML and
+1 -1
View File
@@ -398,7 +398,7 @@ export function webhookSettingsSchemaWith(
.enum(WEBHOOK_PAYLOAD_FORMATS)
.optional()
.describe(
"Body shape. `generic` sends HealthLog's own JSON envelope; `gotify` sends the body Gotify's `POST /message` expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved before the choice existed.",
"Body shape. `generic` sends HealthLog's own JSON envelope (`title`, `message`, `eventType`, `priority`, plus `content` and `text` so a Discord or Slack webhook URL accepts it as is); `gotify` sends the body Gotify's `POST /message` expects, with an integer priority. Omitted keeps the stored choice, which is `generic` for a config saved before the choice existed.",
),
enabled: z.boolean(),
});