@consilioweb/payload-support 4.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 (49) hide show
  1. package/README.md +24 -14
  2. package/dist/index.cjs +115 -13
  3. package/dist/index.d.cts +20 -0
  4. package/dist/index.d.ts +20 -0
  5. package/dist/index.js +115 -13
  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/endpoints/auth-2fa.ts +53 -8
  25. package/src/endpoints/capabilities.ts +2 -4
  26. package/src/endpoints/chatbot.ts +2 -2
  27. package/src/endpoints/import-conversation.ts +2 -2
  28. package/src/endpoints/login.ts +13 -3
  29. package/src/endpoints/push.ts +14 -1
  30. package/src/endpoints/statuses.ts +17 -0
  31. package/src/portal/login/page.tsx +14 -4
  32. package/src/utils/push.ts +22 -0
  33. package/src/utils/rateLimiter.ts +98 -0
  34. package/src/utils/twoFactorChallenge.ts +6 -1
  35. package/src/utils/urlSafety.ts +36 -1
  36. package/src/views/BillingView/index.tsx +4 -4
  37. package/src/views/ChatView/index.tsx +4 -4
  38. package/src/views/CrmView/index.tsx +4 -4
  39. package/src/views/EmailTrackingView/index.tsx +4 -4
  40. package/src/views/ImportConversationView/index.tsx +4 -4
  41. package/src/views/LogsView/index.tsx +4 -2
  42. package/src/views/NewTicketView/index.tsx +4 -2
  43. package/src/views/PendingEmailsView/index.tsx +4 -4
  44. package/src/views/SupportDashboardView/index.tsx +4 -4
  45. package/src/views/TicketDetailView/index.tsx +4 -4
  46. package/src/views/TicketInboxView/index.tsx +4 -2
  47. package/src/views/TicketingSettingsView/index.tsx +4 -4
  48. package/src/views/TimeDashboardView/index.tsx +4 -4
  49. package/src/views/shared/viewAccess.ts +73 -0
@@ -1,12 +1,14 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
2
  import { DefaultTemplate } from '@payloadcms/next/templates';
3
3
  import { redirect } from 'next/navigation';
4
+ import { supportViewRedirectTarget } from '../shared/viewAccess.js';
4
5
  import { AdminErrorBoundary } from '../shared/ErrorBoundary.js';
5
6
  import { TicketInboxClient } from './client.js';
6
7
 
