@consilioweb/payload-support 3.0.0 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -14
- package/dist/index.cjs +673 -107
- package/dist/index.d.cts +40 -5
- package/dist/index.d.ts +40 -5
- package/dist/index.js +673 -107
- 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/collections/ChatMessages.ts +59 -2
- package/src/collections/ClientSummaries.ts +10 -4
- package/src/collections/TicketMessages.ts +4 -1
- package/src/collections/WebhookEndpoints.ts +44 -2
- package/src/endpoints/admin-chat.ts +3 -3
- package/src/endpoints/ai-agent.ts +3 -3
- package/src/endpoints/ai.ts +3 -3
- package/src/endpoints/auth-2fa.ts +68 -11
- package/src/endpoints/capabilities.ts +7 -9
- package/src/endpoints/chat.ts +7 -5
- package/src/endpoints/chatbot.ts +50 -4
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/email-stats.ts +19 -3
- package/src/endpoints/import-conversation.ts +3 -3
- package/src/endpoints/index.ts +1 -1
- package/src/endpoints/invite-collaborator.ts +29 -3
- package/src/endpoints/login.ts +28 -5
- package/src/endpoints/oauth-google.ts +130 -8
- package/src/endpoints/push.ts +14 -1
- package/src/endpoints/resend-notification.ts +3 -3
- package/src/endpoints/send-reminder.ts +3 -3
- package/src/endpoints/signature.ts +9 -2
- package/src/endpoints/statuses.ts +17 -0
- package/src/endpoints/ticket-synthesis.ts +3 -3
- package/src/endpoints/transfer-ticket.ts +28 -3
- package/src/endpoints/typing.ts +117 -14
- package/src/endpoints/user-prefs.ts +5 -2
- package/src/plugin.ts +12 -0
- package/src/portal/auth/layout.tsx +19 -1
- package/src/portal/auth/tickets/detail/MessageBody.tsx +88 -0
- package/src/portal/auth/tickets/detail/page.tsx +2 -6
- package/src/portal/login/page.tsx +23 -5
- package/src/utils/fireWebhooks.ts +4 -1
- package/src/utils/push.ts +22 -0
- package/src/utils/rateLimiter.ts +136 -4
- package/src/utils/readSettings.ts +124 -14
- package/src/utils/ticketAccess.ts +16 -1
- package/src/utils/twoFactorChallenge.ts +85 -0
- package/src/utils/urlSafety.ts +265 -0
- package/src/utils/webhookDispatcher.ts +5 -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/src/utils/rateLimiter.ts
CHANGED
|
@@ -8,13 +8,59 @@ export interface RateLimitStore {
|
|
|
8
8
|
reset(key: string, context?: unknown): Promise<void>
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Hard ceiling on the number of distinct keys held at once, all endpoints
|
|
13
|
+
* combined. Sized for a busy install (thousands of client IPs inside one
|
|
14
|
+
* 15-minute window) while capping the map at a few hundred kilobytes.
|
|
15
|
+
*/
|
|
16
|
+
export const MAX_MEMORY_RATE_LIMIT_KEYS = 10_000
|
|
17
|
+
|
|
11
18
|
/**
|
|
12
19
|
* Process-local fallback store. Applications running more than one process
|
|
13
20
|
* should provide a persistent RateLimitStore through the plugin options.
|
|
21
|
+
*
|
|
22
|
+
* BOUNDED, because the keys come from anonymous HTTP traffic: `/support/login`
|
|
23
|
+
* and `/support/chatbot` key their limiter on the client IP, and the IP is read
|
|
24
|
+
* from `x-forwarded-for`. A caller rotating that header wrote one PERMANENT
|
|
25
|
+
* entry per value into a map that had no ceiling, no expiry sweep and no
|
|
26
|
+
* eviction — only `reset()` ever removed anything, and the login path never
|
|
27
|
+
* calls it. Worse, every fresh key opened a fresh window, so the limiter did
|
|
28
|
+
* not even slow down the flood that was exhausting it.
|
|
29
|
+
*
|
|
30
|
+
* Two independent bounds, the same pair `endpoints/typing.ts` already applies
|
|
31
|
+
* to its own module-level map: `clientIpRateKey` caps the SIZE of a key, the
|
|
32
|
+
* ceiling below caps their NUMBER.
|
|
14
33
|
*/
|
|
15
34
|
export class MemoryRateLimitStore implements RateLimitStore {
|
|
16
35
|
private readonly entries = new Map<string, RateLimitEntry>()
|
|
17
36
|
|
|
37
|
+
constructor(private readonly maxKeys: number = MAX_MEMORY_RATE_LIMIT_KEYS) {}
|
|
38
|
+
|
|
39
|
+
/** Distinct keys currently held. Exposed so the ceiling can be asserted. */
|
|
40
|
+
get size(): number {
|
|
41
|
+
return this.entries.size
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reclaims every window that has already closed. */
|
|
45
|
+
private sweepExpired(now: number): void {
|
|
46
|
+
for (const [key, entry] of this.entries) {
|
|
47
|
+
if (now > entry.resetAt) this.entries.delete(key)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Soonest-closing window first, so the ceiling drops the least useful entry. */
|
|
52
|
+
private evictOldest(): void {
|
|
53
|
+
let oldestKey: string | null = null
|
|
54
|
+
let oldestResetAt = Infinity
|
|
55
|
+
for (const [key, entry] of this.entries) {
|
|
56
|
+
if (entry.resetAt < oldestResetAt) {
|
|
57
|
+
oldestResetAt = entry.resetAt
|
|
58
|
+
oldestKey = key
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (oldestKey !== null) this.entries.delete(oldestKey)
|
|
62
|
+
}
|
|
63
|
+
|
|
18
64
|
async increment(key: string, windowMs: number): Promise<RateLimitEntry> {
|
|
19
65
|
const now = Date.now()
|
|
20
66
|
const current = this.entries.get(key)
|
|
@@ -22,6 +68,12 @@ export class MemoryRateLimitStore implements RateLimitStore {
|
|
|
22
68
|
? { count: 1, resetAt: now + windowMs }
|
|
23
69
|
: { ...current, count: current.count + 1 }
|
|
24
70
|
|
|
71
|
+
// Only a key that is not already held can grow the map.
|
|
72
|
+
if (!current && this.entries.size >= this.maxKeys) {
|
|
73
|
+
this.sweepExpired(now)
|
|
74
|
+
if (this.entries.size >= this.maxKeys) this.evictOldest()
|
|
75
|
+
}
|
|
76
|
+
|
|
25
77
|
this.entries.set(key, next)
|
|
26
78
|
return next
|
|
27
79
|
}
|
|
@@ -119,27 +171,107 @@ export class PayloadRateLimitStore implements RateLimitStore {
|
|
|
119
171
|
}
|
|
120
172
|
}
|
|
121
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Identity part of a rate-limit key for an authenticated caller.
|
|
176
|
+
*
|
|
177
|
+
* Ids are per-collection sequences: support-client #7 and agent #7 are different
|
|
178
|
+
* people with the same `id`. Keying a limiter on `String(user.id)` alone let one
|
|
179
|
+
* consume the other's budget, so the auth collection travels with the id.
|
|
180
|
+
*/
|
|
181
|
+
export function principalRateKey(
|
|
182
|
+
user: { id?: unknown; collection?: unknown } | null | undefined,
|
|
183
|
+
): string {
|
|
184
|
+
if (!user || user.id === undefined || user.id === null) return 'anonymous'
|
|
185
|
+
const collection = typeof user.collection === 'string' && user.collection ? user.collection : 'unknown'
|
|
186
|
+
return `${collection}:${String(user.id)}`
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Longest textual IPv6 address, `xxxx:` * 7 + an embedded IPv4 literal.
|
|
191
|
+
* Nothing legitimate is longer; the cap is what makes the key bounded.
|
|
192
|
+
*/
|
|
193
|
+
const MAX_IP_KEY_LENGTH = 45
|
|
194
|
+
/** Dotted-quad, or the hex/`::`/zone alphabet of an IPv6 literal. Shape only. */
|
|
195
|
+
const IPV4_PATTERN = /^(?:\d{1,3}\.){3}\d{1,3}$/
|
|
196
|
+
const IPV6_PATTERN = /^[0-9a-fA-F:.%]+$/
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Identity part of a rate-limit key for an ANONYMOUS caller.
|
|
200
|
+
*
|
|
201
|
+
* `x-forwarded-for` is attacker-controlled — the note in `endpoints/login.ts`
|
|
202
|
+
* has always said so, and Payload's account lock is what actually stops a
|
|
203
|
+
* brute-force. But the header was also used RAW as the limiter key, and that is
|
|
204
|
+
* a second, distinct problem: a key is a stored object. A caller rotating the
|
|
205
|
+
* header minted one unbounded, never-reclaimed entry per value; a value can be
|
|
206
|
+
* most of Node's 16 KB header budget. This validates the SHAPE and caps the
|
|
207
|
+
* LENGTH, so a forged header can still pick a bucket but can no longer invent
|
|
208
|
+
* an unbounded number of them, nor make any single one large.
|
|
209
|
+
*
|
|
210
|
+
* Anything that is not an IP literal falls back to the shared `unknown` bucket.
|
|
211
|
+
* That is the fail-closed direction: an install with no proxy already puts
|
|
212
|
+
* every caller there, and one behind a proxy never lands there legitimately.
|
|
213
|
+
*/
|
|
214
|
+
export function clientIpRateKey(req: { headers: { get(name: string): string | null } }): string {
|
|
215
|
+
const candidate = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
|
216
|
+
|| req.headers.get('x-real-ip')?.trim()
|
|
217
|
+
|| ''
|
|
218
|
+
return normalizeIpKey(candidate)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Exposed separately so a caller holding an already-extracted address can reuse it. */
|
|
222
|
+
export function normalizeIpKey(candidate: string): string {
|
|
223
|
+
if (!candidate || candidate.length > MAX_IP_KEY_LENGTH) return 'unknown'
|
|
224
|
+
const host = candidate.startsWith('[') && candidate.endsWith(']')
|
|
225
|
+
? candidate.slice(1, -1)
|
|
226
|
+
: candidate
|
|
227
|
+
if (!host) return 'unknown'
|
|
228
|
+
if (IPV4_PATTERN.test(host)) {
|
|
229
|
+
return host.split('.').every((octet) => Number(octet) <= 255) ? host : 'unknown'
|
|
230
|
+
}
|
|
231
|
+
if (host.includes(':') && IPV6_PATTERN.test(host)) return host.toLowerCase()
|
|
232
|
+
return 'unknown'
|
|
233
|
+
}
|
|
234
|
+
|
|
122
235
|
export class RateLimiter {
|
|
123
236
|
private readonly store: RateLimitStore
|
|
237
|
+
private readonly prefix: string
|
|
124
238
|
|
|
239
|
+
/**
|
|
240
|
+
* @param namespace Endpoint-scoped prefix for every key this limiter writes.
|
|
241
|
+
* The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
|
|
242
|
+
* all endpoints), and the raw keys collide across endpoints: `ip` was used
|
|
243
|
+
* by both the login and the chatbot limiter — 10 forged chatbot requests
|
|
244
|
+
* locked a victim out of the portal for 15 minutes — and `String(user.id)`
|
|
245
|
+
* by five different endpoints. Always pass one; it is optional only because
|
|
246
|
+
* `RateLimiter` is part of the published API surface.
|
|
247
|
+
*/
|
|
125
248
|
constructor(
|
|
126
249
|
private readonly windowMs: number,
|
|
127
250
|
private readonly maxRequests: number,
|
|
128
251
|
store?: RateLimitStore,
|
|
252
|
+
namespace?: string,
|
|
129
253
|
) {
|
|
130
254
|
this.store = store ?? new MemoryRateLimitStore()
|
|
255
|
+
this.prefix = namespace ? `${namespace}:` : ''
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The key actually written to the store. Exposed for assertions in tests. */
|
|
259
|
+
scopedKey(key: string): string {
|
|
260
|
+
return `${this.prefix}${key}`
|
|
131
261
|
}
|
|
132
262
|
|
|
133
263
|
async check(key: string, context?: unknown): Promise<boolean> {
|
|
264
|
+
const scoped = this.scopedKey(key)
|
|
134
265
|
const entry = context === undefined
|
|
135
|
-
? await this.store.increment(
|
|
136
|
-
: await this.store.increment(
|
|
266
|
+
? await this.store.increment(scoped, this.windowMs)
|
|
267
|
+
: await this.store.increment(scoped, this.windowMs, context)
|
|
137
268
|
return entry.count > this.maxRequests
|
|
138
269
|
}
|
|
139
270
|
|
|
140
271
|
async reset(key: string, context?: unknown): Promise<void> {
|
|
141
|
-
|
|
142
|
-
|
|
272
|
+
const scoped = this.scopedKey(key)
|
|
273
|
+
if (context === undefined) await this.store.reset(scoped)
|
|
274
|
+
else await this.store.reset(scoped, context)
|
|
143
275
|
}
|
|
144
276
|
}
|
|
145
277
|
import { commitTransaction, initTransaction, killTransaction, type PayloadRequest } from 'payload'
|
|
@@ -13,6 +13,50 @@ const USER_PREFS_KEY_PREFIX = 'support-user-prefs'
|
|
|
13
13
|
/** Pre-2.1 standalone round-robin row — read as a fallback, then folded into `features`. */
|
|
14
14
|
const LEGACY_ROUND_ROBIN_KEY = 'support-round-robin'
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Key under which `plugin.ts` publishes the resolved staff collection slug on
|
|
18
|
+
* `config.custom`. It is the ONE source of truth shared by the read path (here)
|
|
19
|
+
* and the write path (`requireAdmin` → `slugs.users`).
|
|
20
|
+
*/
|
|
21
|
+
export const SUPPORT_STAFF_SLUG_CONFIG_KEY = 'supportStaffCollection'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Owner scope for every `payload-preferences` row this plugin reads back.
|
|
25
|
+
*
|
|
26
|
+
* `payload-preferences` is writable by ANY authenticated principal, whatever its
|
|
27
|
+
* auth collection: Payload's `POST /api/payload-preferences/:key` handler only
|
|
28
|
+
* checks `!!req.user` before upserting the row. Reading a plugin-wide row by key
|
|
29
|
+
* alone therefore lets a front-office user (or a support-client) plant their own
|
|
30
|
+
* row and have the whole plugin read it — replyTo addresses, SLA escalation
|
|
31
|
+
* address, AI provider, feature flags.
|
|
32
|
+
*
|
|
33
|
+
* Every read below is constrained to `user.relationTo = <staff collection>`, the
|
|
34
|
+
* same scope the WRITE path already uses (endpoints/settings.ts, signature.ts,
|
|
35
|
+
* user-prefs.ts all upsert with `req.user.collection`, and all three are guarded
|
|
36
|
+
* by `requireAdmin`, which compares against `slugs.users`).
|
|
37
|
+
*
|
|
38
|
+
* TWO SOURCES OF TRUTH WERE THE BUG: this used to resolve the scope from
|
|
39
|
+
* `config.admin.user`, which Payload defaults to the FIRST auth collection of
|
|
40
|
+
* the app when the integrator did not declare it (config/sanitize.js). On a host
|
|
41
|
+
* whose first auth collection is the front office and whose staff collection is
|
|
42
|
+
* declared through `collectionSlugs.users`, the read scope named the front
|
|
43
|
+
* office while the write scope named the staff — and the poisoning this scope
|
|
44
|
+
* was added to defeat was open again. So the plugin now PUBLISHES the resolved
|
|
45
|
+
* `slugs.users` on `config.custom` (see plugin.ts) and reads it back here.
|
|
46
|
+
* `config.admin.user` remains a last-resort fallback for callers using these
|
|
47
|
+
* helpers outside of a plugin-built config; there is no plugin deployment in
|
|
48
|
+
* which it is consulted.
|
|
49
|
+
*/
|
|
50
|
+
export function resolveStaffPrefSlug(payload: Payload, staffSlug?: string): string {
|
|
51
|
+
if (staffSlug) return staffSlug
|
|
52
|
+
const config = (payload as unknown as {
|
|
53
|
+
config?: { admin?: { user?: string }; custom?: Record<string, unknown> }
|
|
54
|
+
}).config
|
|
55
|
+
const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY]
|
|
56
|
+
if (typeof registered === 'string' && registered) return registered
|
|
57
|
+
return config?.admin?.user || 'users'
|
|
58
|
+
}
|
|
59
|
+
|
|
16
60
|
export interface SupportSettings {
|
|
17
61
|
email: { fromAddress: string; fromName: string; replyToAddress: string }
|
|
18
62
|
ai: { provider: string; model: string; enableSentiment: boolean; enableSynthesis: boolean; enableSuggestion: boolean; enableRewrite: boolean }
|
|
@@ -44,8 +88,17 @@ export const DEFAULT_USER_PREFS: UserPrefs = {
|
|
|
44
88
|
// (the afterChange hook chain). The settings doc changes rarely, so a short TTL
|
|
45
89
|
// + explicit invalidation on save (endpoints/settings.ts) avoids redundant DB
|
|
46
90
|
// reads of the same `payload-preferences` row.
|
|
47
|
-
|
|
91
|
+
// Keyed by the STAFF SLUG the read was scoped to: a single shared slot would
|
|
92
|
+
// serve one install's (or one test's) settings to another whose scope differs.
|
|
93
|
+
// Bounded — the key comes from config, never from a request — but capped anyway
|
|
94
|
+
// so an integrator calling the exported helper with arbitrary slugs cannot grow
|
|
95
|
+
// it without limit.
|
|
96
|
+
const settingsCache = new Map<string, { value: SupportSettingsState; ts: number }>()
|
|
48
97
|
const SETTINGS_TTL_MS = 60_000
|
|
98
|
+
const SETTINGS_CACHE_MAX = 8
|
|
99
|
+
|
|
100
|
+
/** Rate-limits the "settings row exists but is not staff-owned" warning, per scope. */
|
|
101
|
+
const warnedForeignSettingsRow = new Set<string>()
|
|
49
102
|
|
|
50
103
|
export interface SupportSettingsState {
|
|
51
104
|
settings: SupportSettings
|
|
@@ -59,7 +112,8 @@ export interface SupportSettingsState {
|
|
|
59
112
|
|
|
60
113
|
/** Invalidate the settings cache — call right after writing support settings. */
|
|
61
114
|
export function invalidateSupportSettingsCache(): void {
|
|
62
|
-
settingsCache
|
|
115
|
+
settingsCache.clear()
|
|
116
|
+
warnedForeignSettingsRow.clear()
|
|
63
117
|
}
|
|
64
118
|
|
|
65
119
|
/**
|
|
@@ -85,9 +139,14 @@ export function mergeSupportSettings(
|
|
|
85
139
|
}
|
|
86
140
|
}
|
|
87
141
|
|
|
88
|
-
export async function readSupportSettingsState(
|
|
89
|
-
|
|
90
|
-
|
|
142
|
+
export async function readSupportSettingsState(
|
|
143
|
+
payload: Payload,
|
|
144
|
+
staffSlug?: string,
|
|
145
|
+
): Promise<SupportSettingsState> {
|
|
146
|
+
const staff = resolveStaffPrefSlug(payload, staffSlug)
|
|
147
|
+
const cached = settingsCache.get(staff)
|
|
148
|
+
if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
|
|
149
|
+
return cached.value
|
|
91
150
|
}
|
|
92
151
|
let value: SupportSettingsState = {
|
|
93
152
|
settings: mergeSupportSettings(null),
|
|
@@ -95,7 +154,10 @@ export async function readSupportSettingsState(payload: Payload): Promise<Suppor
|
|
|
95
154
|
}
|
|
96
155
|
try {
|
|
97
156
|
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
98
|
-
|
|
157
|
+
// Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
|
|
158
|
+
// security boundary: without it any authenticated principal can plant a
|
|
159
|
+
// `support-settings` row and own the plugin's server settings.
|
|
160
|
+
where: { key: { equals: PREF_KEY }, 'user.relationTo': { equals: staff } },
|
|
99
161
|
// The upsert is scoped per admin user, so several rows can share the key.
|
|
100
162
|
// Sorting makes "last write wins" deterministic instead of arbitrary.
|
|
101
163
|
sort: '-updatedAt',
|
|
@@ -110,24 +172,63 @@ export async function readSupportSettingsState(payload: Payload): Promise<Suppor
|
|
|
110
172
|
// Honour it until the first save writes `features` — after that the
|
|
111
173
|
// legacy row is ignored, and both write paths land in `features`.
|
|
112
174
|
if (!featuresConfigured) {
|
|
113
|
-
settings.features.roundRobin = await readLegacyRoundRobin(payload)
|
|
175
|
+
settings.features.roundRobin = await readLegacyRoundRobin(payload, staff)
|
|
114
176
|
}
|
|
115
177
|
|
|
116
178
|
value = { settings, featuresConfigured }
|
|
179
|
+
} else {
|
|
180
|
+
await warnOnForeignSettingsRow(payload, staff)
|
|
117
181
|
}
|
|
118
182
|
} catch { /* fallback to defaults */ }
|
|
119
|
-
settingsCache
|
|
183
|
+
if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear()
|
|
184
|
+
settingsCache.set(staff, { value, ts: Date.now() })
|
|
120
185
|
return value
|
|
121
186
|
}
|
|
122
187
|
|
|
123
|
-
|
|
124
|
-
|
|
188
|
+
/**
|
|
189
|
+
* The scoped read came back empty. That is normal on a fresh install, but it is
|
|
190
|
+
* also what an operator sees when the settings row was written under another
|
|
191
|
+
* owner than the resolved staff collection: the row exists, is ignored,
|
|
192
|
+
* and the whole plugin silently runs on the defaults — a `try/catch` away from
|
|
193
|
+
* any signal. So: if a row with this key exists under ANOTHER owner, say so.
|
|
194
|
+
*
|
|
195
|
+
* Same message covers the other case, deliberately: a `support-settings` row
|
|
196
|
+
* owned by a non-staff principal is exactly the poisoning attempt this scope was
|
|
197
|
+
* added to defeat, and the operator should hear about it. Once per cache window
|
|
198
|
+
* at most (the extra query only runs when nothing was found).
|
|
199
|
+
*/
|
|
200
|
+
async function warnOnForeignSettingsRow(payload: Payload, staff: string): Promise<void> {
|
|
201
|
+
if (warnedForeignSettingsRow.has(staff)) return
|
|
202
|
+
try {
|
|
203
|
+
const any = await dbFind(payload, 'payload-preferences', {
|
|
204
|
+
where: { key: { equals: PREF_KEY } },
|
|
205
|
+
limit: 1, depth: 0, overrideAccess: true,
|
|
206
|
+
})
|
|
207
|
+
if (any.docs.length === 0) return // fresh install — nothing to report
|
|
208
|
+
if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear()
|
|
209
|
+
warnedForeignSettingsRow.add(staff)
|
|
210
|
+
console.warn(
|
|
211
|
+
`[support] A "${PREF_KEY}" preference row exists but none is owned by the "${staff}" collection: ` +
|
|
212
|
+
'the plugin is running on its DEFAULT settings. Either the staff auth collection differs from ' +
|
|
213
|
+
'`admin.user`, or the row was written by a principal that is not staff — in which case it is ' +
|
|
214
|
+
'ignored on purpose.',
|
|
215
|
+
)
|
|
216
|
+
} catch {
|
|
217
|
+
/* diagnostic only — never let it change the outcome */
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function readSupportSettings(
|
|
222
|
+
payload: Payload,
|
|
223
|
+
staffSlug?: string,
|
|
224
|
+
): Promise<SupportSettings> {
|
|
225
|
+
return (await readSupportSettingsState(payload, staffSlug)).settings
|
|
125
226
|
}
|
|
126
227
|
|
|
127
|
-
async function readLegacyRoundRobin(payload: Payload): Promise<boolean> {
|
|
228
|
+
async function readLegacyRoundRobin(payload: Payload, staff: string): Promise<boolean> {
|
|
128
229
|
try {
|
|
129
230
|
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
130
|
-
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY } },
|
|
231
|
+
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, 'user.relationTo': { equals: staff } },
|
|
131
232
|
limit: 1, depth: 0, overrideAccess: true,
|
|
132
233
|
})
|
|
133
234
|
if (prefs.docs.length > 0) {
|
|
@@ -137,11 +238,20 @@ async function readLegacyRoundRobin(payload: Payload): Promise<boolean> {
|
|
|
137
238
|
return DEFAULT_TICKETING_FEATURES.roundRobin
|
|
138
239
|
}
|
|
139
240
|
|
|
140
|
-
export async function readUserPrefs(
|
|
241
|
+
export async function readUserPrefs(
|
|
242
|
+
payload: Payload,
|
|
243
|
+
userId: string | number,
|
|
244
|
+
staffSlug?: string,
|
|
245
|
+
): Promise<UserPrefs> {
|
|
141
246
|
try {
|
|
142
247
|
const key = `${USER_PREFS_KEY_PREFIX}-${userId}`
|
|
248
|
+
// Ids collide across auth collections: a support-client with id 7 would
|
|
249
|
+
// otherwise own `support-user-prefs-7`, the row read back for agent 7.
|
|
143
250
|
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
144
|
-
where: {
|
|
251
|
+
where: {
|
|
252
|
+
key: { equals: key },
|
|
253
|
+
'user.relationTo': { equals: resolveStaffPrefSlug(payload, staffSlug) },
|
|
254
|
+
},
|
|
145
255
|
limit: 1, depth: 0, overrideAccess: true,
|
|
146
256
|
})
|
|
147
257
|
if (prefs.docs.length > 0) {
|
|
@@ -13,11 +13,24 @@ import { dbFind } from './db'
|
|
|
13
13
|
*
|
|
14
14
|
* Returns an empty array when the client has no accessible tickets — callers MUST
|
|
15
15
|
* treat that as "deny" (e.g. filter on a sentinel id) rather than "allow all".
|
|
16
|
+
*
|
|
17
|
+
* `mode` selects which of the two scopes is wanted:
|
|
18
|
+
* - `'read'` (default) — owned tickets + every ticket collaborated on, whatever
|
|
19
|
+
* the role. This is the historical behaviour and what `access.read` needs.
|
|
20
|
+
* - `'write'` — owned tickets + collaborated tickets whose row carries
|
|
21
|
+
* `role === 'collaborator'`. The invitation endpoint offers two roles and the
|
|
22
|
+
* invitation email promises "lecteur (consultation)" for `viewer`, but nothing
|
|
23
|
+
* ever read the field back: a viewer could post into the thread and trigger the
|
|
24
|
+
* whole notification chain. Rows with no explicit role are treated as viewers
|
|
25
|
+
* (the collection's own `defaultValue`), i.e. read-only — fail closed.
|
|
16
26
|
*/
|
|
27
|
+
export type TicketAccessMode = 'read' | 'write'
|
|
28
|
+
|
|
17
29
|
export async function resolveAccessibleTicketIds(
|
|
18
30
|
payload: Payload,
|
|
19
31
|
slugs: CollectionSlugs,
|
|
20
32
|
clientId: number | string,
|
|
33
|
+
mode: TicketAccessMode = 'read',
|
|
21
34
|
): Promise<Array<number | string>> {
|
|
22
35
|
const ids = new Set<number | string>()
|
|
23
36
|
|
|
@@ -41,7 +54,9 @@ export async function resolveAccessibleTicketIds(
|
|
|
41
54
|
overrideAccess: true,
|
|
42
55
|
})
|
|
43
56
|
for (const r of collab.docs) {
|
|
44
|
-
const row = r as { ticket?: number | string | { id?: number | string } }
|
|
57
|
+
const row = r as { ticket?: number | string | { id?: number | string }; role?: string }
|
|
58
|
+
// `viewer` (and any row without an explicit role) grants read only.
|
|
59
|
+
if (mode === 'write' && row.role !== 'collaborator') continue
|
|
45
60
|
const tid = typeof row.ticket === 'object' ? row.ticket?.id : row.ticket
|
|
46
61
|
if (tid !== undefined && tid !== null) ids.add(tid)
|
|
47
62
|
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'crypto'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Proof that the password step already succeeded for this email address.
|
|
5
|
+
*
|
|
6
|
+
* `POST /support/2fa {action:'send'}` used to accept an email and nothing else.
|
|
7
|
+
* Its only guard was a limiter keyed on that email — 3 sends per hour — so an
|
|
8
|
+
* ANONYMOUS caller who knew a victim's address (they circulate in ticket threads
|
|
9
|
+
* and email copies) could burn the quota in three requests and leave the victim
|
|
10
|
+
* unable to obtain the code their own login now demands. Since the OAuth path
|
|
11
|
+
* enforces 2FA as well, that was a complete, renewable account lockout, plus an
|
|
12
|
+
* outbound-email amplifier and a way to overwrite a code already in flight.
|
|
13
|
+
*
|
|
14
|
+
* The fix keys the consumable resource to something the ATTACKER cannot produce:
|
|
15
|
+
* a short-lived signature issued only by a successful password (or Google)
|
|
16
|
+
* authentication. Same HMAC-over-PAYLOAD_SECRET construction as the tracking
|
|
17
|
+
* pixel, no storage, no new dependency.
|
|
18
|
+
*
|
|
19
|
+
* Format: `<expiry-ms>.<hex hmac>`. The signature covers the email AND the
|
|
20
|
+
* expiry, so neither can be swapped without invalidating the token, and a token
|
|
21
|
+
* issued for one account is useless for another.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Matches the 10-minute validity of the code the challenge lets you request. */
|
|
25
|
+
export const TWO_FACTOR_CHALLENGE_TTL_MS = 10 * 60 * 1000
|
|
26
|
+
|
|
27
|
+
function challengeSecret(): string {
|
|
28
|
+
const secret = process.env.PAYLOAD_SECRET
|
|
29
|
+
if (!secret) {
|
|
30
|
+
// Fail closed: the codes themselves are hashed with this secret, so 2FA is
|
|
31
|
+
// already inoperable without it — never fall back to a source-visible value.
|
|
32
|
+
throw new Error(
|
|
33
|
+
'[support][2fa] PAYLOAD_SECRET is not set — refusing to issue a 2FA challenge with an insecure fallback secret',
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
return secret
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The address the challenge is signed over. Also the rate-limit key of both 2FA
|
|
41
|
+
* branches: signing over the normalized form while keying the limiter on the raw
|
|
42
|
+
* one would give a single challenge one budget PER CASING VARIANT.
|
|
43
|
+
*/
|
|
44
|
+
export function normalizeEmail(email: string): string {
|
|
45
|
+
return String(email).trim().toLowerCase()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sign(email: string, expiresAt: number): string {
|
|
49
|
+
return createHmac('sha256', challengeSecret())
|
|
50
|
+
.update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`)
|
|
51
|
+
.digest('hex')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Issued by the login endpoints once credentials have been verified. */
|
|
55
|
+
export function issueTwoFactorChallenge(email: string, now: number = Date.now()): string {
|
|
56
|
+
const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS
|
|
57
|
+
return `${expiresAt}.${sign(email, expiresAt)}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Constant-time; false on any malformed, expired or foreign token. */
|
|
61
|
+
export function verifyTwoFactorChallenge(
|
|
62
|
+
email: unknown,
|
|
63
|
+
token: unknown,
|
|
64
|
+
now: number = Date.now(),
|
|
65
|
+
): boolean {
|
|
66
|
+
if (typeof email !== 'string' || !email || typeof token !== 'string') return false
|
|
67
|
+
const separator = token.indexOf('.')
|
|
68
|
+
if (separator <= 0) return false
|
|
69
|
+
|
|
70
|
+
const expiresAt = Number(token.slice(0, separator))
|
|
71
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false
|
|
72
|
+
|
|
73
|
+
const received = token.slice(separator + 1)
|
|
74
|
+
if (!/^[0-9a-f]{64}$/i.test(received)) return false
|
|
75
|
+
|
|
76
|
+
let expected: string
|
|
77
|
+
try {
|
|
78
|
+
expected = sign(email, expiresAt)
|
|
79
|
+
} catch {
|
|
80
|
+
return false // no secret configured — fail closed
|
|
81
|
+
}
|
|
82
|
+
const a = Buffer.from(expected, 'hex')
|
|
83
|
+
const b = Buffer.from(received.toLowerCase(), 'hex')
|
|
84
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
85
|
+
}
|