@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
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/@consilioweb/payload-support)
|
|
6
6
|
[](LICENSE)
|
|
7
|
-
[](https://payloadcms.com)
|
|
8
8
|
|
|
9
9
|
## About
|
|
10
10
|
|
|
@@ -81,12 +81,18 @@ pnpm add @consilioweb/payload-support
|
|
|
81
81
|
**Peer dependencies** — all six are required, none is optional:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
pnpm add payload@^3.
|
|
84
|
+
pnpm add payload@^3.79.1 @payloadcms/next@^3.79.1 next@^15.2.9 react@^19 react-dom@^19 lucide-react@">=0.300.0"
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
- `payload@^3.
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
- `payload@^3.79.1` — the floor is a security floor, and it is stated as such rather than
|
|
88
|
+
dressed up as an API constraint. Below 3.79.1 Payload is vulnerable to a pre-authentication
|
|
89
|
+
account takeover (GHSA-hp5w-3hxx-vmwf) and to a SQL injection, so a range that admitted those
|
|
90
|
+
versions — the previous `^3.37.0` did — let an install satisfy this plugin's requirements while
|
|
91
|
+
remaining exploitable. The API surface allows lower: the Google OAuth endpoint imports `jwtSign`
|
|
92
|
+
and `getFieldsToSign` from the `payload` barrel, and a missing named export fails the whole
|
|
93
|
+
`payload.config.ts` rather than just that endpoint, but both have been exported since 3.37 and
|
|
94
|
+
every symbol this package imports was verified present in 3.79.1. CI builds and tests against
|
|
95
|
+
the 3.88 line; 3.79.1 to 3.87 are permitted on that verification, not on a test run.
|
|
90
96
|
- `@payloadcms/next` and `next` — the 13 admin views are registered unless you pass
|
|
91
97
|
`skipViews: true`, and each imports `DefaultTemplate` from `@payloadcms/next/templates` plus
|
|
92
98
|
`next/navigation` / `next/link` statically. Both are already present in any Payload 3 admin app.
|
|
@@ -407,7 +413,7 @@ to stay enabled for the endpoint to be registered.
|
|
|
407
413
|
|---|---|---|---|---|
|
|
408
414
|
| `GET` | `/support/search` | staff | — | Global search over tickets, messages, clients and the knowledge base. |
|
|
409
415
|
| `GET` | `/support/kb/search` | public | — | Knowledge-base search (used by the portal and the chatbot). |
|
|
410
|
-
| `GET` | `/support/statuses` |
|
|
416
|
+
| `GET` | `/support/statuses` | staff or support-client | `customStatuses` | Ticket statuses, sorted. Mirrors `ticket-statuses.access.read`: a principal from any other auth collection of your app gets 403, not the taxonomy. |
|
|
411
417
|
| `POST` | `/support/bulk-action` | staff | `bulkActions` | Apply one action to many tickets. |
|
|
412
418
|
| `POST` | `/support/merge-tickets` | staff | `merge` | Merge a source ticket into a target. |
|
|
413
419
|
| `POST` | `/support/split-ticket` | staff | `splitTicket` | Extract a message into a new ticket. |
|
|
@@ -430,7 +436,7 @@ to stay enabled for the endpoint to be registered.
|
|
|
430
436
|
| Method | Path | Access | Purpose |
|
|
431
437
|
|---|---|---|---|
|
|
432
438
|
| `POST` | `/support/login` | public, rate-limited | Portal password login. Sets an `HttpOnly` cookie; the JWT is never returned in JSON. |
|
|
433
|
-
| `POST` | `/support/2fa` | public, rate-limited | Email second factor. `action: 'send'`
|
|
439
|
+
| `POST` | `/support/2fa` | public, rate-limited | Email second factor. **Both** `action: 'send'` and `action: 'verify'` require the short-lived `challenge` returned by `/support/login` (or by the Google callback) alongside `requires2FA`; without it, an anonymous caller who merely knew an address could burn the victim's send quota *or* their 5 verification attempts and lock them out of the only route that clears the 2FA gate. Every accepted `send` returns a refreshed `challenge` — keep the latest one and pass it to `verify` along with the code. |
|
|
434
440
|
| `POST` | `/support/oauth/google` | public | Google sign-in, optionally restricted by `allowedEmailDomains`. |
|
|
435
441
|
| `GET` | `/support/email-stats` | **staff**, rate-limited | Email pipeline aggregate. Was reachable by any authenticated session before 3.0.1. |
|
|
436
442
|
| `GET` | `/support/export-data` | client | GDPR data export. |
|
|
@@ -495,7 +501,7 @@ await fetch('/api/support/ai-agent', {
|
|
|
495
501
|
| `GET`/`POST` | `/support/settings` | staff | — | Read and write the support settings, including the runtime feature flags. |
|
|
496
502
|
| `GET`/`POST` | `/support/round-robin-config` | staff | `roundRobin` | Read and toggle round-robin assignment. |
|
|
497
503
|
| `GET` | `/support/push/vapid-public-key` | public | — | VAPID public key for the browser subscription. |
|
|
498
|
-
| `POST` | `/support/push/subscribe` | staff | — | Register an agent's push subscription. |
|
|
504
|
+
| `POST` | `/support/push/subscribe` | staff | — | Register an agent's push subscription. The `endpoint` must be an `https://` URL on a public host — private, loopback and link-local targets are refused with 400 (SSRF), at write time and again at send time. |
|
|
499
505
|
| `GET` | `/support/auto-close` | `x-cron-secret` | `autoClose` | Remind, then close, inactive tickets. |
|
|
500
506
|
| `POST` | `/support/process-snooze` | `x-cron-secret` | `snooze` | Wake snoozed tickets. |
|
|
501
507
|
| `POST` | `/support/process-scheduled` | `x-cron-secret` | `scheduledReplies` | Release scheduled replies. |
|
|
@@ -614,16 +620,18 @@ import type { SupportPluginConfig, SupportFeatures } from '@consilioweb/payload-
|
|
|
614
620
|
| Requirement | Range | Source |
|
|
615
621
|
|---|---|---|
|
|
616
622
|
| Node.js | `>=20.9.0` | `engines.node` |
|
|
617
|
-
| Payload | `^3.
|
|
618
|
-
| `@payloadcms/next` | `^3.
|
|
623
|
+
| Payload | `^3.79.1` | `peerDependencies.payload` |
|
|
624
|
+
| `@payloadcms/next` | `^3.79.1` | `peerDependencies` |
|
|
619
625
|
| Next.js | `^15.2.9 \|\| ^16.0.0` | `peerDependencies` |
|
|
620
626
|
| React / React DOM | `^19.0.0` | `peerDependencies` |
|
|
621
627
|
| `lucide-react` | `>=0.300.0` | `peerDependencies` |
|
|
622
628
|
| `@anthropic-ai/sdk` | any recent version | Runtime-only, install it yourself if you use the AI features |
|
|
623
629
|
| `@payloadcms/richtext-lexical` | any 3.x | Only if you mount the portal FAQ page |
|
|
624
630
|
|
|
625
|
-
Installing on Node 18, React 18, Next 14 or Payload < 3.
|
|
626
|
-
`engine-strict` or a strict peer resolver.
|
|
631
|
+
Installing on Node 18, React 18, Next 14 or Payload < 3.79.1 warns, and fails outright under
|
|
632
|
+
`engine-strict` or a strict peer resolver. Payload below 3.79.1 is additionally vulnerable to a
|
|
633
|
+
pre-authentication account takeover (GHSA-hp5w-3hxx-vmwf) and to a SQL injection: upgrade rather
|
|
634
|
+
than force the install.
|
|
627
635
|
|
|
628
636
|
## Security
|
|
629
637
|
|
|
@@ -657,7 +665,9 @@ Installing on Node 18, React 18, Next 14 or Payload < 3.37 warns, and fails outr
|
|
|
657
665
|
- **Bounded inbound email** payloads and attachments.
|
|
658
666
|
- **Bounded outbound mail** — ticket transfers and collaborator invitations are capped per user over
|
|
659
667
|
a long window, not only per ticket (a client can create tickets at will).
|
|
660
|
-
- **SSRF-guarded outbound
|
|
668
|
+
- **SSRF-guarded outbound requests** — scheme, literal host, resolved address and every redirect hop
|
|
669
|
+
for webhooks; scheme, literal host and resolved address for Web Push subscription endpoints, which
|
|
670
|
+
`web-push` would otherwise hand host and port straight to `https.request`.
|
|
661
671
|
- **2FA also enforced on the Google OAuth path**, which mints its session outside `payload.login`
|
|
662
672
|
and therefore outside the `beforeLogin` hook.
|
|
663
673
|
|
|
@@ -741,7 +751,7 @@ auto-migrated: generate and push it before deploying. See [Troubleshooting](#tro
|
|
|
741
751
|
recomputing it — so a stale total keeps over-billing until something touches a time entry on that
|
|
742
752
|
ticket.
|
|
743
753
|
5. **Import `./views` and `./components/TicketConversation` with ESM** — their CJS builds are gone.
|
|
744
|
-
6. Check your platform: Node 20.9+, React 19, Next 15.2.9+, Payload 3.
|
|
754
|
+
6. Check your platform: Node 20.9+, React 19, Next 15.2.9+, Payload 3.79.1+, and install
|
|
745
755
|
`lucide-react` explicitly if you were relying on it being optional.
|
|
746
756
|
|
|
747
757
|
### 1.x → 2.0
|
package/dist/index.cjs
CHANGED
|
@@ -84,12 +84,43 @@ var DEFAULT_SLUGS = {
|
|
|
84
84
|
function resolveSlugs(overrides) {
|
|
85
85
|
return { ...DEFAULT_SLUGS, ...overrides };
|
|
86
86
|
}
|
|
87
|
+
var MAX_MEMORY_RATE_LIMIT_KEYS = 1e4;
|
|
87
88
|
var MemoryRateLimitStore = class {
|
|
89
|
+
constructor(maxKeys = MAX_MEMORY_RATE_LIMIT_KEYS) {
|
|
90
|
+
this.maxKeys = maxKeys;
|
|
91
|
+
}
|
|
92
|
+
maxKeys;
|
|
88
93
|
entries = /* @__PURE__ */ new Map();
|
|
94
|
+
/** Distinct keys currently held. Exposed so the ceiling can be asserted. */
|
|
95
|
+
get size() {
|
|
96
|
+
return this.entries.size;
|
|
97
|
+
}
|
|
98
|
+
/** Reclaims every window that has already closed. */
|
|
99
|
+
sweepExpired(now) {
|
|
100
|
+
for (const [key, entry] of this.entries) {
|
|
101
|
+
if (now > entry.resetAt) this.entries.delete(key);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Soonest-closing window first, so the ceiling drops the least useful entry. */
|
|
105
|
+
evictOldest() {
|
|
106
|
+
let oldestKey = null;
|
|
107
|
+
let oldestResetAt = Infinity;
|
|
108
|
+
for (const [key, entry] of this.entries) {
|
|
109
|
+
if (entry.resetAt < oldestResetAt) {
|
|
110
|
+
oldestResetAt = entry.resetAt;
|
|
111
|
+
oldestKey = key;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (oldestKey !== null) this.entries.delete(oldestKey);
|
|
115
|
+
}
|
|
89
116
|
async increment(key, windowMs) {
|
|
90
117
|
const now = Date.now();
|
|
91
118
|
const current = this.entries.get(key);
|
|
92
119
|
const next = !current || now > current.resetAt ? { count: 1, resetAt: now + windowMs } : { ...current, count: current.count + 1 };
|
|
120
|
+
if (!current && this.entries.size >= this.maxKeys) {
|
|
121
|
+
this.sweepExpired(now);
|
|
122
|
+
if (this.entries.size >= this.maxKeys) this.evictOldest();
|
|
123
|
+
}
|
|
93
124
|
this.entries.set(key, next);
|
|
94
125
|
return next;
|
|
95
126
|
}
|
|
@@ -173,6 +204,23 @@ function principalRateKey(user) {
|
|
|
173
204
|
const collection = typeof user.collection === "string" && user.collection ? user.collection : "unknown";
|
|
174
205
|
return `${collection}:${String(user.id)}`;
|
|
175
206
|
}
|
|
207
|
+
var MAX_IP_KEY_LENGTH = 45;
|
|
208
|
+
var IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/;
|
|
209
|
+
var IPV6_PATTERN = /^[0-9a-fA-F:.%]+$/;
|
|
210
|
+
function clientIpRateKey(req) {
|
|
211
|
+
const candidate = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip")?.trim() || "";
|
|
212
|
+
return normalizeIpKey(candidate);
|
|
213
|
+
}
|
|
214
|
+
function normalizeIpKey(candidate) {
|
|
215
|
+
if (!candidate || candidate.length > MAX_IP_KEY_LENGTH) return "unknown";
|
|
216
|
+
const host = candidate.startsWith("[") && candidate.endsWith("]") ? candidate.slice(1, -1) : candidate;
|
|
217
|
+
if (!host) return "unknown";
|
|
218
|
+
if (IPV4_PATTERN.test(host)) {
|
|
219
|
+
return host.split(".").every((octet) => Number(octet) <= 255) ? host : "unknown";
|
|
220
|
+
}
|
|
221
|
+
if (host.includes(":") && IPV6_PATTERN.test(host)) return host.toLowerCase();
|
|
222
|
+
return "unknown";
|
|
223
|
+
}
|
|
176
224
|
var RateLimiter = class {
|
|
177
225
|
/**
|
|
178
226
|
* @param namespace Endpoint-scoped prefix for every key this limiter writes.
|
|
@@ -252,7 +300,7 @@ function createInboundEmailEndpoint(capability, store) {
|
|
|
252
300
|
if (!verifySecret(req.headers.get(secretHeader), capability.secret)) {
|
|
253
301
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
254
302
|
}
|
|
255
|
-
const ip =
|
|
303
|
+
const ip = clientIpRateKey(req);
|
|
256
304
|
if (await limiter.check(ip, req)) {
|
|
257
305
|
return Response.json({ error: "Rate limit exceeded" }, { status: 429 });
|
|
258
306
|
}
|
|
@@ -2809,6 +2857,10 @@ function createStatusesEndpoint(slugs) {
|
|
|
2809
2857
|
if (!req.user) {
|
|
2810
2858
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
2811
2859
|
}
|
|
2860
|
+
const collection = req.user.collection;
|
|
2861
|
+
if (collection !== slugs.users && collection !== slugs.supportClients) {
|
|
2862
|
+
return Response.json({ error: "Forbidden" }, { status: 403 });
|
|
2863
|
+
}
|
|
2812
2864
|
const { docs } = await dbFind(payload, slugs.ticketStatuses, {
|
|
2813
2865
|
sort: "sortOrder",
|
|
2814
2866
|
limit: 100,
|
|
@@ -2999,7 +3051,7 @@ function createChatbotEndpoint(slugs, store, maxPerHour) {
|
|
|
2999
3051
|
method: "post",
|
|
3000
3052
|
handler: async (req) => {
|
|
3001
3053
|
try {
|
|
3002
|
-
const ip =
|
|
3054
|
+
const ip = clientIpRateKey(req);
|
|
3003
3055
|
if (await chatbotLimiter.check(ip, req)) {
|
|
3004
3056
|
return Response.json({ error: "Too many requests. Please wait a moment." }, { status: 429 });
|
|
3005
3057
|
}
|
|
@@ -5205,13 +5257,14 @@ function verifyTwoFactorChallenge(email, token, now = Date.now()) {
|
|
|
5205
5257
|
}
|
|
5206
5258
|
|
|
5207
5259
|
// src/endpoints/login.ts
|
|
5260
|
+
var MAX_LOGGED_USER_AGENT = 256;
|
|
5208
5261
|
function createLoginEndpoint(slugs, store) {
|
|
5209
5262
|
const loginLimiter = new RateLimiter(15 * 6e4, 10, store, "login");
|
|
5210
5263
|
return {
|
|
5211
5264
|
path: "/support/login",
|
|
5212
5265
|
method: "post",
|
|
5213
5266
|
handler: async (req) => {
|
|
5214
|
-
const ip =
|
|
5267
|
+
const ip = clientIpRateKey(req);
|
|
5215
5268
|
if (await loginLimiter.check(ip, req)) {
|
|
5216
5269
|
return Response.json(
|
|
5217
5270
|
{ error: "Trop de tentatives. R\xE9essayez dans quelques minutes." },
|
|
@@ -5226,7 +5279,7 @@ function createLoginEndpoint(slugs, store) {
|
|
|
5226
5279
|
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
5227
5280
|
}
|
|
5228
5281
|
const { email, password } = body;
|
|
5229
|
-
const userAgent = req.headers.get("user-agent") || "";
|
|
5282
|
+
const userAgent = (req.headers.get("user-agent") || "").slice(0, MAX_LOGGED_USER_AGENT);
|
|
5230
5283
|
if (!email || !password) {
|
|
5231
5284
|
return Response.json({ error: "Email et mot de passe requis." }, { status: 400 });
|
|
5232
5285
|
}
|
|
@@ -5314,7 +5367,19 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5314
5367
|
if (!action || !email) {
|
|
5315
5368
|
return Response.json({ error: "Param\xE8tres manquants" }, { status: 400 });
|
|
5316
5369
|
}
|
|
5317
|
-
const
|
|
5370
|
+
const limiterKey = normalizeEmail(email);
|
|
5371
|
+
const sendResponse = () => {
|
|
5372
|
+
let refreshed;
|
|
5373
|
+
try {
|
|
5374
|
+
refreshed = issueTwoFactorChallenge(email);
|
|
5375
|
+
} catch {
|
|
5376
|
+
}
|
|
5377
|
+
return Response.json({
|
|
5378
|
+
success: true,
|
|
5379
|
+
message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9.",
|
|
5380
|
+
...refreshed ? { challenge: refreshed } : {}
|
|
5381
|
+
});
|
|
5382
|
+
};
|
|
5318
5383
|
if (action === "send") {
|
|
5319
5384
|
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
5320
5385
|
return Response.json(
|
|
@@ -5322,8 +5387,8 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5322
5387
|
{ status: 401 }
|
|
5323
5388
|
);
|
|
5324
5389
|
}
|
|
5325
|
-
if (await sendLimiter.check(
|
|
5326
|
-
return
|
|
5390
|
+
if (await sendLimiter.check(limiterKey, req)) {
|
|
5391
|
+
return sendResponse();
|
|
5327
5392
|
}
|
|
5328
5393
|
const clients = await dbFind(payload, slugs.supportClients, {
|
|
5329
5394
|
where: { email: { equals: email } },
|
|
@@ -5332,7 +5397,7 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5332
5397
|
overrideAccess: true
|
|
5333
5398
|
});
|
|
5334
5399
|
if (clients.docs.length === 0) {
|
|
5335
|
-
return
|
|
5400
|
+
return sendResponse();
|
|
5336
5401
|
}
|
|
5337
5402
|
const client = clients.docs[0];
|
|
5338
5403
|
const plainCode = generateSecureCode();
|
|
@@ -5357,13 +5422,19 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5357
5422
|
<p style="font-size: 13px; color: #6b7280;">Ce code est valable 10 minutes.</p>
|
|
5358
5423
|
</div>`
|
|
5359
5424
|
});
|
|
5360
|
-
return
|
|
5425
|
+
return sendResponse();
|
|
5361
5426
|
}
|
|
5362
5427
|
if (action === "verify") {
|
|
5428
|
+
if (!verifyTwoFactorChallenge(email, challenge)) {
|
|
5429
|
+
return Response.json(
|
|
5430
|
+
{ error: "Authentification requise avant la v\xE9rification d'un code." },
|
|
5431
|
+
{ status: 401 }
|
|
5432
|
+
);
|
|
5433
|
+
}
|
|
5363
5434
|
if (!code) {
|
|
5364
5435
|
return Response.json({ error: "Code manquant" }, { status: 400 });
|
|
5365
5436
|
}
|
|
5366
|
-
if (await verifyLimiter.check(
|
|
5437
|
+
if (await verifyLimiter.check(limiterKey, req)) {
|
|
5367
5438
|
return Response.json(
|
|
5368
5439
|
{ error: "Trop de tentatives. R\xE9essayez dans 15 minutes." },
|
|
5369
5440
|
{ status: 429 }
|
|
@@ -5404,7 +5475,7 @@ function createAuth2faEndpoint(slugs, store) {
|
|
|
5404
5475
|
data: { twoFactorCode: "", twoFactorExpiry: "", twoFactorVerifiedAt: (/* @__PURE__ */ new Date()).toISOString() },
|
|
5405
5476
|
overrideAccess: true
|
|
5406
5477
|
});
|
|
5407
|
-
verifyLimiter.reset(
|
|
5478
|
+
await verifyLimiter.reset(limiterKey, req);
|
|
5408
5479
|
return Response.json({ success: true, verified: true });
|
|
5409
5480
|
}
|
|
5410
5481
|
return Response.json({ error: "Action invalide" }, { status: 400 });
|
|
@@ -5966,7 +6037,7 @@ function createImportConversationEndpoint(slugs, store) {
|
|
|
5966
6037
|
if (!isAuthed) {
|
|
5967
6038
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
|
5968
6039
|
}
|
|
5969
|
-
const ip = req
|
|
6040
|
+
const ip = clientIpRateKey(req);
|
|
5970
6041
|
if (await importLimiter.check(ip, req)) {
|
|
5971
6042
|
return Response.json({ error: "Rate limit exceeded. Maximum 10 imports per hour." }, { status: 429 });
|
|
5972
6043
|
}
|
|
@@ -6174,6 +6245,21 @@ var WEBHOOK_URL_MESSAGES = {
|
|
|
6174
6245
|
scheme_not_allowed: "Seules les URL https:// sont accept\xE9es.",
|
|
6175
6246
|
private_host: "Les adresses priv\xE9es, loopback et link-local sont interdites (SSRF)."
|
|
6176
6247
|
};
|
|
6248
|
+
var MAX_PUSH_ENDPOINT_LENGTH = 2048;
|
|
6249
|
+
function validatePushEndpoint(raw) {
|
|
6250
|
+
if (typeof raw !== "string" || !raw.trim() || raw.length > MAX_PUSH_ENDPOINT_LENGTH) {
|
|
6251
|
+
return { ok: false, reason: "invalid_url" };
|
|
6252
|
+
}
|
|
6253
|
+
let url;
|
|
6254
|
+
try {
|
|
6255
|
+
url = new URL(raw.trim());
|
|
6256
|
+
} catch {
|
|
6257
|
+
return { ok: false, reason: "invalid_url" };
|
|
6258
|
+
}
|
|
6259
|
+
if (url.protocol !== "https:") return { ok: false, reason: "scheme_not_allowed" };
|
|
6260
|
+
if (isBlockedHost(url.hostname)) return { ok: false, reason: "private_host" };
|
|
6261
|
+
return { ok: true, url };
|
|
6262
|
+
}
|
|
6177
6263
|
var LOOKUP_TIMEOUT_MS = 5e3;
|
|
6178
6264
|
var LOOKUP_TIMED_OUT = /* @__PURE__ */ Symbol("lookup-timed-out");
|
|
6179
6265
|
var NONEXISTENT_HOST_CODES = /* @__PURE__ */ new Set(["ENOTFOUND", "ENODATA", "NOTFOUND"]);
|
|
@@ -6629,6 +6715,15 @@ async function sendPushToUser(payload, slugs, userId, notification) {
|
|
|
6629
6715
|
for (const s of subs.docs) {
|
|
6630
6716
|
const row = s;
|
|
6631
6717
|
if (!row.endpoint || !row.p256dh || !row.auth) continue;
|
|
6718
|
+
const check = validatePushEndpoint(row.endpoint);
|
|
6719
|
+
if (!check.ok || !check.url) {
|
|
6720
|
+
console.warn("[support] Push endpoint refused (unsafe URL):", check.reason);
|
|
6721
|
+
continue;
|
|
6722
|
+
}
|
|
6723
|
+
if (!await assertPublicHost(check.url.hostname)) {
|
|
6724
|
+
console.warn("[support] Push endpoint refused (host resolves to a private address)");
|
|
6725
|
+
continue;
|
|
6726
|
+
}
|
|
6632
6727
|
try {
|
|
6633
6728
|
await webpush__default.default.sendNotification(
|
|
6634
6729
|
{ endpoint: row.endpoint, keys: { p256dh: row.p256dh, auth: row.auth } },
|
|
@@ -6679,7 +6774,14 @@ function createPushSubscribeEndpoint(slugs) {
|
|
|
6679
6774
|
if (!endpoint || !p256dh || !auth) {
|
|
6680
6775
|
return Response.json({ error: "subscription invalide (endpoint + keys requis)." }, { status: 400 });
|
|
6681
6776
|
}
|
|
6682
|
-
const
|
|
6777
|
+
const endpointCheck = validatePushEndpoint(endpoint);
|
|
6778
|
+
if (!endpointCheck.ok) {
|
|
6779
|
+
return Response.json(
|
|
6780
|
+
{ error: WEBHOOK_URL_MESSAGES[endpointCheck.reason || "invalid_url"] },
|
|
6781
|
+
{ status: 400 }
|
|
6782
|
+
);
|
|
6783
|
+
}
|
|
6784
|
+
const data = { user: req.user.id, endpoint, p256dh, auth, userAgent: (req.headers.get("user-agent") || "").slice(0, 256) };
|
|
6683
6785
|
const existing = await dbFind(req.payload, slugs.pushSubscriptions, { where: { endpoint: { equals: endpoint } }, limit: 1, depth: 0, overrideAccess: true });
|
|
6684
6786
|
if (existing.docs.length > 0) {
|
|
6685
6787
|
await dbUpdate(req.payload, slugs.pushSubscriptions, { id: existing.docs[0].id, data, overrideAccess: true });
|
package/dist/index.d.cts
CHANGED
|
@@ -11,9 +11,29 @@ interface RateLimitStore {
|
|
|
11
11
|
/**
|
|
12
12
|
* Process-local fallback store. Applications running more than one process
|
|
13
13
|
* should provide a persistent RateLimitStore through the plugin options.
|
|
14
|
+
*
|
|
15
|
+
* BOUNDED, because the keys come from anonymous HTTP traffic: `/support/login`
|
|
16
|
+
* and `/support/chatbot` key their limiter on the client IP, and the IP is read
|
|
17
|
+
* from `x-forwarded-for`. A caller rotating that header wrote one PERMANENT
|
|
18
|
+
* entry per value into a map that had no ceiling, no expiry sweep and no
|
|
19
|
+
* eviction — only `reset()` ever removed anything, and the login path never
|
|
20
|
+
* calls it. Worse, every fresh key opened a fresh window, so the limiter did
|
|
21
|
+
* not even slow down the flood that was exhausting it.
|
|
22
|
+
*
|
|
23
|
+
* Two independent bounds, the same pair `endpoints/typing.ts` already applies
|
|
24
|
+
* to its own module-level map: `clientIpRateKey` caps the SIZE of a key, the
|
|
25
|
+
* ceiling below caps their NUMBER.
|
|
14
26
|
*/
|
|
15
27
|
declare class MemoryRateLimitStore implements RateLimitStore {
|
|
28
|
+
private readonly maxKeys;
|
|
16
29
|
private readonly entries;
|
|
30
|
+
constructor(maxKeys?: number);
|
|
31
|
+
/** Distinct keys currently held. Exposed so the ceiling can be asserted. */
|
|
32
|
+
get size(): number;
|
|
33
|
+
/** Reclaims every window that has already closed. */
|
|
34
|
+
private sweepExpired;
|
|
35
|
+
/** Soonest-closing window first, so the ceiling drops the least useful entry. */
|
|
36
|
+
private evictOldest;
|
|
17
37
|
increment(key: string, windowMs: number): Promise<RateLimitEntry>;
|
|
18
38
|
reset(key: string): Promise<void>;
|
|
19
39
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,9 +11,29 @@ interface RateLimitStore {
|
|
|
11
11
|
/**
|
|
12
12
|
* Process-local fallback store. Applications running more than one process
|
|
13
13
|
* should provide a persistent RateLimitStore through the plugin options.
|
|
14
|
+
*
|
|
15
|
+
* BOUNDED, because the keys come from anonymous HTTP traffic: `/support/login`
|
|
16
|
+
* and `/support/chatbot` key their limiter on the client IP, and the IP is read
|
|
17
|
+
* from `x-forwarded-for`. A caller rotating that header wrote one PERMANENT
|
|
18
|
+
* entry per value into a map that had no ceiling, no expiry sweep and no
|
|
19
|
+
* eviction — only `reset()` ever removed anything, and the login path never
|
|
20
|
+
* calls it. Worse, every fresh key opened a fresh window, so the limiter did
|
|
21
|
+
* not even slow down the flood that was exhausting it.
|
|
22
|
+
*
|
|
23
|
+
* Two independent bounds, the same pair `endpoints/typing.ts` already applies
|
|
24
|
+
* to its own module-level map: `clientIpRateKey` caps the SIZE of a key, the
|
|
25
|
+
* ceiling below caps their NUMBER.
|
|
14
26
|
*/
|
|
15
27
|
declare class MemoryRateLimitStore implements RateLimitStore {
|
|
28
|
+
private readonly maxKeys;
|
|
16
29
|
private readonly entries;
|
|
30
|
+
constructor(maxKeys?: number);
|
|
31
|
+
/** Distinct keys currently held. Exposed so the ceiling can be asserted. */
|
|
32
|
+
get size(): number;
|
|
33
|
+
/** Reclaims every window that has already closed. */
|
|
34
|
+
private sweepExpired;
|
|
35
|
+
/** Soonest-closing window first, so the ceiling drops the least useful entry. */
|
|
36
|
+
private evictOldest;
|
|
17
37
|
increment(key: string, windowMs: number): Promise<RateLimitEntry>;
|
|
18
38
|
reset(key: string): Promise<void>;
|
|
19
39
|
}
|