7
8
  const TicketInboxView = ({ initPageResult }) => {
8
9
  const { req, visibleEntities } = initPageResult;
9
- if (!req.user) redirect("/admin/login");
10
+ const redirectTo = supportViewRedirectTarget(initPageResult);
11
+ if (redirectTo) redirect(redirectTo);
10
12
  return /* @__PURE__ */ jsx(
11
13
  DefaultTemplate,
12
14
  {
@@ -16,7 +18,7 @@ const TicketInboxView = ({ initPageResult }) => {
16
18
  payload: req.payload,
17
19
  permissions: initPageResult.permissions,
18
20
  searchParams: {},
19
- user: req.user,
21
+ user: req.user ?? void 0,
20
22
  visibleEntities,
21
23
  children: /* @__PURE__ */ jsx(AdminErrorBoundary, { viewName: "TicketInboxView", children: /* @__PURE__ */ jsx(TicketInboxClient, {}) })
22
24
  }
@@ -1,14 +1,14 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
2
  import { DefaultTemplate } from '@payloadcms/next/templates';
3
3
  import { redirect } from 'next/navigation';
4
+ import { supportViewRedirectTarget } from '../shared/viewAccess.js';
4
5
  import { AdminErrorBoundary } from '../shared/ErrorBoundary.js';
5
6
  import { TicketingSettingsClient } from './client.js';
6
7
 
7
8
  const TicketingSettingsView = ({ initPageResult }) => {
8
9
  const { req, visibleEntities } = initPageResult;
9
- if (!req.user) {
10
- redirect("/admin/login");
11
- }
10
+ const redirectTo = supportViewRedirectTarget(initPageResult);
11
+ if (redirectTo) redirect(redirectTo);
12
12
  return /* @__PURE__ */ jsx(
13
13
  DefaultTemplate,
14
14
  {
@@ -18,7 +18,7 @@ const TicketingSettingsView = ({ initPageResult }) => {
18
18
  payload: req.payload,
19
19
  permissions: initPageResult.permissions,
20
20
  searchParams: {},
21
- user: req.user,
21
+ user: req.user ?? void 0,
22
22
  visibleEntities,
23
23
  children: /* @__PURE__ */ jsx(AdminErrorBoundary, { viewName: "TicketingSettingsView", children: /* @__PURE__ */ jsx(TicketingSettingsClient, {}) })
24
24
  }
@@ -1,14 +1,14 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
2
  import { DefaultTemplate } from '@payloadcms/next/templates';
3
3
  import { redirect } from 'next/navigation';
4
+ import { supportViewRedirectTarget } from '../shared/viewAccess.js';
4
5
  import { AdminErrorBoundary } from '../shared/ErrorBoundary.js';
5
6
  import { TimeDashboardClient } from './client.js';
6
7
 
7
8
  const TimeDashboardView = ({ initPageResult }) => {
8
9
  const { req, visibleEntities } = initPageResult;
9
- if (!req.user) {
10
- redirect("/admin/login");
11
- }
10
+ const redirectTo = supportViewRedirectTarget(initPageResult);
11
+ if (redirectTo) redirect(redirectTo);
12
12
  return /* @__PURE__ */ jsx(
13
13
  DefaultTemplate,
14
14
  {
@@ -18,7 +18,7 @@ const TimeDashboardView = ({ initPageResult }) => {
18
18
  payload: req.payload,
19
19
  permissions: initPageResult.permissions,
20
20
  searchParams: {},
21
- user: req.user,
21
+ user: req.user ?? void 0,
22
22
  visibleEntities,
23
23
  children: /* @__PURE__ */ jsx(AdminErrorBoundary, { viewName: "TimeDashboardView", children: /* @__PURE__ */ jsx(TimeDashboardClient, {}) })
24
24
  }
@@ -0,0 +1,29 @@
1
+ import type { AdminViewServerProps } from 'payload';
2
+ /**
3
+ * Where a custom admin view must send a caller it refuses, or `null` to render.
4
+ *
5
+ * Payload does NOT gate custom admin views. `RootPage` skips its own
6
+ * `canAccessAdmin` redirect as soon as `isCustomAdminView()` matches, and that
7
+ * helper only compares the request path against the registered `view.path` —
8
+ * it reads no visibility flag, despite what its docblock claims. Authorising a
9
+ * custom view is therefore the view's own job, and every view registered by
10
+ * this plugin sits at a custom path (`/support/inbox`, `/support/ticket`, …).
11
+ *
12
+ * `!req.user` alone is not that check. A single `payload-token` cookie serves
13
+ * every auth collection of the host app, so an ordinary front-office account —
14
+ * a `customers`, `members` or `subscribers` signup — carries one on `/admin`
15
+ * routes too. Such a caller reached `DefaultTemplate` and received the admin
16
+ * chrome together with the client config Payload builds for any authenticated
17
+ * request: the field schema of every collection and global, `admin.hidden`
18
+ * ones included, plus an unfiltered `visibleEntities`.
19
+ *
20
+ * The ticket data itself never travelled — the client components fetch through
21
+ * `/api/support/*`, which `requireAdmin` has guarded all along — but the shape
22
+ * of the whole CMS did.
23
+ *
24
+ * The gate mirrors `requireAdmin` (utils/auth.ts): membership of the staff
25
+ * collection. It additionally honours `canAccessAdmin`, so an account the host
26
+ * disabled through its own `access.admin` is refused here too, and it fails
27
+ * closed on anything it cannot resolve.
28
+ */
29
+ export declare function supportViewRedirectTarget(initPageResult: AdminViewServerProps['initPageResult']): string | null;
@@ -0,0 +1,24 @@
1
+ import { formatAdminURL } from 'payload/shared';
2
+ import { SUPPORT_STAFF_SLUG_CONFIG_KEY } from '../../utils/readSettings.js';
3
+
4
+ function supportViewRedirectTarget(initPageResult) {
5
+ const req = initPageResult?.req;
6
+ const config = req?.payload?.config;
7
+ const adminRoute = config?.routes?.admin ?? "/admin";
8
+ const routes = config?.admin?.routes;
9
+ const to = (route, fallback) => formatAdminURL({ adminRoute, path: route ?? fallback });
10
+ if (!req?.user) return to(routes?.login, "/login");
11
+ if (initPageResult?.permissions?.canAccessAdmin === false) {
12
+ return to(routes?.unauthorized, "/unauthorized");
13
+ }
14
+ const custom = config?.custom;
15
+ const registered = custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
16
+ const staffSlug = typeof registered === "string" && registered || config?.admin?.user || null;
17
+ const collection = req.user.collection;
18
+ if (!staffSlug || !collection || collection !== staffSlug) {
19
+ return to(routes?.unauthorized, "/unauthorized");
20
+ }
21
+ return null;
22
+ }
23
+
24
+ export { supportViewRedirectTarget };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "4.0.0",
3
+ "version": "5.0.0",
4
4
  "description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -85,12 +85,13 @@
85
85
  "author": "ConsilioWEB <contact@consilioweb.fr> (https://consilioweb.fr)",
86
86
  "license": "MIT",
87
87
  "peerDependencies": {
88
- "@payloadcms/next": "^3.37.0",
88
+ "@payloadcms/next": "^3.79.1",
89
89
  "lucide-react": ">=0.300.0",
90
90
  "next": "^15.2.9 || ^16.0.0",
91
- "payload": "^3.37.0",
91
+ "payload": "^3.79.1",
92
92
  "react": "^19.0.0",
93
- "react-dom": "^19.0.0"
93
+ "react-dom": "^19.0.0",
94
+ "@payloadcms/richtext-lexical": "^3.79.1"
94
95
  },
95
96
  "engines": {
96
97
  "node": ">=20.9.0"
@@ -100,32 +101,37 @@
100
101
  "provenance": true
101
102
  },
102
103
  "devDependencies": {
103
- "@payloadcms/db-sqlite": "^3.86.0",
104
- "@payloadcms/graphql": "^3.86.0",
105
- "@payloadcms/next": "^3.86.0",
106
- "@payloadcms/richtext-lexical": "^3.86.0",
107
- "@payloadcms/translations": "^3.86.0",
108
- "@payloadcms/ui": "^3.86.0",
109
- "@playwright/test": "^1.49.0",
104
+ "@payloadcms/db-sqlite": "^3.88.0",
105
+ "@payloadcms/graphql": "^3.88.0",
106
+ "@payloadcms/next": "^3.88.0",
107
+ "@payloadcms/richtext-lexical": "^3.88.0",
108
+ "@payloadcms/translations": "^3.88.0",
109
+ "@payloadcms/ui": "^3.88.0",
110
+ "@playwright/test": "^1.63.0",
110
111
  "@types/pdfkit": "^0.17.6",
111
- "@types/react": "^19.0.0",
112
+ "@types/react": "^19.2.18",
112
113
  "@types/sanitize-html": "^2.16.1",
113
114
  "@types/web-push": "^3.6.4",
114
115
  "esbuild-sass-plugin": "^3.7.0",
115
- "next": "^16.2.9",
116
- "payload": "^3.86.0",
117
- "react": "^19.2.7",
118
- "react-dom": "^19.2.7",
116
+ "next": "^16.3.4",
117
+ "payload": "^3.88.0",
118
+ "react": "^19.2.8",
119
+ "react-dom": "^19.2.8",
119
120
  "sharp": "0.34.5",
120
- "tsup": "^8.0.0",
121
- "typescript": "^5.5.0",
122
- "vitest": "^4.1.9"
121
+ "tsup": "^8.5.1",
122
+ "typescript": "^5.9.3",
123
+ "vitest": "^4.1.11"
123
124
  },
124
125
  "dependencies": {
125
126
  "pdfkit": "^0.19.1",
126
- "sanitize-html": "^2.17.5",
127
+ "sanitize-html": "^2.17.7",
127
128
  "web-push": "^3.6.7"
128
129
  },
130
+ "peerDependenciesMeta": {
131
+ "@payloadcms/richtext-lexical": {
132
+ "optional": true
133
+ }
134
+ },
129
135
  "scripts": {
130
136
  "build": "tsup && tsc -p tsconfig.types.json && node scripts/copy-subpath-types.mjs",
131
137
  "typecheck": "tsc --noEmit",
@@ -4,7 +4,7 @@ import crypto, { createHmac } from 'crypto'
4
4
  import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
5
  import { escapeHtml } from '../utils/emailTemplate'
6
6
  import { dbFind, dbUpdate } from '../utils/db'
7
- import { verifyTwoFactorChallenge } from '../utils/twoFactorChallenge'
7
+ import { issueTwoFactorChallenge, normalizeEmail, verifyTwoFactorChallenge } from '../utils/twoFactorChallenge'
8
8
 
9
9
  function generateSecureCode(): string {
10
10
  const buf = crypto.randomBytes(4)
@@ -46,7 +46,35 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
46
46
  return Response.json({ error: 'Paramètres manquants' }, { status: 400 })
47
47
  }
48
48
 
49
- const genericSendResponse = { success: true, message: 'Si un compte existe, un code a été envoyé.' }
49
+ // Both limiters are keyed on the SAME normalized address the challenge is
50
+ // signed over. Keyed on the raw input, one challenge bought one budget per
51
+ // casing variant of the address it was minted for.
52
+ const limiterKey = normalizeEmail(email)
53
+
54
+ /**
55
+ * A challenge is a 10-minute proof that the password step succeeded. It
56
+ * is refreshed on every accepted `send` so it outlives exactly as long as
57
+ * the code that send just minted: without this, a resend late in the
58
+ * window produced a code whose challenge expired before the user could
59
+ * type it, and `verify` answered 401 on a perfectly valid code.
60
+ *
61
+ * Returned on EVERY `send` reply, including the throttled and the
62
+ * unknown-address ones, so the response shape never tells the caller
63
+ * whether the account exists.
64
+ */
65
+ const sendResponse = (): Response => {
66
+ let refreshed: string | undefined
67
+ try {
68
+ refreshed = issueTwoFactorChallenge(email)
69
+ } catch {
70
+ // PAYLOAD_SECRET vanished mid-flight: answer without a challenge.
71
+ }
72
+ return Response.json({
73
+ success: true,
74
+ message: 'Si un compte existe, un code a été envoyé.',
75
+ ...(refreshed ? { challenge: refreshed } : {}),
76
+ })
77
+ }
50
78
 
51
79
  if (action === 'send') {
52
80
  // The challenge is checked BEFORE the limiter on purpose: the limiter
@@ -60,8 +88,8 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
60
88
  )
61
89
  }
62
90
 
63
- if (await sendLimiter.check(email, req)) {
64
- return Response.json(genericSendResponse)
91
+ if (await sendLimiter.check(limiterKey, req)) {
92
+ return sendResponse()
65
93
  }
66
94
 
67
95
  const clients = await dbFind(payload, slugs.supportClients, {
@@ -72,7 +100,7 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
72
100
  })
73
101
 
74
102
  if (clients.docs.length === 0) {
75
- return Response.json(genericSendResponse)
103
+ return sendResponse()
76
104
  }
77
105
 
78
106
  const client = clients.docs[0] as any
@@ -101,15 +129,29 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
101
129
  </div>`,
102
130
  })
103
131
 
104
- return Response.json(genericSendResponse)
132
+ return sendResponse()
105
133
  }
106
134
 
107
135
  if (action === 'verify') {
136
+ // Same proof, same reason, same position as `send` above: the limiter
137
+ // below is keyed on the VICTIM's email, so an unauthenticated caller
138
+ // able to reach it could burn the 5 attempts and leave the victim —
139
+ // holding the right password AND the right code — locked out of the
140
+ // only route that clears the 2FA gate, renewably, every 15 minutes.
141
+ // Only `send` got this guard in the previous pass; `verify` is the
142
+ // other half of the same door.
143
+ if (!verifyTwoFactorChallenge(email, challenge)) {
144
+ return Response.json(
145
+ { error: 'Authentification requise avant la vérification d\'un code.' },
146
+ { status: 401 },
147
+ )
148
+ }
149
+
108
150
  if (!code) {
109
151
  return Response.json({ error: 'Code manquant' }, { status: 400 })
110
152
  }
111
153
 
112
- if (await verifyLimiter.check(email, req)) {
154
+ if (await verifyLimiter.check(limiterKey, req)) {
113
155
  return Response.json(
114
156
  { error: 'Trop de tentatives. Réessayez dans 15 minutes.' },
115
157
  { status: 429 },
@@ -159,7 +201,10 @@ export function createAuth2faEndpoint(slugs: CollectionSlugs, store?: RateLimitS
159
201
  overrideAccess: true,
160
202
  })
161
203
 
162
- verifyLimiter.reset(email)
204
+ // `req` is REQUIRED here: PayloadRateLimitStore throws without it, and
205
+ // this reset runs after the marker was written — a throw turned a
206
+ // successful verification into a 500 for the caller.
207
+ await verifyLimiter.reset(limiterKey, req)
163
208
 
164
209
  return Response.json({ success: true, verified: true })
165
210
  }
@@ -1,7 +1,7 @@
1
1
  import type { Endpoint, Where } from 'payload'
2
2
  import type { SupportCapabilities } from '../types'
3
3
  import type { CollectionSlugs } from '../utils/slugs'
4
- import { principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
+ import { clientIpRateKey, principalRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
5
5
  import {
6
6
  validateInboundEmailPayload,
7
7
  verifySecret,
@@ -21,9 +21,7 @@ export function createInboundEmailEndpoint(
21
21
  if (!verifySecret(req.headers.get(secretHeader), capability.secret)) {
22
22
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
23
23
  }
24
- const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
25
- || req.headers.get('x-real-ip')
26
- || 'unknown'
24
+ const ip = clientIpRateKey(req)
27
25
  if (await limiter.check(ip, req)) {
28
26
  return Response.json({ error: 'Rate limit exceeded' }, { status: 429 })
29
27
  }
@@ -1,6 +1,6 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
3
+ import { clientIpRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
4
  import { dbFind } from '../utils/db'
5
5
 
6
6
 
@@ -41,7 +41,7 @@ export function createChatbotEndpoint(
41
41
  method: 'post',
42
42
  handler: async (req) => {
43
43
  try {
44
- const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
44
+ const ip = clientIpRateKey(req)
45
45
  if (await chatbotLimiter.check(ip, req)) {
46
46
  return Response.json({ error: 'Too many requests. Please wait a moment.' }, { status: 429 })
47
47
  }
@@ -1,6 +1,6 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
3
+ import { clientIpRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
4
  import { readSupportSettings } from '../utils/readSettings'
5
5
  import { dbFind, dbCreate } from '../utils/db'
6
6
  import { verifySecret } from '../utils/webhookSecurity'
@@ -154,7 +154,7 @@ export function createImportConversationEndpoint(slugs: CollectionSlugs, store?:
154
154
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
155
155
  }
156
156
 
157
- const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
157
+ const ip = clientIpRateKey(req)
158
158
  if (await importLimiter.check(ip, req)) {
159
159
  return Response.json({ error: 'Rate limit exceeded. Maximum 10 imports per hour.' }, { status: 429 })
160
160
  }
@@ -1,10 +1,13 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
- import { RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
3
+ import { clientIpRateKey, RateLimiter, type RateLimitStore } from '../utils/rateLimiter'
4
4
  import { dbCreate } from '../utils/db'
5
5
  import { issueTwoFactorChallenge } from '../utils/twoFactorChallenge'
6
6
 
7
7
 
8
+ /** Enough to identify a browser, short enough that a flood cannot fill the disk. */
9
+ const MAX_LOGGED_USER_AGENT = 256
10
+
8
11
  /**
9
12
  * POST /api/support/login
10
13
  * Client login endpoint.
@@ -18,7 +21,12 @@ export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitSto
18
21
  // NOTE: x-forwarded-for is spoofable unless this app sits strictly behind a
19
22
  // trusted proxy that rewrites it. This IP rate-limit is a SECONDARY defense;
20
23
  // the primary brute-force control is Payload's account lock (maxLoginAttempts).
21
- const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || req.headers.get('x-real-ip') || 'unknown'
24
+ //
25
+ // `clientIpRateKey` is what keeps a spoofed header from being more than a
26
+ // bucket choice: the raw header used to become the limiter key AND the
27
+ // `ipAddress` column below, so rotating it minted unbounded map entries in
28
+ // memory and unbounded rows on disk.
29
+ const ip = clientIpRateKey(req)
22
30
 
23
31
  if (await loginLimiter.check(ip, req)) {
24
32
  return Response.json(
@@ -35,7 +43,9 @@ export function createLoginEndpoint(slugs: CollectionSlugs, store?: RateLimitSto
35
43
  return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
36
44
  }
37
45
  const { email, password } = body
38
- const userAgent = req.headers.get('user-agent') || ''
46
+ // Persisted on every failed attempt, from an anonymous request: bounded so
47
+ // a flood cannot write tens of kilobytes per row into `auth-logs`.
48
+ const userAgent = (req.headers.get('user-agent') || '').slice(0, MAX_LOGGED_USER_AGENT)
39
49
 
40
50
  if (!email || !password) {
41
51
  return Response.json({ error: 'Email et mot de passe requis.' }, { status: 400 })
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
4
  import { dbFind, dbCreate, dbUpdate } from '../utils/db'
5
5
  import { getVapidPublicKey } from '../utils/push'
6
+ import { validatePushEndpoint, WEBHOOK_URL_MESSAGES } from '../utils/urlSafety'
6
7
 
7
8
  /** GET /api/support/push/vapid-public-key — public key the browser uses to subscribe. */
8
9
  export function createVapidKeyEndpoint(): Endpoint {
@@ -30,7 +31,19 @@ export function createPushSubscribeEndpoint(slugs: CollectionSlugs): Endpoint {
30
31
  if (!endpoint || !p256dh || !auth) {
31
32
  return Response.json({ error: 'subscription invalide (endpoint + keys requis).' }, { status: 400 })
32
33
  }
33
- const data = { user: req.user!.id, endpoint, p256dh, auth, userAgent: req.headers.get('user-agent') || '' }
34
+ // WRITE-time SSRF guard. `requireAdmin` above only proves the caller
35
+ // belongs to the staff collection — the very actor `utils/urlSafety`
36
+ // already models as hostile for webhook URLs — and `web-push` hands this
37
+ // string's host and port straight to `https.request`. Same guard, same
38
+ // reason, on the second field that steers an outbound request.
39
+ const endpointCheck = validatePushEndpoint(endpoint)
40
+ if (!endpointCheck.ok) {
41
+ return Response.json(
42
+ { error: WEBHOOK_URL_MESSAGES[endpointCheck.reason || 'invalid_url'] },
43
+ { status: 400 },
44
+ )
45
+ }
46
+ const data = { user: req.user!.id, endpoint, p256dh, auth, userAgent: (req.headers.get('user-agent') || '').slice(0, 256) }
34
47
  const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true })
35
48
  if (existing.docs.length > 0) {
36
49
  await dbUpdate(req.payload, slugs.pushSubscriptions, { id: (existing.docs[0] as { id: number | string }).id, data, overrideAccess: true })
@@ -5,6 +5,18 @@ import { dbFind } from '../utils/db'
5
5
  /**
6
6
  * GET /api/support/statuses
7
7
  * Returns all ticket statuses sorted by sortOrder.
8
+ *
9
+ * The guard used to be `!!req.user`, which ANY authenticated principal
10
+ * satisfies — including a member of an unrelated auth collection of the host
11
+ * app (customers, members, subscribers created by public sign-up). Because the
12
+ * rows below are read with `overrideAccess: true`, that handed them the whole
13
+ * support workflow taxonomy — internal status names, private states, pipeline
14
+ * order — which `ticket-statuses.access.read` explicitly refuses them.
15
+ *
16
+ * The check below MIRRORS that ACL rather than replacing it: staff and
17
+ * support-clients, nobody else. `overrideAccess` stays, because the endpoint
18
+ * legitimately serves the full list to both, and the projection it returns is
19
+ * narrower than the documents themselves.
8
20
  */
9
21
  export function createStatusesEndpoint(slugs: CollectionSlugs): Endpoint {
10
22
  return {
@@ -18,6 +30,11 @@ export function createStatusesEndpoint(slugs: CollectionSlugs): Endpoint {
18
30
  return Response.json({ error: 'Unauthorized' }, { status: 401 })
19
31
  }
20
32
 
33
+ const collection = (req.user as { collection?: string }).collection
34
+ if (collection !== slugs.users && collection !== slugs.supportClients) {
35
+ return Response.json({ error: 'Forbidden' }, { status: 403 })
36
+ }
37
+
21
38
  const { docs } = await dbFind(payload, slugs.ticketStatuses, {
22
39
  sort: 'sortOrder',
23
40
  limit: 100,
@@ -33,8 +33,11 @@ function SupportLoginContent() {
33
33
  // 2FA state
34
34
  const [needs2FA, setNeeds2FA] = useState(false)
35
35
  // Proof that the password step succeeded, minted by /support/login. Required
36
- // by /support/2fa {action:'send'} — that endpoint no longer sends a code to
37
- // anyone who merely knows the address.
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.
38
41
  const [challenge, setChallenge] = useState('')
39
42
  const [twoFactorCode, setTwoFactorCode] = useState('')
40
43
  const [sending2FA, setSending2FA] = useState(false)
@@ -72,6 +75,8 @@ function SupportLoginContent() {
72
75
  })
73
76
 
74
77
  if (codeRes.ok) {
78
+ const codeData = await codeRes.json().catch(() => ({}))
79
+ if (codeData?.challenge) setChallenge(codeData.challenge)
75
80
  setNeeds2FA(true)
76
81
  } else {
77
82
  setError('Erreur lors de l\'envoi du code de vérification.')
@@ -98,13 +103,16 @@ function SupportLoginContent() {
98
103
  const verifyRes = await fetch('/api/support/2fa', {
99
104
  method: 'POST',
100
105
  headers: { 'Content-Type': 'application/json' },
101
- body: JSON.stringify({ action: 'verify', email, code: twoFactorCode }),
106
+ body: JSON.stringify({ action: 'verify', email, code: twoFactorCode, challenge }),
102
107
  })
103
108
 
104
109
  const verifyData = await verifyRes.json()
105
110
 
106
111
  if (!verifyRes.ok || !verifyData.verified) {
107
- 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.')
108
116
  return
109
117
  }
110
118
 
@@ -138,6 +146,8 @@ function SupportLoginContent() {
138
146
  body: JSON.stringify({ action: 'send', email, challenge }),
139
147
  })
140
148
  if (res.ok) {
149
+ const data = await res.json().catch(() => ({}))
150
+ if (data?.challenge) setChallenge(data.challenge)
141
151
  setError('')
142
152
  } else {
143
153
  // 401 = the challenge expired (10 min): the password must be re-entered.
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 } },