@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
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@consilioweb/payload-support.svg)](https://www.npmjs.com/package/@consilioweb/payload-support)
6
6
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
- [![payload](https://img.shields.io/badge/payload-%5E3.37-blue.svg)](https://payloadcms.com)
7
+ [![payload](https://img.shields.io/badge/payload-%5E3.79.1-blue.svg)](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.37.0 @payloadcms/next@^3.37.0 next@^15.2.9 react@^19 react-dom@^19 lucide-react@">=0.300.0"
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.37.0` — the Google OAuth endpoint imports `jwtSign` from the `payload` barrel, which
88
- only exists from 3.37.0 on. Below that, the missing named export fails the whole
89
- `payload.config.ts`, not just that endpoint.
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` | authenticated | `customStatuses` | Ticket statuses, sorted. |
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'` requires the short-lived `challenge` returned by `/support/login` (or by the Google callback) alongside `requires2FA` without it an anonymous caller could burn a victim's send quota and lock them out. `action: 'verify'` takes the code. |
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.37.0` | `peerDependencies.payload` |
618
- | `@payloadcms/next` | `^3.37.0` | `peerDependencies` |
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.37 warns, and fails outright under
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 webhooks** — scheme, literal host, resolved address and every redirect hop.
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.37+, and install
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 = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
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 = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
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 = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || req.headers.get("x-real-ip") || "unknown";
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 genericSendResponse = { success: true, message: "Si un compte existe, un code a \xE9t\xE9 envoy\xE9." };
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(email, req)) {
5326
- return Response.json(genericSendResponse);
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 Response.json(genericSendResponse);
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 Response.json(genericSendResponse);
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(email, req)) {
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(email);
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.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
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 data = { user: req.user.id, endpoint, p256dh, auth, userAgent: req.headers.get("user-agent") || "" };
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
  }