@consilioweb/payload-support 3.0.0 → 4.0.0

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.
Files changed (43) hide show
  1. package/README.md +36 -2
  2. package/dist/index.cjs +558 -94
  3. package/dist/index.d.cts +20 -5
  4. package/dist/index.d.ts +20 -5
  5. package/dist/index.js +558 -94
  6. package/package.json +1 -1
  7. package/src/collections/ChatMessages.ts +59 -2
  8. package/src/collections/ClientSummaries.ts +10 -4
  9. package/src/collections/TicketMessages.ts +4 -1
  10. package/src/collections/WebhookEndpoints.ts +44 -2
  11. package/src/endpoints/admin-chat.ts +3 -3
  12. package/src/endpoints/ai-agent.ts +3 -3
  13. package/src/endpoints/ai.ts +3 -3
  14. package/src/endpoints/auth-2fa.ts +16 -4
  15. package/src/endpoints/capabilities.ts +6 -6
  16. package/src/endpoints/chat.ts +7 -5
  17. package/src/endpoints/chatbot.ts +48 -2
  18. package/src/endpoints/client-intelligence.ts +4 -4
  19. package/src/endpoints/email-stats.ts +19 -3
  20. package/src/endpoints/import-conversation.ts +1 -1
  21. package/src/endpoints/index.ts +1 -1
  22. package/src/endpoints/invite-collaborator.ts +29 -3
  23. package/src/endpoints/login.ts +15 -2
  24. package/src/endpoints/oauth-google.ts +130 -8
  25. package/src/endpoints/resend-notification.ts +3 -3
  26. package/src/endpoints/send-reminder.ts +3 -3
  27. package/src/endpoints/signature.ts +9 -2
  28. package/src/endpoints/ticket-synthesis.ts +3 -3
  29. package/src/endpoints/transfer-ticket.ts +28 -3
  30. package/src/endpoints/typing.ts +117 -14
  31. package/src/endpoints/user-prefs.ts +5 -2
  32. package/src/plugin.ts +12 -0
  33. package/src/portal/auth/layout.tsx +19 -1
  34. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  35. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  36. package/src/portal/login/page.tsx +11 -3
  37. package/src/utils/fireWebhooks.ts +4 -1
  38. package/src/utils/rateLimiter.ts +39 -5
  39. package/src/utils/readSettings.ts +124 -14
  40. package/src/utils/ticketAccess.ts +16 -1
  41. package/src/utils/twoFactorChallenge.ts +80 -0
  42. package/src/utils/urlSafety.ts +230 -0
  43. package/src/utils/webhookDispatcher.ts +5 -1
