@notifkit/provider-discord 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -18,6 +18,7 @@ export interface DiscordTransportOptions {
18
18
  windowSeconds: number;
19
19
  };
20
20
  }
21
+ export declare function isValidDiscordWebhookUrl(url: URL): boolean;
21
22
  export declare class DiscordTransport implements Transport {
22
23
  readonly channel: "discord";
23
24
  readonly limits?: {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1D,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEtE,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;CACnD;AAED,qBAAa,gBAAiB,YAAW,SAAS;IAChD,QAAQ,CAAC,OAAO,EAAG,SAAS,CAAU;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;IAE3D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAS;gBAErB,EACV,QAAQ,EACR,SAAS,EACT,MAAM,EACN,MAAuC,GACxC,GAAE,uBAA4B;IAOzB,IAAI,CAAC,IAAI,EAAE,6BAA6B,GAAG,OAAO,CAAC,cAAc,CAAC;CA2EzE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAC1D,OAAO,KAAK,EAAE,6BAA6B,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAEtE,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;CACnD;AASD,wBAAgB,wBAAwB,CAAC,GAAG,EAAE,GAAG,GAAG,OAAO,CAM1D;AAED,qBAAa,gBAAiB,YAAW,SAAS;IAChD,QAAQ,CAAC,OAAO,EAAG,SAAS,CAAU;IACtC,QAAQ,CAAC,MAAM,CAAC,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,aAAa,EAAE,MAAM,CAAA;KAAE,CAAC;IAE3D,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAS;gBAErB,EACV,QAAQ,EACR,SAAS,EACT,MAAM,EACN,MAAuC,GACxC,GAAE,uBAA4B;IAOzB,IAAI,CAAC,IAAI,EAAE,6BAA6B,GAAG,OAAO,CAAC,cAAc,CAAC;CAwFzE"}
package/dist/index.js CHANGED
@@ -1,3 +1,19 @@
1
+ const ALLOWED_DISCORD_HOSTS = new Set([
2
+ "discord.com",
3
+ "discordapp.com",
4
+ "ptb.discord.com",
5
+ "canary.discord.com",
6
+ ]);
7
+ export function isValidDiscordWebhookUrl(url) {
8
+ if (url.protocol !== "https:")
9
+ return false;
10
+ const hostname = url.hostname.toLowerCase();
11
+ if (!ALLOWED_DISCORD_HOSTS.has(hostname))
12
+ return false;
13
+ if (!url.pathname.startsWith("/api/webhooks/"))
14
+ return false;
15
+ return true;
16
+ }
1
17
  export class DiscordTransport {
2
18
  channel = "discord";
3
19
  limits;
@@ -37,6 +53,14 @@ export class DiscordTransport {
37
53
  // no providerMessageId to record in the delivery log.
38
54
  const url = new URL(webhookUrl);
39
55
  url.searchParams.set("wait", "true");
56
+ if (!isValidDiscordWebhookUrl(url)) {
57
+ this.logger?.warn({ taskId: task.taskId, destination: webhookUrl }, "Invalid Discord webhook URL rejected (SSRF protection)");
58
+ return {
59
+ success: false,
60
+ invalidToken: true,
61
+ error: "Invalid Discord webhook URL: destination must be an https://discord.com/api/webhooks/ URL",
62
+ };
63
+ }
40
64
  const res = await fetch(url, {
41
65
  method: "POST",
42
66
  headers: { "Content-Type": "application/json" },
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { DeliveryResult, Transport } from \"notifkit\";\nimport type { NotificationDispatchedPayload, Logger } from \"notifkit\";\n\nexport interface DiscordTransportOptions {\n /**\n * Default sender name shown on messages, overridable per template via\n * `content.username`. Left unset, Discord uses the webhook's own name.\n */\n username?: string;\n /** Default avatar URL, overridable per template via `content.avatarUrl`. */\n avatarUrl?: string;\n logger?: Logger;\n /**\n * Discord enforces roughly 5 requests / 2 seconds per webhook. Tighten\n * this if several templates share one webhook and could burst together.\n */\n limits?: { limit: number; windowSeconds: number };\n}\n\nexport class DiscordTransport implements Transport {\n readonly channel = \"discord\" as const;\n readonly limits?: { limit: number; windowSeconds: number };\n\n private readonly username?: string;\n private readonly avatarUrl?: string;\n private readonly logger?: Logger;\n\n constructor({\n username,\n avatarUrl,\n logger,\n limits = { limit: 5, windowSeconds: 2 },\n }: DiscordTransportOptions = {}) {\n this.username = username;\n this.avatarUrl = avatarUrl;\n this.logger = logger;\n this.limits = limits;\n }\n\n async send(task: NotificationDispatchedPayload): Promise<DeliveryResult> {\n // `destination` is optional on the contract, so a malformed task can reach\n // a transport without one. Failing here costs one clear error; passing\n // `undefined` as the webhook URL costs an opaque fetch failure instead.\n const webhookUrl = task.destination;\n if (!webhookUrl) {\n return { success: false, error: \"No destination (Discord webhook URL) on task\" };\n }\n\n const content = task.renderedContent.content as any;\n const body = content.text || content.body;\n const subject = content.subject;\n // Discord messages have no subject line of their own, so a template that\n // renders one is bolded onto the first line rather than dropped.\n const text = subject ? `**${subject}**\\n${body}` : body;\n\n // A template may carry its own sender, so one webhook can post as\n // different bots for different alert types.\n const username =\n typeof content.username === \"string\" && content.username ? content.username : this.username;\n const avatarUrl =\n typeof content.avatarUrl === \"string\" && content.avatarUrl\n ? content.avatarUrl\n : this.avatarUrl;\n const embeds = Array.isArray(content.embeds) ? content.embeds : undefined;\n\n try {\n // `wait=true` makes Discord return the created message (with its id)\n // in the response body instead of an empty 204 — without it there is\n // no providerMessageId to record in the delivery log.\n const url = new URL(webhookUrl);\n url.searchParams.set(\"wait\", \"true\");\n\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n content: text,\n ...(username ? { username } : {}),\n ...(avatarUrl ? { avatar_url: avatarUrl } : {}),\n ...(embeds ? { embeds } : {}),\n }),\n });\n\n if (!res.ok) {\n const errorBody = await res.text();\n // A deleted or regenerated webhook answers 404/401 for every future\n // request — the target itself is dead, not a transient failure, so\n // the caller should deactivate the contact rather than retry it.\n const invalidToken = res.status === 404 || res.status === 401;\n\n this.logger?.warn(\n { taskId: task.taskId, status: res.status, errorBody },\n \"Discord send failed\",\n );\n\n return {\n success: false,\n invalidToken,\n error: `Discord webhook error ${res.status}: ${errorBody}`,\n };\n }\n\n const data = (await res.json().catch(() => null)) as { id?: string } | null;\n const providerMessageId = data?.id ?? \"\";\n\n this.logger?.debug({ taskId: task.taskId, providerMessageId }, \"Discord message sent\");\n return { success: true, providerMessageId };\n } catch (err) {\n const error = err as Error;\n this.logger?.error({ taskId: task.taskId, error: error.message }, \"Discord unexpected error\");\n\n return { success: false, error: error.message };\n }\n }\n}\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["import type { DeliveryResult, Transport } from \"notifkit\";\nimport type { NotificationDispatchedPayload, Logger } from \"notifkit\";\n\nexport interface DiscordTransportOptions {\n /**\n * Default sender name shown on messages, overridable per template via\n * `content.username`. Left unset, Discord uses the webhook's own name.\n */\n username?: string;\n /** Default avatar URL, overridable per template via `content.avatarUrl`. */\n avatarUrl?: string;\n logger?: Logger;\n /**\n * Discord enforces roughly 5 requests / 2 seconds per webhook. Tighten\n * this if several templates share one webhook and could burst together.\n */\n limits?: { limit: number; windowSeconds: number };\n}\n\nconst ALLOWED_DISCORD_HOSTS = new Set([\n \"discord.com\",\n \"discordapp.com\",\n \"ptb.discord.com\",\n \"canary.discord.com\",\n]);\n\nexport function isValidDiscordWebhookUrl(url: URL): boolean {\n if (url.protocol !== \"https:\") return false;\n const hostname = url.hostname.toLowerCase();\n if (!ALLOWED_DISCORD_HOSTS.has(hostname)) return false;\n if (!url.pathname.startsWith(\"/api/webhooks/\")) return false;\n return true;\n}\n\nexport class DiscordTransport implements Transport {\n readonly channel = \"discord\" as const;\n readonly limits?: { limit: number; windowSeconds: number };\n\n private readonly username?: string;\n private readonly avatarUrl?: string;\n private readonly logger?: Logger;\n\n constructor({\n username,\n avatarUrl,\n logger,\n limits = { limit: 5, windowSeconds: 2 },\n }: DiscordTransportOptions = {}) {\n this.username = username;\n this.avatarUrl = avatarUrl;\n this.logger = logger;\n this.limits = limits;\n }\n\n async send(task: NotificationDispatchedPayload): Promise<DeliveryResult> {\n // `destination` is optional on the contract, so a malformed task can reach\n // a transport without one. Failing here costs one clear error; passing\n // `undefined` as the webhook URL costs an opaque fetch failure instead.\n const webhookUrl = task.destination;\n if (!webhookUrl) {\n return { success: false, error: \"No destination (Discord webhook URL) on task\" };\n }\n\n const content = task.renderedContent.content as any;\n const body = content.text || content.body;\n const subject = content.subject;\n // Discord messages have no subject line of their own, so a template that\n // renders one is bolded onto the first line rather than dropped.\n const text = subject ? `**${subject}**\\n${body}` : body;\n\n // A template may carry its own sender, so one webhook can post as\n // different bots for different alert types.\n const username =\n typeof content.username === \"string\" && content.username ? content.username : this.username;\n const avatarUrl =\n typeof content.avatarUrl === \"string\" && content.avatarUrl\n ? content.avatarUrl\n : this.avatarUrl;\n const embeds = Array.isArray(content.embeds) ? content.embeds : undefined;\n\n try {\n // `wait=true` makes Discord return the created message (with its id)\n // in the response body instead of an empty 204 — without it there is\n // no providerMessageId to record in the delivery log.\n const url = new URL(webhookUrl);\n url.searchParams.set(\"wait\", \"true\");\n\n if (!isValidDiscordWebhookUrl(url)) {\n this.logger?.warn(\n { taskId: task.taskId, destination: webhookUrl },\n \"Invalid Discord webhook URL rejected (SSRF protection)\",\n );\n return {\n success: false,\n invalidToken: true,\n error:\n \"Invalid Discord webhook URL: destination must be an https://discord.com/api/webhooks/ URL\",\n };\n }\n\n const res = await fetch(url, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n content: text,\n ...(username ? { username } : {}),\n ...(avatarUrl ? { avatar_url: avatarUrl } : {}),\n ...(embeds ? { embeds } : {}),\n }),\n });\n\n if (!res.ok) {\n const errorBody = await res.text();\n // A deleted or regenerated webhook answers 404/401 for every future\n // request — the target itself is dead, not a transient failure, so\n // the caller should deactivate the contact rather than retry it.\n const invalidToken = res.status === 404 || res.status === 401;\n\n this.logger?.warn(\n { taskId: task.taskId, status: res.status, errorBody },\n \"Discord send failed\",\n );\n\n return {\n success: false,\n invalidToken,\n error: `Discord webhook error ${res.status}: ${errorBody}`,\n };\n }\n\n const data = (await res.json().catch(() => null)) as { id?: string } | null;\n const providerMessageId = data?.id ?? \"\";\n\n this.logger?.debug({ taskId: task.taskId, providerMessageId }, \"Discord message sent\");\n return { success: true, providerMessageId };\n } catch (err) {\n const error = err as Error;\n this.logger?.error({ taskId: task.taskId, error: error.message }, \"Discord unexpected error\");\n\n return { success: false, error: error.message };\n }\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notifkit/provider-discord",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -17,6 +17,21 @@ export interface DiscordTransportOptions {
17
17
  limits?: { limit: number; windowSeconds: number };
18
18
  }
19
19
 
20
+ const ALLOWED_DISCORD_HOSTS = new Set([
21
+ "discord.com",
22
+ "discordapp.com",
23
+ "ptb.discord.com",
24
+ "canary.discord.com",
25
+ ]);
26
+
27
+ export function isValidDiscordWebhookUrl(url: URL): boolean {
28
+ if (url.protocol !== "https:") return false;
29
+ const hostname = url.hostname.toLowerCase();
30
+ if (!ALLOWED_DISCORD_HOSTS.has(hostname)) return false;
31
+ if (!url.pathname.startsWith("/api/webhooks/")) return false;
32
+ return true;
33
+ }
34
+
20
35
  export class DiscordTransport implements Transport {
21
36
  readonly channel = "discord" as const;
22
37
  readonly limits?: { limit: number; windowSeconds: number };
@@ -70,6 +85,19 @@ export class DiscordTransport implements Transport {
70
85
  const url = new URL(webhookUrl);
71
86
  url.searchParams.set("wait", "true");
72
87
 
88
+ if (!isValidDiscordWebhookUrl(url)) {
89
+ this.logger?.warn(
90
+ { taskId: task.taskId, destination: webhookUrl },
91
+ "Invalid Discord webhook URL rejected (SSRF protection)",
92
+ );
93
+ return {
94
+ success: false,
95
+ invalidToken: true,
96
+ error:
97
+ "Invalid Discord webhook URL: destination must be an https://discord.com/api/webhooks/ URL",
98
+ };
99
+ }
100
+
73
101
  const res = await fetch(url, {
74
102
  method: "POST",
75
103
  headers: { "Content-Type": "application/json" },