@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.
- package/README.md +24 -14
- package/dist/index.cjs +115 -13
- package/dist/index.d.cts +20 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.js +115 -13
- package/dist/utils/db.d.ts +34 -0
- package/dist/utils/readSettings.d.ts +90 -0
- package/dist/views/BillingView/index.js +4 -4
- package/dist/views/ChatView/index.js +4 -4
- package/dist/views/CrmView/index.js +4 -4
- package/dist/views/EmailTrackingView/index.js +4 -4
- package/dist/views/ImportConversationView/index.js +4 -4
- package/dist/views/LogsView/index.js +4 -2
- package/dist/views/NewTicketView/index.js +4 -2
- package/dist/views/PendingEmailsView/index.js +4 -4
- package/dist/views/SupportDashboardView/index.js +4 -4
- package/dist/views/TicketDetailView/index.js +4 -4
- package/dist/views/TicketInboxView/index.js +4 -2
- package/dist/views/TicketingSettingsView/index.js +4 -4
- package/dist/views/TimeDashboardView/index.js +4 -4
- package/dist/views/shared/viewAccess.d.ts +29 -0
- package/dist/views/shared/viewAccess.js +24 -0
- package/package.json +26 -20
- package/src/endpoints/auth-2fa.ts +53 -8
- package/src/endpoints/capabilities.ts +2 -4
- package/src/endpoints/chatbot.ts +2 -2
- package/src/endpoints/import-conversation.ts +2 -2
- package/src/endpoints/login.ts +13 -3
- package/src/endpoints/push.ts +14 -1
- package/src/endpoints/statuses.ts +17 -0
- package/src/portal/login/page.tsx +14 -4
- package/src/utils/push.ts +22 -0
- package/src/utils/rateLimiter.ts +98 -0
- package/src/utils/twoFactorChallenge.ts +6 -1
- package/src/utils/urlSafety.ts +36 -1
- package/src/views/BillingView/index.tsx +4 -4
- package/src/views/ChatView/index.tsx +4 -4
- package/src/views/CrmView/index.tsx +4 -4
- package/src/views/EmailTrackingView/index.tsx +4 -4
- package/src/views/ImportConversationView/index.tsx +4 -4
- package/src/views/LogsView/index.tsx +4 -2
- package/src/views/NewTicketView/index.tsx +4 -2
- package/src/views/PendingEmailsView/index.tsx +4 -4
- package/src/views/SupportDashboardView/index.tsx +4 -4
- package/src/views/TicketDetailView/index.tsx +4 -4
- package/src/views/TicketInboxView/index.tsx +4 -2
- package/src/views/TicketingSettingsView/index.tsx +4 -4
- package/src/views/TimeDashboardView/index.tsx +4 -4
- 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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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": "
|
|
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.
|
|
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.
|
|
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.
|
|
104
|
-
"@payloadcms/graphql": "^3.
|
|
105
|
-
"@payloadcms/next": "^3.
|
|
106
|
-
"@payloadcms/richtext-lexical": "^3.
|
|
107
|
-
"@payloadcms/translations": "^3.
|
|
108
|
-
"@payloadcms/ui": "^3.
|
|
109
|
-
"@playwright/test": "^1.
|
|
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.
|
|
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.
|
|
116
|
-
"payload": "^3.
|
|
117
|
-
"react": "^19.2.
|
|
118
|
-
"react-dom": "^19.2.
|
|
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.
|
|
121
|
-
"typescript": "^5.
|
|
122
|
-
"vitest": "^4.1.
|
|
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.
|
|
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
|
-
|
|
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(
|
|
64
|
-
return
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/endpoints/chatbot.ts
CHANGED
|
@@ -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 =
|
|
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
|
|
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
|
}
|
package/src/endpoints/login.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 })
|
package/src/endpoints/push.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
37
|
-
//
|
|
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
|
-
|
|
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 } },
|