@@ -0,0 +1,230 @@
1
+ import { lookup } from 'dns/promises'
2
+
3
+ /**
4
+ * SSRF guards for every URL the SERVER follows on behalf of a user-supplied
5
+ * value (today: outbound webhook endpoints).
6
+ *
7
+ * `webhook-endpoints.url` is a plain `text` field with no validation, writable by
8
+ * any member of the staff collection, and `_sendToEndpoint` used to `fetch()` it
9
+ * with the default redirect policy. Pointing it at `http://169.254.169.254/…` or
10
+ * an internal service turned every ticket event into a blind SSRF, with the HTTP
11
+ * status echoed back into the admin through `lastStatus`.
12
+ *
13
+ * Three layers, because any one of them alone is bypassable:
14
+ * 1. `validateWebhookUrl` at WRITE time — rejects the scheme and the literal
15
+ * private / loopback / link-local hosts before the row is saved.
16
+ * 2. `assertPublicHost` at SEND time — resolves the name and re-checks the
17
+ * resolved addresses, so a DNS record flipped to 127.0.0.1 after the row was
18
+ * validated is caught. It FAILS CLOSED: a resolution that errors, returns
19
+ * nothing, or outruns `LOOKUP_TIMEOUT_MS` is treated as "do not connect".
20
+ * Note the limit, honestly stated: `fetch` resolves the name a SECOND time,
21
+ * so this narrows the rebinding window rather than closing it — closing it
22
+ * would mean connecting to the address we validated (a custom dispatcher
23
+ * lookup), which is a dependency this plugin does not take.
24
+ * 3. `safeFetch` follows redirects MANUALLY, re-running (1) and (2) on every
25
+ * hop: a public host answering `302 Location: http://127.0.0.1:8080/` would
26
+ * otherwise walk straight through the first two layers.
27
+ */
28
+
29
+ /** Set `SUPPORT_ALLOW_INSECURE_WEBHOOKS=1` to allow plain http (local dev only). */
30
+ function httpAllowed(): boolean {
31
+ return process.env.SUPPORT_ALLOW_INSECURE_WEBHOOKS === '1'
32
+ }
33
+
34
+ const BLOCKED_HOST_SUFFIXES = ['.local', '.localhost', '.internal', '.home.arpa']
35
+
36
+ /** Dotted-quad only — no octal/hex shorthand, which `new URL()` normalises away anyway. */
37
+ function parseIPv4(host: string): number[] | null {
38
+ const parts = host.split('.')
39
+ if (parts.length !== 4) return null
40
+ const octets: number[] = []
41
+ for (const part of parts) {
42
+ if (!/^\d{1,3}$/.test(part)) return null
43
+ const n = Number(part)
44
+ if (n > 255) return null
45
+ octets.push(n)
46
+ }
47
+ return octets
48
+ }
49
+
50
+ function isPrivateIPv4(octets: number[]): boolean {
51
+ const [a, b] = octets
52
+ if (a === 0) return true // "this network" / 0.0.0.0
53
+ if (a === 10) return true // 10/8
54
+ if (a === 127) return true // loopback
55
+ if (a === 169 && b === 254) return true // link-local, incl. cloud metadata
56
+ if (a === 172 && b >= 16 && b <= 31) return true // 172.16/12
57
+ if (a === 192 && b === 168) return true // 192.168/16
58
+ if (a === 192 && b === 0) return true // 192.0.0/24 + 192.0.2/24
59
+ if (a === 198 && (b === 18 || b === 19)) return true // benchmarking
60
+ if (a === 100 && b >= 64 && b <= 127) return true // CGNAT 100.64/10
61
+ if (a >= 224) return true // multicast + reserved + broadcast
62
+ return false
63
+ }
64
+
65
+ /**
66
+ * Handles the IPv4-mapped and IPv4-compatible forms — `::ffff:10.0.0.1` and
67
+ * `::ffff:a00:1` both reach 10.0.0.1 and both must be blocked.
68
+ */
69
+ function isPrivateIPv6(host: string): boolean {
70
+ const lower = host.toLowerCase()
71
+ if (lower === '::' || lower === '::1') return true
72
+ if (lower.startsWith('fe8') || lower.startsWith('fe9') || lower.startsWith('fea') || lower.startsWith('feb')) return true // fe80::/10
73
+ if (lower.startsWith('fc') || lower.startsWith('fd')) return true // fc00::/7 unique-local
74
+ if (lower.startsWith('ff')) return true // multicast
75
+
76
+ // IPv4-mapped / -compatible, dotted form: ::ffff:10.0.0.1
77
+ const dotted = lower.match(/^::(?:ffff:)?(\d{1,3}(?:\.\d{1,3}){3})$/)
78
+ if (dotted) {
79
+ const octets = parseIPv4(dotted[1])
80
+ return octets ? isPrivateIPv4(octets) : true
81
+ }
82
+
83
+ // IPv4-mapped, hex form: ::ffff:a00:1
84
+ const hex = lower.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/)
85
+ if (hex) {
86
+ const high = parseInt(hex[1], 16)
87
+ const low = parseInt(hex[2], 16)
88
+ return isPrivateIPv4([high >> 8, high & 0xff, low >> 8, low & 0xff])
89
+ }
90
+
91
+ return false
92
+ }
93
+
94
+ /** Strips the brackets WHATWG URL keeps around an IPv6 literal. */
95
+ function normalizeHost(hostname: string): string {
96
+ const host = hostname.trim().toLowerCase()
97
+ return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
98
+ }
99
+
100
+ /**
101
+ * True when the host is a literal address (or a name) that must never be
102
+ * reached from the server. Names that are not literals are NOT blocked here —
103
+ * they are resolved and re-checked by `assertPublicHost`.
104
+ */
105
+ export function isBlockedHost(hostname: string): boolean {
106
+ const host = normalizeHost(hostname)
107
+ if (!host) return true
108
+ if (host === 'localhost') return true
109
+ if (BLOCKED_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return true
110
+
111
+ const v4 = parseIPv4(host)
112
+ if (v4) return isPrivateIPv4(v4)
113
+ if (host.includes(':')) return isPrivateIPv6(host)
114
+ return false
115
+ }
116
+
117
+ export interface UrlValidationResult {
118
+ ok: boolean
119
+ /** Machine-readable reason, also used as the field validation message. */
120
+ reason?: 'invalid_url' | 'scheme_not_allowed' | 'private_host'
121
+ url?: URL
122
+ }
123
+
124
+ /** Scheme + literal-host validation. Cheap, synchronous, safe to run in a field `validate`. */
125
+ export function validateWebhookUrl(raw: unknown): UrlValidationResult {
126
+ if (typeof raw !== 'string' || !raw.trim()) return { ok: false, reason: 'invalid_url' }
127
+ let url: URL
128
+ try {
129
+ url = new URL(raw.trim())
130
+ } catch {
131
+ return { ok: false, reason: 'invalid_url' }
132
+ }
133
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && httpAllowed())) {
134
+ return { ok: false, reason: 'scheme_not_allowed' }
135
+ }
136
+ if (isBlockedHost(url.hostname)) return { ok: false, reason: 'private_host' }
137
+ return { ok: true, url }
138
+ }
139
+
140
+ /** French messages for the collection field — mirrors the rest of the admin UI. */
141
+ export const WEBHOOK_URL_MESSAGES: Record<NonNullable<UrlValidationResult['reason']>, string> = {
142
+ invalid_url: 'URL invalide.',
143
+ scheme_not_allowed: 'Seules les URL https:// sont acceptées.',
144
+ private_host: 'Les adresses privées, loopback et link-local sont interdites (SSRF).',
145
+ }
146
+
147
+ /**
148
+ * Resolve the name and reject unless EVERY resolved address is public.
149
+ *
150
+ * FAIL-CLOSED on anything that is not a clean answer. The previous version
151
+ * returned `true` on its own timeout, on an empty answer and on every error, on
152
+ * the theory that "`fetch` would fail the same way". A slow resolver is not a
153
+ * failing resolver: an attacker-run authoritative server answering just after
154
+ * the deadline got this guard to wave the request through, and `fetch` then did
155
+ * its OWN resolution — no deadline — and reached 127.0.0.1. So a timeout, a
156
+ * temporary resolver error (EAI_AGAIN/EAI_FAIL) and an empty answer all block.
157
+ *
158
+ * The ONE tolerated case is a definitive "this name does not exist"
159
+ * (ENOTFOUND / ENODATA): there is no address for `fetch` to reach either, so
160
+ * blocking would only swap one failure message for another.
161
+ *
162
+ * The deadline is generous on purpose — with fail-closed semantics it now costs
163
+ * a legitimate delivery when it fires, and a resolver needing more than 5s is
164
+ * broken, not slow. It still bounds the delivery: the `fetch` that follows has
165
+ * its own 10s abort.
166
+ */
167
+ const LOOKUP_TIMEOUT_MS = 5000
168
+ const LOOKUP_TIMED_OUT = Symbol('lookup-timed-out')
169
+ /** Definitive "no such host" — nothing for `fetch` to connect to either. */
170
+ const NONEXISTENT_HOST_CODES = new Set(['ENOTFOUND', 'ENODATA', 'NOTFOUND'])
171
+
172
+ export async function assertPublicHost(hostname: string): Promise<boolean> {
173
+ const host = normalizeHost(hostname)
174
+ if (isBlockedHost(host)) return false
175
+ if (parseIPv4(host) || host.includes(':')) return true // already a literal, checked above
176
+ try {
177
+ // Bounded: a stalled resolver must not hold a webhook delivery open.
178
+ const addresses = await Promise.race([
179
+ lookup(host, { all: true }),
180
+ new Promise<typeof LOOKUP_TIMED_OUT>((resolve) =>
181
+ setTimeout(() => resolve(LOOKUP_TIMED_OUT), LOOKUP_TIMEOUT_MS).unref?.(),
182
+ ),
183
+ ])
184
+ if (addresses === LOOKUP_TIMED_OUT) return false
185
+ // `[].every()` is `true`: an empty answer must not read as "all public".
186
+ if (!Array.isArray(addresses) || addresses.length === 0) return false
187
+ return addresses.every((entry) => !isBlockedHost(entry.address))
188
+ } catch (error) {
189
+ const code = (error as { code?: string } | null)?.code
190
+ return typeof code === 'string' && NONEXISTENT_HOST_CODES.has(code)
191
+ }
192
+ }
193
+
194
+ export class BlockedRequestError extends Error {
195
+ constructor(public readonly reason: NonNullable<UrlValidationResult['reason']> | 'too_many_redirects') {
196
+ super(`Blocked outbound request: ${reason}`)
197
+ this.name = 'BlockedRequestError'
198
+ }
199
+ }
200
+
201
+ const MAX_REDIRECTS = 3
202
+
203
+ /**
204
+ * `fetch` with SSRF guards on the initial URL AND on every redirect target.
205
+ *
206
+ * `redirect: 'manual'` is the point: the default policy follows 3xx inside
207
+ * undici, where no guard of ours can see the new target.
208
+ */
209
+ export async function safeFetch(rawUrl: string, init: RequestInit): Promise<Response> {
210
+ let current = rawUrl
211
+ let body: BodyInit | null | undefined = init.body
212
+
213
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
214
+ const validation = validateWebhookUrl(current)
215
+ if (!validation.ok || !validation.url) throw new BlockedRequestError(validation.reason || 'invalid_url')
216
+ if (!(await assertPublicHost(validation.url.hostname))) throw new BlockedRequestError('private_host')
217
+
218
+ const response = await fetch(current, { ...init, body, redirect: 'manual' })
219
+ if (response.status < 300 || response.status > 399) return response
220
+
221
+ const location = response.headers.get('location')
222
+ if (!location) return response
223
+
224
+ current = new URL(location, current).toString()
225
+ // 303 (and, in practice, 301/302 on a POST) turns the follow-up into a GET.
226
+ if (response.status === 303) body = undefined
227
+ }
228
+
229
+ throw new BlockedRequestError('too_many_redirects')
230
+ }
@@ -2,6 +2,7 @@ import crypto from 'crypto'
2
2
  import type { BasePayload } from 'payload'
3
3
  import type { CollectionSlugs } from './slugs'
4
4
  import { dbFind, dbUpdate } from './db'
5
+ import { safeFetch } from './urlSafety'
5
6
 
6
7
  /**
7
8
  * Outbound webhook events. Must stay in sync with the `events` options of the
@@ -90,7 +91,10 @@ async function _sendToEndpoint(
90
91
  headers['X-Webhook-Signature'] = signature
91
92
  }
92
93
 
93
- const response = await fetch(endpoint.url, {
94
+ // safeFetch: rejects non-https and private / loopback / link-local targets,
95
+ // re-resolves the name before connecting (DNS rebinding) and re-checks every
96
+ // redirect hop instead of letting undici follow a 302 into the private range.
97
+ const response = await safeFetch(endpoint.url, {
94
98
  method: 'POST',
95
99
  headers,
96
100
  body,