@mantiq/notify 0.5.20 → 0.5.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mantiq/notify",
3
- "version": "0.5.20",
3
+ "version": "0.5.22",
4
4
  "description": "Multi-channel notifications — mail, database, broadcast, SMS, Slack, webhook",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,6 +16,85 @@ import { NotifyError } from '../errors/NotifyError.ts'
16
16
  export class WebhookChannel implements NotificationChannel {
17
17
  readonly name = 'webhook'
18
18
 
19
+ /**
20
+ * Security: validate webhook URL to prevent SSRF attacks.
21
+ * Rejects private/reserved IP ranges, localhost, link-local, and non-http(s) schemes
22
+ * so that attackers cannot target internal services (e.g. cloud metadata at 169.254.169.254).
23
+ */
24
+ private validateUrl(url: string): void {
25
+ let parsed: URL
26
+ try {
27
+ parsed = new URL(url)
28
+ } catch {
29
+ throw new NotifyError('Webhook URL is not a valid URL', {
30
+ channel: this.name,
31
+ url,
32
+ })
33
+ }
34
+
35
+ // Only allow http and https
36
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
37
+ throw new NotifyError(`Webhook URL scheme "${parsed.protocol}" is not allowed — only http and https`, {
38
+ channel: this.name,
39
+ url,
40
+ })
41
+ }
42
+
43
+ const hostname = parsed.hostname
44
+
45
+ // Reject IPv6 addresses in brackets
46
+ const ipv6Match = hostname.match(/^\[(.+)\]$/)
47
+ if (ipv6Match) {
48
+ const ipv6 = ipv6Match[1]!.toLowerCase()
49
+ // Reject loopback (::1), link-local (fe80::), unique-local (fc00::/7 = fc and fd)
50
+ if (
51
+ ipv6 === '::1' ||
52
+ ipv6.startsWith('fe80:') ||
53
+ ipv6.startsWith('fc') ||
54
+ ipv6.startsWith('fd')
55
+ ) {
56
+ throw new NotifyError('Webhook URL targets a private/reserved network address', {
57
+ channel: this.name,
58
+ url,
59
+ })
60
+ }
61
+ }
62
+
63
+ // Reject known private/reserved IPv4 ranges and localhost
64
+ const lower = hostname.toLowerCase()
65
+ if (
66
+ lower === 'localhost' ||
67
+ lower.endsWith('.localhost') ||
68
+ lower === '[::1]'
69
+ ) {
70
+ throw new NotifyError('Webhook URL targets a private/reserved network address', {
71
+ channel: this.name,
72
+ url,
73
+ })
74
+ }
75
+
76
+ // Check IPv4-like hostnames
77
+ const ipv4Parts = hostname.split('.')
78
+ if (ipv4Parts.length === 4 && ipv4Parts.every((p) => /^\d{1,3}$/.test(p))) {
79
+ const octets = ipv4Parts.map(Number)
80
+ const [a, b] = octets as [number, number, number, number]
81
+
82
+ if (
83
+ a === 127 || // 127.0.0.0/8 — loopback
84
+ a === 10 || // 10.0.0.0/8 — private
85
+ (a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12 — private
86
+ (a === 192 && b === 168) || // 192.168.0.0/16 — private
87
+ (a === 169 && b === 254) || // 169.254.0.0/16 — link-local (AWS metadata)
88
+ a === 0 // 0.0.0.0/8
89
+ ) {
90
+ throw new NotifyError('Webhook URL targets a private/reserved network address', {
91
+ channel: this.name,
92
+ url,
93
+ })
94
+ }
95
+ }
96
+ }
97
+
19
98
  async send(notifiable: Notifiable, notification: Notification): Promise<void> {
20
99
  const payload = notification.getPayloadFor('webhook', notifiable) as WebhookPayload | undefined
21
100
  if (!payload) return
@@ -27,6 +106,9 @@ export class WebhookChannel implements NotificationChannel {
27
106
  })
28
107
  }
29
108
 
109
+ // Security: validate URL before making the request to prevent SSRF
110
+ this.validateUrl(payload.url)
111
+
30
112
  const method = payload.method ?? 'POST'
31
113
  const headers: Record<string, string> = {
32
114
  'Content-Type': 'application/json',