@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.
- package/README.md +36 -2
- package/dist/index.cjs +558 -94
- package/dist/index.d.cts +20 -5
- package/dist/index.d.ts +20 -5
- package/dist/index.js +558 -94
- package/package.json +1 -1
- package/src/collections/ChatMessages.ts +59 -2
- package/src/collections/ClientSummaries.ts +10 -4
- package/src/collections/TicketMessages.ts +4 -1
- package/src/collections/WebhookEndpoints.ts +44 -2
- package/src/endpoints/admin-chat.ts +3 -3
- package/src/endpoints/ai-agent.ts +3 -3
- package/src/endpoints/ai.ts +3 -3
- package/src/endpoints/auth-2fa.ts +16 -4
- package/src/endpoints/capabilities.ts +6 -6
- package/src/endpoints/chat.ts +7 -5
- package/src/endpoints/chatbot.ts +48 -2
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/email-stats.ts +19 -3
- package/src/endpoints/import-conversation.ts +1 -1
- package/src/endpoints/index.ts +1 -1
- package/src/endpoints/invite-collaborator.ts +29 -3
- package/src/endpoints/login.ts +15 -2
- package/src/endpoints/oauth-google.ts +130 -8
- package/src/endpoints/resend-notification.ts +3 -3
- package/src/endpoints/send-reminder.ts +3 -3
- package/src/endpoints/signature.ts +9 -2
- package/src/endpoints/ticket-synthesis.ts +3 -3
- package/src/endpoints/transfer-ticket.ts +28 -3
- package/src/endpoints/typing.ts +117 -14
- package/src/endpoints/user-prefs.ts +5 -2
- package/src/plugin.ts +12 -0
- package/src/portal/auth/layout.tsx +19 -1
- package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
- package/src/portal/auth/tickets/detail/page.tsx +2 -6
- package/src/portal/login/page.tsx +11 -3
- package/src/utils/fireWebhooks.ts +4 -1
- package/src/utils/rateLimiter.ts +39 -5
- package/src/utils/readSettings.ts +124 -14
- package/src/utils/ticketAccess.ts +16 -1
- package/src/utils/twoFactorChallenge.ts +80 -0
- package/src/utils/urlSafety.ts +230 -0
- 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
|
-
|
|
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,
|