pipework 0.253.0 → 0.254.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.
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Allow-list HTML sanitiser for mail from strangers. The input is tokenised and the output is built only from
3
+ * allow-listed tags and attributes, every tag re-serialised and every text run and attribute value escaped.
4
+ * No slice of the input is copied through. Output is balanced and `sanitizeHtml(sanitizeHtml(x)) === sanitizeHtml(x)`.
5
+ */
6
+
7
+ const VOID = new Set(['br', 'hr', 'img', 'col'])
8
+ const ALLOWED = new Set([
9
+ 'p', 'br', 'div', 'span', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'sub', 'sup', 'small', 'blockquote', 'pre', 'code',
10
+ 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
11
+ 'caption', 'colgroup', 'col', 'a', 'img', 'font', 'center',
12
+ ])
13
+ /**
14
+ * Dropped together with everything inside them. Wrappers such as form, button, select, option and head are not listed:
15
+ * they are unwrapped (tag gone, text kept) and their dangerous children are dropped one by one.
16
+ */
17
+ const DROPPED = new Set([
18
+ 'script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'applet', 'input', 'textarea',
19
+ 'svg', 'math', 'template', 'noscript', 'title', 'base', 'link', 'meta',
20
+ ])
21
+ /** Dropped elements whose content is not markup: skipped to the matching end tag. */
22
+ const RAW_TEXT = new Set(['script', 'style', 'iframe', 'textarea', 'template', 'noscript', 'title'])
23
+ /** Dropped elements with no content. */
24
+ const EMPTY_DROPPED = new Set(['input', 'base', 'link', 'meta', 'embed', 'frame'])
25
+
26
+ const NAMED: Record<string, string> = {
27
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', colon: ':', Tab: '\t', NewLine: '\n', copy: '©',
28
+ reg: '®', hellip: '…', mdash: '—', ndash: '–', lsquo: '‘', rsquo: '’', ldquo: '“',
29
+ rdquo: '”', bull: '•', middot: '·', euro: '€', zwnj: '', zwj: '', shy: '', trade: '\u2122',
30
+ rarr: '\u2192', larr: '\u2190', times: '\u00d7', deg: '\u00b0', laquo: '\u00ab', raquo: '\u00bb', pound: '\u00a3',
31
+ yen: '\u00a5', cent: '\u00a2', sect: '\u00a7', para: '\u00b6',
32
+ }
33
+
34
+ /** Marks an unknown but well-formed named reference in decoded text; `sanitizeHtml` passes it through, `htmlToText` drops it. */
35
+ const REF_OPEN = '\ue000'
36
+ const REF_CLOSE = '\ue001'
37
+ const MARKERS = /[\ue000\ue001]/g
38
+
39
+ /** In text mode an unknown reference like `&foo;` is wrapped in markers instead of left bare. */
40
+ function decodeEntities(s: string, text = false): string {
41
+ if (text) s = s.replace(MARKERS, '')
42
+ if (!s.includes('&')) return s
43
+ return s.replace(/&(#[xX][0-9a-fA-F]+|#[0-9]+|[A-Za-z][A-Za-z0-9]*);?/g, (match, body: string) => {
44
+ if (body.startsWith('#')) {
45
+ const code = body[1] === 'x' || body[1] === 'X' ? parseInt(body.slice(2), 16) : parseInt(body.slice(1), 10)
46
+ if (!Number.isFinite(code) || code <= 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff) || code === 0xe000 || code === 0xe001) return '�'
47
+ return String.fromCodePoint(code)
48
+ }
49
+ const value = match.endsWith(';') ? NAMED[body] : undefined
50
+ if (value !== undefined) return value
51
+ return text && match.endsWith(';') && body.length >= 2 && body.length <= 32 ? `${REF_OPEN}${match}${REF_CLOSE}` : match
52
+ })
53
+ }
54
+
55
+ function escapeText(s: string): string {
56
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
57
+ }
58
+
59
+ /** Escapes a decoded text run, leaving the marked unknown references as written (they cannot form markup). */
60
+ function escapeRun(s: string): string {
61
+ return escapeText(s).replace(/\ue000&amp;([A-Za-z][A-Za-z0-9]*;)\ue001/g, '&$1').replace(MARKERS, '')
62
+ }
63
+
64
+ function escapeAttr(s: string): string {
65
+ return escapeText(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;')
66
+ }
67
+
68
+ type Token =
69
+ | { readonly t: 'text'; readonly v: string }
70
+ | { readonly t: 'start'; readonly name: string; readonly attrs: ReadonlyMap<string, string> }
71
+ | { readonly t: 'end'; readonly name: string }
72
+
73
+ interface Tag { readonly end: number; readonly name: string; readonly closing: boolean; readonly selfClosing: boolean; readonly attrs: Map<string, string> }
74
+
75
+ const isSpace = (c: string | undefined): boolean => c === ' ' || c === '\t' || c === '\n' || c === '\r' || c === '\f'
76
+ const isLetter = (c: string | undefined): boolean => c !== undefined && /[A-Za-z]/.test(c)
77
+
78
+ /** Reads the tag starting at `<`; null when it never closes. */
79
+ function readTag(s: string, start: number): Tag | null {
80
+ const n = s.length
81
+ let j = start + 1
82
+ const closing = s[j] === '/'
83
+ if (closing) j++
84
+ const nameStart = j
85
+ while (j < n && !isSpace(s[j]) && s[j] !== '/' && s[j] !== '>') j++
86
+ const name = s.slice(nameStart, j).toLowerCase()
87
+ const attrs = new Map<string, string>()
88
+ for (;;) {
89
+ while (j < n && (isSpace(s[j]) || s[j] === '/')) j++
90
+ if (j >= n) return null
91
+ if (s[j] === '>') return { end: j + 1, name, closing, selfClosing: s[j - 1] === '/', attrs }
92
+ const aStart = j
93
+ while (j < n && !isSpace(s[j]) && s[j] !== '=' && s[j] !== '/' && s[j] !== '>') j++
94
+ if (j === aStart) { j++; continue }
95
+ const attr = s.slice(aStart, j).toLowerCase()
96
+ while (j < n && isSpace(s[j])) j++
97
+ let value = ''
98
+ if (s[j] === '=') {
99
+ j++
100
+ while (j < n && isSpace(s[j])) j++
101
+ const q = s[j]
102
+ if (q === '"' || q === "'") {
103
+ const k = s.indexOf(q, j + 1)
104
+ if (k < 0) return null
105
+ value = s.slice(j + 1, k)
106
+ j = k + 1
107
+ } else {
108
+ const vStart = j
109
+ while (j < n && !isSpace(s[j]) && s[j] !== '>') j++
110
+ value = s.slice(vStart, j)
111
+ }
112
+ }
113
+ if (!attrs.has(attr)) attrs.set(attr, decodeEntities(value))
114
+ }
115
+ }
116
+
117
+ /** Tokens of the input with dropped elements and their content already removed. Text and attribute values are decoded. */
118
+ function* tokenize(s: string): Generator<Token> {
119
+ const n = s.length
120
+ let i = 0
121
+ let skip: { name: string; depth: number } | null = null
122
+ while (i < n) {
123
+ if (s[i] !== '<') {
124
+ const k = s.indexOf('<', i)
125
+ const end = k < 0 ? n : k
126
+ if (skip === null) yield { t: 'text', v: decodeEntities(s.slice(i, end), true) }
127
+ i = end
128
+ continue
129
+ }
130
+ const next = s[i + 1]
131
+ if (isLetter(next) || (next === '/' && isLetter(s[i + 2]))) {
132
+ const tag = readTag(s, i)
133
+ if (tag === null) return
134
+ i = tag.end
135
+ if (skip !== null) {
136
+ if (tag.name === skip.name) {
137
+ if (tag.closing) { if (--skip.depth === 0) skip = null } else if (!tag.selfClosing) skip.depth++
138
+ }
139
+ continue
140
+ }
141
+ if (tag.closing) { yield { t: 'end', name: tag.name }; continue }
142
+ if (DROPPED.has(tag.name)) {
143
+ if (EMPTY_DROPPED.has(tag.name) || tag.selfClosing) continue
144
+ if (RAW_TEXT.has(tag.name)) {
145
+ const close = new RegExp(`</${tag.name}[\\s/>]`, 'ig')
146
+ close.lastIndex = i
147
+ const m = close.exec(s)
148
+ if (m === null) return
149
+ const gt = s.indexOf('>', m.index)
150
+ if (gt < 0) return
151
+ i = gt + 1
152
+ } else skip = { name: tag.name, depth: 1 }
153
+ continue
154
+ }
155
+ yield { t: 'start', name: tag.name, attrs: tag.attrs }
156
+ continue
157
+ }
158
+ if (next === '!' || next === '?' || next === '/') {
159
+ let end: number
160
+ if (s.startsWith('<!-->', i)) end = i + 5
161
+ else if (s.startsWith('<!--->', i)) end = i + 6
162
+ else if (s.startsWith('<!--', i)) { const k = s.indexOf('-->', i + 4); end = k < 0 ? -1 : k + 3 }
163
+ else if (s.startsWith('<![CDATA[', i)) { const k = s.indexOf(']]>', i + 9); end = k < 0 ? -1 : k + 3 }
164
+ else { const k = s.indexOf('>', i + 2); end = k < 0 ? -1 : k + 1 }
165
+ if (end < 0) return
166
+ i = end
167
+ continue
168
+ }
169
+ // A lone '<' is text.
170
+ const k = s.indexOf('<', i + 1)
171
+ const end = k < 0 ? n : k
172
+ if (skip === null) yield { t: 'text', v: decodeEntities(s.slice(i, end), true) }
173
+ i = end
174
+ }
175
+ }
176
+
177
+ const cleanUrl = (v: string): string => v.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim()
178
+ const NUM = /^\d{1,5}$/
179
+ const SIZE = /^\d{1,5}%?$/
180
+ const ALIGN = /^(left|right|center|justify)$/i
181
+ const VALIGN = /^(top|middle|bottom|baseline)$/i
182
+
183
+ const ATTRS: Record<string, Record<string, RegExp | 'text'>> = {
184
+ a: { title: 'text' },
185
+ img: { alt: 'text', width: SIZE, height: SIZE, title: 'text' },
186
+ td: { colspan: NUM, rowspan: NUM, align: ALIGN, valign: VALIGN },
187
+ th: { colspan: NUM, rowspan: NUM, align: ALIGN, valign: VALIGN },
188
+ table: { border: NUM, cellpadding: NUM, cellspacing: NUM, width: SIZE, align: ALIGN },
189
+ col: { span: NUM, width: SIZE },
190
+ colgroup: { span: NUM, width: SIZE },
191
+ ol: { start: NUM, type: /^[1aAiI]$/ },
192
+ font: { color: /^#?[0-9A-Za-z]{3,20}$/, size: /^[+-]?\d$/, face: /^[\w ,-]{1,100}$/ },
193
+ p: { align: ALIGN }, div: { align: ALIGN },
194
+ h1: { align: ALIGN }, h2: { align: ALIGN }, h3: { align: ALIGN }, h4: { align: ALIGN }, h5: { align: ALIGN }, h6: { align: ALIGN },
195
+ }
196
+
197
+ function safeHref(attrs: ReadonlyMap<string, string>): string | null {
198
+ const href = cleanUrl(attrs.get('href') ?? '')
199
+ return /^(https?:|mailto:)/i.test(href) ? href : null
200
+ }
201
+
202
+ function serialiseStart(name: string, attrs: ReadonlyMap<string, string>): string {
203
+ let out = `<${name}`
204
+ if (name === 'a') {
205
+ const href = safeHref(attrs)
206
+ if (href !== null) out += ` href="${escapeAttr(href)}"`
207
+ }
208
+ const rules = ATTRS[name]
209
+ if (rules !== undefined) {
210
+ for (const [attr, rule] of Object.entries(rules)) {
211
+ const v = attrs.get(attr)
212
+ if (v === undefined) continue
213
+ if (rule === 'text' || rule.test(v.trim())) out += ` ${attr}="${escapeAttr(rule === 'text' ? v : v.trim())}"`
214
+ }
215
+ }
216
+ if (name === 'a') out += ' rel="noopener noreferrer nofollow" target="_blank"'
217
+ return out + '>'
218
+ }
219
+
220
+ /** Returns the HTML reduced to allow-listed tags and attributes: no script, event handlers, style, forms or remote resources. */
221
+ export function sanitizeHtml(html: string): string {
222
+ let out = ''
223
+ const open: string[] = []
224
+ for (const tok of tokenize(html)) {
225
+ if (tok.t === 'text') { out += escapeRun(tok.v); continue }
226
+ if (!ALLOWED.has(tok.name)) continue
227
+ if (tok.t === 'start') {
228
+ if (tok.name === 'img') {
229
+ const src = cleanUrl(tok.attrs.get('src') ?? '')
230
+ if (/^cid:/i.test(src)) {
231
+ out += `<img src="${escapeAttr(src)}"${serialiseStart('img', tok.attrs).slice(4, -1)}>`
232
+ } else {
233
+ const alt = tok.attrs.get('alt') ?? ''
234
+ if (alt.trim() !== '') out += escapeText(alt)
235
+ }
236
+ continue
237
+ }
238
+ out += serialiseStart(tok.name, tok.attrs)
239
+ if (!VOID.has(tok.name)) open.push(tok.name)
240
+ } else {
241
+ const at = open.lastIndexOf(tok.name)
242
+ if (at < 0 || VOID.has(tok.name)) continue
243
+ while (open.length > at) out += `</${open.pop()}>`
244
+ }
245
+ }
246
+ while (open.length > 0) out += `</${open.pop()}>`
247
+ return out
248
+ }
249
+
250
+ const BLOCK = new Set(['p', 'div', 'br', 'li', 'tr', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'ul', 'ol', 'table', 'pre', 'hr'])
251
+
252
+ const LINE_START_ONLY = new Set(['li', 'tr', 'br'])
253
+
254
+ /** The readable text of the HTML: block tags become line breaks, entities are decoded, script and style are excluded. */
255
+ export function htmlToText(html: string): string {
256
+ let out = ''
257
+ let pre = 0
258
+ const links: { href: string; at: number }[] = []
259
+ for (const tok of tokenize(html)) {
260
+ if (tok.t === 'text') {
261
+ const v = tok.v.replace(/\ue000[^\ue001]*\ue001/g, '')
262
+ out += pre > 0 ? v : v.replace(/\s+/g, ' ')
263
+ } else if (tok.t === 'start') {
264
+ if (tok.name === 'pre') pre++
265
+ if (tok.name === 'a') {
266
+ const href = safeHref(tok.attrs)
267
+ if (href !== null) links.push({ href, at: out.length })
268
+ }
269
+ if (BLOCK.has(tok.name)) out += '\n'
270
+ } else {
271
+ if (tok.name === 'pre' && pre > 0) pre--
272
+ if (tok.name === 'a') {
273
+ const link = links.pop()
274
+ if (link !== undefined) {
275
+ const label = out.slice(link.at).trim()
276
+ if (label !== '' && label !== link.href && `mailto:${label}` !== link.href) out += ` (${link.href})`
277
+ }
278
+ }
279
+ if (BLOCK.has(tok.name) && !LINE_START_ONLY.has(tok.name)) out += '\n'
280
+ else if (tok.name === 'td' || tok.name === 'th') out += ' '
281
+ }
282
+ }
283
+ return out.split('\n').map(l => (pre > 0 ? l : l.replace(/[ \t ]+/g, ' ').trim())).join('\n').replace(/\n{3,}/g, '\n\n').trim()
284
+ }
@@ -15,6 +15,7 @@ import type { Handler } from '../../request/di/types.js'
15
15
  import { __flowStep } from '../../trace/runtime/flow-step.js'
16
16
  import { recordBeat, beatsEnabled } from '../alerts/beat.js'
17
17
  import type { Mail } from '../email/mail.js'
18
+ import { sanitizeHtml, htmlToText } from '../email/sanitize.js'
18
19
  import type { ReceivedEmail } from '../email/inbound.js'
19
20
  import { createOutbox, type Sink, type SinkDelivery } from '../outbox/core.js'
20
21
  import { TICKET_STATES } from './schema.js'
@@ -287,11 +288,17 @@ function toTicket(r: Record<string, unknown>): Ticket {
287
288
  }
288
289
  }
289
290
 
291
+ /** Inbound html is stranger-sent: rows stored before sanitising at ingest are cleaned on read (idempotent). */
292
+ function storedHtml(r: Record<string, unknown>): string | null {
293
+ const html = (r['html'] ?? null) as string | null
294
+ return html !== null && r['kind'] === 'inbound' ? sanitizeHtml(html) : html
295
+ }
296
+
290
297
  function toMessage(r: Record<string, unknown>): TicketMessage {
291
298
  return {
292
299
  id: r['id'] as string, ticketId: r['ticket_id'] as string, kind: r['kind'] as TicketMessage['kind'],
293
300
  channel: r['channel'] as TicketMessage['channel'], subject: (r['subject'] ?? null) as string | null,
294
- text: r['text'] as string, html: (r['html'] ?? null) as string | null, fromAddress: (r['from_address'] ?? null) as string | null,
301
+ text: r['text'] as string, html: storedHtml(r), fromAddress: (r['from_address'] ?? null) as string | null,
295
302
  inboundEmailId: (r['inbound_email_id'] ?? null) as string | null,
296
303
  attachments: (r['attachments'] ?? null) as TicketMessage['attachments'],
297
304
  draftStatus: (r['draft_status'] ?? null) as TicketMessage['draftStatus'], version: Number(r['version']),
@@ -549,9 +556,11 @@ export function createSupport(config: SupportConfig): Support {
549
556
  ticket = toTicket(rows[0]!)
550
557
  }
551
558
 
559
+ const html = m.html === null ? null : sanitizeHtml(m.html)
560
+ const text = m.text.trim() === '' && m.html !== null ? htmlToText(m.html) : m.text
552
561
  await tx.execute(sql`
553
562
  INSERT INTO support_message (tenant_id, ticket_id, kind, channel, text, html, from_address, inbound_email_id, attachments)
554
- VALUES (${tenantId}::uuid, ${ticket.id}::uuid, 'inbound', ${m.channel}, ${m.text}, ${m.html}, ${m.fromAddress},
563
+ VALUES (${tenantId}::uuid, ${ticket.id}::uuid, 'inbound', ${m.channel}, ${text}, ${html}, ${m.fromAddress},
555
564
  ${m.inboundEmailId}::uuid, ${m.attachments === null ? null : JSON.stringify(m.attachments)}::jsonb)`)
556
565
  await audit(flow, tx, tenantId, 'receive', ticket.id, { channel: m.channel, emailId: m.inboundEmailId, created, separated: m.separate === true, ...(joinedBy === null ? {} : { joinedBy }), ...(userIdSet === null ? {} : { userIdSet }) })
557
566
  await emit(tx, created ? 'ticket.created' : 'ticket.customer-replied', ticket)