spyne 0.24.0 → 0.26.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.
@@ -15,7 +15,7 @@ const getPropType = (prp) => {
15
15
  return type
16
16
  }
17
17
 
18
- const cmsDataReviveNestedProxyObj = (proxyObj, proxyReviverMethod) => {
18
+ const cmsDataReviveNestedProxyObj = (proxyObj = {}, proxyReviverMethod) => {
19
19
  const { __proxy__proxyName } = proxyObj
20
20
 
21
21
  if (__proxy__proxyName === undefined) {
@@ -50,7 +50,7 @@ const cmsDataReviveNestedProxyObj = (proxyObj, proxyReviverMethod) => {
50
50
  return rootObj
51
51
  }
52
52
 
53
- export const safeClone = function(o) {
53
+ const reviveIfProxy = (o = {}) => {
54
54
  const { __proxy__proxyName } = o
55
55
  if (__proxy__proxyName !== undefined) {
56
56
  const proxyReviverMethod = SpyneAppProperties.getProxyReviver(__proxy__proxyName)
@@ -58,6 +58,132 @@ export const safeClone = function(o) {
58
58
  return cmsDataReviveNestedProxyObj(o, proxyReviverMethod)
59
59
  }
60
60
  }
61
+ return undefined
62
+ }
63
+
64
+ /**
65
+ *
66
+ * Deep, proxy-preserving clone. Unlike safeClone — which only revives when the
67
+ * ROOT object is a proxy — this walks plain containers (objects and arrays)
68
+ * and revives any nested proxy it finds, so wrappers that hold proxied data
69
+ * (e.g. channel payloads, pageData containers) survive cloning intact.
70
+ * Functions are passed through by reference; cycles are guarded.
71
+ *
72
+ * @param {Object|Array} o
73
+ * @returns A writable copy in which every nested proxy has been revived
74
+ */
75
+ export const safeCloneDeep = function(o, seen = new WeakSet()) {
76
+ if (o === null || typeof o !== 'object') {
77
+ return o
78
+ }
79
+
80
+ const revived = reviveIfProxy(o)
81
+ if (revived !== undefined) {
82
+ return revived
83
+ }
84
+
85
+ if (seen.has(o)) {
86
+ return o
87
+ }
88
+ seen.add(o)
89
+
90
+ if (Array.isArray(o)) {
91
+ return o.map(item => safeCloneDeep(item, seen))
92
+ }
93
+
94
+ const out = {}
95
+ Object.entries(o).forEach(([key, val]) => {
96
+ out[key] = safeCloneDeep(val, seen)
97
+ })
98
+ return out
99
+ }
100
+
101
+ /**
102
+ * Re-wraps a derived array with the SOURCE array's proxy metadata via the
103
+ * registered reviver. Native filter/reject/map keep element identity but
104
+ * return a plain array — losing the array-level proxy that string-item
105
+ * template loops (e.g. {{#titles}}) rely on for __cms__dataId / keyFor
106
+ * lookups. In production (no proxies) this is a single undefined check.
107
+ *
108
+ * Note: the reviver rebuilds its key/value map from the derived contents, so
109
+ * value-keyed lookups survive while original index positions shift.
110
+ */
111
+ const rewrapArrayFromSource = (sourceArr, resultArr) => {
112
+ const proxyName = sourceArr?.__proxy__proxyName
113
+ if (proxyName === undefined) {
114
+ return resultArr
115
+ }
116
+ const proxyReviverMethod = SpyneAppProperties.getProxyReviver(proxyName)
117
+ if (typeof proxyReviverMethod !== 'function') {
118
+ return resultArr
119
+ }
120
+ return proxyReviverMethod(resultArr, sourceArr.__proxy__props)
121
+ }
122
+
123
+ /**
124
+ *
125
+ * Proxy-preserving Array.prototype.filter: elements keep their identity
126
+ * (native behavior) AND the returned array keeps the source array's proxy
127
+ * metadata. Use in place of ramda filter / arr.filter on CMS data.
128
+ */
129
+ export const safeFilter = function(arr, predicate) {
130
+ return rewrapArrayFromSource(arr, arr.filter(predicate))
131
+ }
132
+
133
+ /**
134
+ *
135
+ * Proxy-preserving reject — inverse of safeFilter.
136
+ */
137
+ export const safeReject = function(arr, predicate) {
138
+ return rewrapArrayFromSource(arr, arr.filter((item, i) => predicate(item, i) === false))
139
+ }
140
+
141
+ /**
142
+ *
143
+ * Proxy-preserving Array.prototype.map with an augment-in-place contract:
144
+ * proxied elements arrive in the callback as WRITABLE revived copies, so
145
+ * `safeMap(stories, s => { s.type = 'story'; return s; })` is safe by
146
+ * construction (no silent live-proxy write traps). Plain elements pass by
147
+ * reference exactly like native map, so production cost matches native.
148
+ * A callback that builds an entirely new object per element still strips
149
+ * that element's identity — there is nothing left to preserve.
150
+ */
151
+ export const safeMap = function(arr, mapFn) {
152
+ const result = arr.map((item, i) => {
153
+ const isProxied = item !== null && typeof item === 'object' && item.__proxy__proxyName !== undefined
154
+ return mapFn(isProxied ? safeCloneDeep(item) : item, i)
155
+ })
156
+ return rewrapArrayFromSource(arr, result)
157
+ }
158
+
159
+ /**
160
+ *
161
+ * Revive-then-augment. Live CMS proxies silently ignore writes of new keys,
162
+ * so properties cannot be attached to them directly. This returns a writable,
163
+ * proxy-preserved copy of o with the given props applied — the sanctioned way
164
+ * to add view or route properties to CMS-proxied data.
165
+ *
166
+ * @param {Object|Array} o
167
+ * @param {Object} propsObj properties to assign onto the revived copy
168
+ * @returns A writable proxy-preserved copy carrying the extra props
169
+ */
170
+ export const safeAugment = function(o, propsObj = {}) {
171
+ const target = safeCloneDeep(o)
172
+ Object.entries(propsObj).forEach(([key, val]) => {
173
+ target[key] = val
174
+ })
175
+ return target
176
+ }
177
+
178
+ export const safeClone = function(o, deep = false) {
179
+ const revived = reviveIfProxy(o)
180
+ if (revived !== undefined) {
181
+ return revived
182
+ }
183
+
184
+ if (deep === true) {
185
+ return safeCloneDeep(o)
186
+ }
61
187
 
62
188
  const isArr = is(Array)
63
189
  const isObj = is(Object)
@@ -1,5 +1,6 @@
1
1
  // src/utils/sanitize-data.js
2
2
  import DOMPurify from 'dompurify'
3
+ import { spyneWarn } from './spyne-warn.js'
3
4
  import { SpyneAppProperties } from './spyne-app-properties.js'
4
5
 
5
6
  let _sanitizeData
@@ -23,6 +24,56 @@ const normalizeUriForCheck = (val) => String(val).replace(/[\u0000-\u0020]/g, ''
23
24
 
24
25
  const isSafeUri = (val) => SAFE_URI_REGEXP.test(normalizeUriForCheck(val))
25
26
 
27
+ /* ---------------------------------------------
28
+ * Custom elements
29
+ *
30
+ * The framework's own custom elements (spyne-*) are first-party output —
31
+ * the CMS proxy wraps editable content in <spyne-cms-item> etc. — and are
32
+ * admitted in every mode. They are inert without their defining plugin
33
+ * and the attribute check below only admits data-* on them.
34
+ *
35
+ * Additional elements register via allowCustomElements() (additive only;
36
+ * names must contain a hyphen per the custom-element spec, so built-in
37
+ * tags like script or iframe can never be admitted through this path)
38
+ * or via config.customElements at SpyneApp.init.
39
+ * ------------------------------------------- */
40
+ const SPYNE_CUSTOM_ELEMENT_RE = /^spyne-[a-z][a-z0-9-]*$/i
41
+
42
+ const allowedCustomElementPatterns = []
43
+
44
+ const allowCustomElements = (elements = []) => {
45
+ const arr = Array.isArray(elements) ? elements : [elements]
46
+
47
+ arr.forEach((entry) => {
48
+ if (entry instanceof RegExp) {
49
+ allowedCustomElementPatterns.push(entry)
50
+ return
51
+ }
52
+
53
+ const name = String(entry).toLowerCase()
54
+ if (/^[a-z][a-z0-9]*-[a-z0-9-]+$/.test(name) !== true) {
55
+ spyneWarn(`SPYNE WARNING: "${entry}" is not a valid custom element name (a hyphen is required) and was not added to the sanitizer allowlist.`)
56
+ return
57
+ }
58
+
59
+ allowedCustomElementPatterns.push(name)
60
+ })
61
+ }
62
+
63
+ const customElementTagCheck = (tagName) => {
64
+ if (SPYNE_CUSTOM_ELEMENT_RE.test(tagName)) return true
65
+
66
+ return allowedCustomElementPatterns.some(p =>
67
+ p instanceof RegExp ? p.test(tagName) : p === tagName
68
+ )
69
+ }
70
+
71
+ const CUSTOM_ELEMENT_HANDLING = {
72
+ tagNameCheck: customElementTagCheck,
73
+ attributeNameCheck: /^data-[a-z0-9-]+$/i,
74
+ allowCustomizedBuiltInElements: false
75
+ }
76
+
26
77
  /* ---------------------------------------------
27
78
  * Mode configurations
28
79
  * ------------------------------------------- */
@@ -42,14 +93,16 @@ const SAFE_FOR_RICH_TEXT = {
42
93
  ALLOW_DATA_ATTR: true,
43
94
  FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
44
95
  FORBID_TAGS: ['script', 'object', 'embed', 'meta'],
45
- ALLOWED_URI_REGEXP: SAFE_URI_REGEXP
96
+ ALLOWED_URI_REGEXP: SAFE_URI_REGEXP,
97
+ CUSTOM_ELEMENT_HANDLING
46
98
  }
47
99
 
48
100
  const SAFE_FOR_APP = {
49
101
  FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'link', 'meta'],
50
102
  FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
51
103
  ALLOW_DATA_ATTR: false,
52
- ALLOWED_URI_REGEXP: SAFE_URI_REGEXP
104
+ ALLOWED_URI_REGEXP: SAFE_URI_REGEXP,
105
+ CUSTOM_ELEMENT_HANDLING
53
106
  }
54
107
 
55
108
  /* ---------------------------------------------
@@ -136,7 +189,7 @@ const addIframeHardeningHook = () => {
136
189
 
137
190
  const src = node.getAttribute('src') || ''
138
191
  if (src !== '' && isAllowedIframeSrc(src) !== true) {
139
- console.warn(`SPYNE WARNING: iframe src "${src}" was removed by the iframe policy (same-origin, https, or an allowed origin is required).`)
192
+ spyneWarn(`SPYNE WARNING: iframe src "${src}" was removed by the iframe policy (same-origin, https, or an allowed origin is required).`)
140
193
  node.removeAttribute('src')
141
194
  }
142
195
 
@@ -147,6 +200,117 @@ const addIframeHardeningHook = () => {
147
200
  })
148
201
  }
149
202
 
203
+ /* ---------------------------------------------
204
+ * Anchor policy
205
+ *
206
+ * Configured via config.anchors at SpyneApp.init:
207
+ * allowTarget — true (default) admits target on dynamic anchors in
208
+ * every mode; the hook validates values and forces
209
+ * noopener. false strips target everywhere.
210
+ * addNoopener — true (default) forces rel="noopener noreferrer"
211
+ * onto target="_blank" anchors.
212
+ * allowedDomains — [] (any destination the URI scheme policy allows) |
213
+ * ['https://github.com', ...] restricts cross-origin
214
+ * hrefs to the listed origins. Same-origin, relative,
215
+ * mailto: and tel: hrefs are always allowed.
216
+ *
217
+ * A per-call override can be passed as opts.anchors to sanitizeData.
218
+ * The legacy top-level allowTargetAttr and addNoopener config keys are
219
+ * honored as fallbacks.
220
+ * ------------------------------------------- */
221
+ // target accepts any browsing-context name; these are the keywords that
222
+ // stay in the same context and therefore carry no opener risk.
223
+ const SAME_CONTEXT_TARGETS = ['_self', '_parent', '_top']
224
+
225
+ let configuredAnchorPolicy = { allowTarget: true, addNoopener: true, allowedDomains: [] }
226
+ let activeAnchorPolicy = configuredAnchorPolicy
227
+
228
+ const resolveAnchorPolicy = (override) => {
229
+ if (!override) return configuredAnchorPolicy
230
+ return {
231
+ allowTarget: override.allowTarget !== undefined ? override.allowTarget === true : configuredAnchorPolicy.allowTarget,
232
+ addNoopener: override.addNoopener !== undefined ? override.addNoopener === true : configuredAnchorPolicy.addNoopener,
233
+ allowedDomains: override.allowedDomains !== undefined ? override.allowedDomains : configuredAnchorPolicy.allowedDomains
234
+ }
235
+ }
236
+
237
+ const isAllowedAnchorHref = (href, policy = activeAnchorPolicy) => {
238
+ const normalized = normalizeUriForCheck(href)
239
+
240
+ // mailto: and tel: are not domains and always pass.
241
+ if (/^(mailto|tel):/i.test(normalized)) return true
242
+
243
+ let resolved
244
+ try {
245
+ resolved = new URL(normalized, window.location.href)
246
+ } catch (e) {
247
+ return false
248
+ }
249
+
250
+ // Same-origin links (including relative hrefs) are never restricted.
251
+ if (resolved.origin === window.location.origin && resolved.origin !== 'null') {
252
+ return true
253
+ }
254
+
255
+ if (/^https?:$/.test(resolved.protocol) !== true) return false
256
+
257
+ const domains = policy.allowedDomains
258
+ if (!Array.isArray(domains) || domains.length === 0) return true
259
+
260
+ return domains.some(d => {
261
+ try {
262
+ return new URL(d).origin === resolved.origin
263
+ } catch (e) {
264
+ return false
265
+ }
266
+ })
267
+ }
268
+
269
+ const forceNoopenerRel = (el) => {
270
+ const currentRel = el.getAttribute('rel') || ''
271
+ const relSet = new Set(currentRel.split(/\s+/).filter(Boolean))
272
+
273
+ relSet.add('noopener')
274
+ relSet.add('noreferrer')
275
+
276
+ el.setAttribute('rel', Array.from(relSet).join(' '))
277
+ }
278
+
279
+ let anchorHookAdded = false
280
+ const addAnchorHardeningHook = () => {
281
+ if (anchorHookAdded) return
282
+ anchorHookAdded = true
283
+
284
+ DOMPurify.addHook('afterSanitizeAttributes', (node) => {
285
+ if (!node || typeof node.getAttribute !== 'function') return
286
+ if (node.tagName !== 'A') return
287
+
288
+ const policy = activeAnchorPolicy
289
+ const href = node.getAttribute('href')
290
+
291
+ if (href && policy.allowedDomains.length > 0 && isAllowedAnchorHref(href, policy) !== true) {
292
+ spyneWarn(`SPYNE WARNING: anchor href "${href}" was removed by the anchor policy (same-origin or an allowed domain is required).`)
293
+ node.removeAttribute('href')
294
+ }
295
+
296
+ const target = node.getAttribute('target')
297
+ if (!target) return
298
+
299
+ // target without an href serves no purpose.
300
+ if (policy.allowTarget !== true || !node.getAttribute('href')) {
301
+ node.removeAttribute('target')
302
+ return
303
+ }
304
+
305
+ // Any browsing-context name is valid; _blank and named windows both
306
+ // hand the opened page an opener reference, so noopener applies to
307
+ // every target except the same-context keywords.
308
+ if (SAME_CONTEXT_TARGETS.includes(target) !== true && policy.addNoopener === true) {
309
+ forceNoopenerRel(node)
310
+ }
311
+ })
312
+ }
313
+
150
314
  /* ---------------------------------------------
151
315
  * Determine sanitization mode
152
316
  *
@@ -186,13 +350,27 @@ const buildModeConfig = (mode) => {
186
350
  }
187
351
  }
188
352
 
189
- if (mode === 'app' && allowIframe === true) {
190
- return {
191
- ...base,
192
- FORBID_TAGS: base.FORBID_TAGS.filter(t => t !== 'iframe'),
193
- ADD_TAGS: ['iframe'],
194
- ADD_ATTR: ['sandbox']
353
+ if (mode === 'app') {
354
+ const cfg = { ...base }
355
+ const addAttr = []
356
+
357
+ if (allowIframe === true) {
358
+ cfg.FORBID_TAGS = base.FORBID_TAGS.filter(t => t !== 'iframe')
359
+ cfg.ADD_TAGS = ['iframe']
360
+ addAttr.push('sandbox')
361
+ }
362
+
363
+ // DOMPurify strips target by default; re-admit it (with rel) so the
364
+ // anchor hook can validate values and force noopener instead.
365
+ if (activeAnchorPolicy.allowTarget === true) {
366
+ addAttr.push('target', 'rel')
195
367
  }
368
+
369
+ if (addAttr.length > 0) {
370
+ cfg.ADD_ATTR = addAttr
371
+ }
372
+
373
+ return cfg
196
374
  }
197
375
 
198
376
  return base
@@ -209,23 +387,37 @@ function makeSanitizeFn(mode = 'app') {
209
387
  * ------------------------------------------- */
210
388
  const sanitizeDataConfigure = (config = {}) => {
211
389
  if (isConfigured) {
212
- console.warn('sanitizeData is already configured. Reconfiguration is not allowed.')
390
+ spyneWarn('sanitizeData is already configured. Reconfiguration is not allowed.')
213
391
  return
214
392
  }
215
393
 
216
- const { strict = false, disableSanitize = false, iframes } = config
394
+ const { strict = false, disableSanitize = false, iframes, customElements, anchors, allowTargetAttr, addNoopener } = config
217
395
 
218
396
  globalDisable = disableSanitize === true
219
397
 
398
+ if (customElements !== undefined) {
399
+ allowCustomElements(customElements)
400
+ }
401
+
220
402
  if (iframes && typeof iframes === 'object') {
221
403
  configuredIframePolicy = resolveIframePolicy(iframes)
222
404
  activeIframePolicy = configuredIframePolicy
223
405
  }
224
406
 
407
+ // config.anchors wins; the legacy top-level allowTargetAttr/addNoopener
408
+ // keys are honored as fallbacks.
409
+ configuredAnchorPolicy = resolveAnchorPolicy({
410
+ allowTarget: anchors?.allowTarget !== undefined ? anchors.allowTarget : allowTargetAttr,
411
+ addNoopener: anchors?.addNoopener !== undefined ? anchors.addNoopener : addNoopener,
412
+ allowedDomains: anchors?.allowedDomains
413
+ })
414
+ activeAnchorPolicy = configuredAnchorPolicy
415
+
225
416
  if (globalDisable) {
226
417
  console.warn('SPYNE SECURITY WARNING: All data sanitization is DISABLED via config.disableSanitize. Do not use this setting in production.')
227
418
  } else {
228
419
  addIframeHardeningHook()
420
+ addAnchorHardeningHook()
229
421
  }
230
422
 
231
423
  const processFactory = (mode) => {
@@ -245,19 +437,21 @@ const sanitizeDataConfigure = (config = {}) => {
245
437
  }
246
438
 
247
439
  _sanitizeData = (data, opts = {}) => {
248
- const { disableSanitize: callDisable = false, mode, iframes: iframesOverride } = opts
440
+ const { disableSanitize: callDisable = false, mode, iframes: iframesOverride, anchors: anchorsOverride } = opts
249
441
  if (globalDisable || callDisable) return data
250
442
 
251
443
  const legacyStrict = forceStrict || strict
252
444
  const effectiveMode = mode || (legacyStrict ? 'app' : resolveDefaultMode())
253
445
 
254
- // Sanitization is synchronous, so the per-call iframe policy can be
255
- // scoped with a swap-and-restore around the processing pass.
446
+ // Sanitization is synchronous, so the per-call iframe and anchor
447
+ // policies can be scoped with a swap-and-restore around the pass.
256
448
  activeIframePolicy = resolveIframePolicy(iframesOverride)
449
+ activeAnchorPolicy = resolveAnchorPolicy(anchorsOverride)
257
450
  try {
258
451
  return processFactory(effectiveMode)(data)
259
452
  } finally {
260
453
  activeIframePolicy = configuredIframePolicy
454
+ activeAnchorPolicy = configuredAnchorPolicy
261
455
  }
262
456
  }
263
457
 
@@ -330,6 +524,16 @@ const sanitizeAttribute = (key, value, tagName) => {
330
524
  return { allowed: isAllowedIframeSrc(value, configuredIframePolicy), value }
331
525
  }
332
526
 
527
+ // Anchor href follows the anchor policy: safe scheme, plus the
528
+ // configured domain allowlist for cross-origin destinations.
529
+ if (tag === 'A' && k === 'href') {
530
+ if (isSafeUri(value) !== true) return { allowed: false, value }
531
+ if (configuredAnchorPolicy.allowedDomains.length > 0 && isAllowedAnchorHref(value, configuredAnchorPolicy) !== true) {
532
+ return { allowed: false, value }
533
+ }
534
+ return { allowed: true, value }
535
+ }
536
+
333
537
  if (URL_ATTRS.includes(k) && isSafeUri(value) !== true) {
334
538
  return { allowed: false, value }
335
539
  }
@@ -338,16 +542,31 @@ const sanitizeAttribute = (key, value, tagName) => {
338
542
  }
339
543
 
340
544
  /**
341
- * Applies the configured iframe sandbox policy to an element created
545
+ * Applies the configured iframe and anchor policies to an element created
342
546
  * through the attribute channel (DomElement), mirroring the DOMPurify
343
- * hook that guards parsed HTML.
547
+ * hooks that guard parsed HTML.
344
548
  */
345
- const applyIframeHardening = (el) => {
346
- if (globalDisable || !el || el.tagName !== 'IFRAME') return el
549
+ const applyElementHardening = (el) => {
550
+ if (globalDisable || !el) return el
347
551
 
348
- const { sandbox } = configuredIframePolicy
349
- if (sandbox !== false && el.hasAttribute('sandbox') !== true) {
350
- el.setAttribute('sandbox', sandbox)
552
+ if (el.tagName === 'IFRAME') {
553
+ const { sandbox } = configuredIframePolicy
554
+ if (sandbox !== false && el.hasAttribute('sandbox') !== true) {
555
+ el.setAttribute('sandbox', sandbox)
556
+ }
557
+ }
558
+
559
+ if (el.tagName === 'A') {
560
+ const policy = configuredAnchorPolicy
561
+ const target = el.getAttribute('target')
562
+
563
+ if (target) {
564
+ if (policy.allowTarget !== true || !el.getAttribute('href')) {
565
+ el.removeAttribute('target')
566
+ } else if (SAME_CONTEXT_TARGETS.includes(target) !== true && policy.addNoopener === true) {
567
+ forceNoopenerRel(el)
568
+ }
569
+ }
351
570
  }
352
571
 
353
572
  return el
@@ -376,7 +595,7 @@ const sanitizeEventTarget = (el, mode) => {
376
595
  * config.mode at SpyneApp.init. Retained for backward compatibility.
377
596
  * ------------------------------------------- */
378
597
  const setSanitizeDataForceStrict = (bool = true) => {
379
- console.warn('SPYNE DEPRECATION: setSanitizeDataForceStrict is deprecated. Pass { mode: "app" } to sanitizeData, or set config.mode in SpyneApp.init.')
598
+ spyneWarn('SPYNE DEPRECATION: setSanitizeDataForceStrict is deprecated. Pass { mode: "app" } to sanitizeData, or set config.mode in SpyneApp.init.')
380
599
  forceStrict = !!bool
381
600
  }
382
601
 
@@ -385,7 +604,9 @@ export {
385
604
  sanitizeData,
386
605
  sanitizeDataForce,
387
606
  sanitizeAttribute,
388
- applyIframeHardening,
607
+ applyElementHardening,
608
+ allowCustomElements,
609
+ CUSTOM_ELEMENT_HANDLING as customElementHandling,
389
610
  sanitizeEventTarget,
390
611
  setSanitizeDataForceStrict
391
612
  }
@@ -1,4 +1,6 @@
1
1
  import DOMPurify from 'dompurify'
2
+ import { spyneWarn } from './spyne-warn.js'
3
+ import { customElementHandling } from './sanitize-data.js'
2
4
 
3
5
  let _sanitizeHTML
4
6
  let isConfigured = false
@@ -17,7 +19,7 @@ let isConfigured = false
17
19
  */
18
20
  const sanitizeHTMLConfigure = (config = {}) => {
19
21
  if (isConfigured) {
20
- console.warn('sanitizeHTML is already configured. Reconfiguration is not allowed.')
22
+ spyneWarn('sanitizeHTML is already configured. Reconfiguration is not allowed.')
21
23
  return
22
24
  }
23
25
 
@@ -26,9 +28,9 @@ const sanitizeHTMLConfigure = (config = {}) => {
26
28
  mode = 'app',
27
29
  disableSanitize = false,
28
30
  allowTargetAttr = true,
29
- addNoopener = true,
30
31
  debug = false,
31
- iframes = {}
32
+ iframes = {},
33
+ anchors = {}
32
34
  } = config
33
35
 
34
36
  if (disableSanitize === true) {
@@ -46,6 +48,12 @@ const sanitizeHTMLConfigure = (config = {}) => {
46
48
  // is admitted here.
47
49
  const allowIframe = iframes.allow !== undefined ? iframes.allow === true : isRichtext
48
50
 
51
+ // config.anchors.allowTarget wins; the legacy top-level allowTargetAttr
52
+ // key is honored as a fallback. Target values and noopener enforcement
53
+ // are handled by the anchor hook registered in sanitize-data, which
54
+ // applies to this layer's sanitization as well.
55
+ const allowTarget = anchors.allowTarget !== undefined ? anchors.allowTarget === true : allowTargetAttr === true
56
+
49
57
  const domPurifyConfig = {
50
58
  RETURN_TRUSTED_TYPE: false,
51
59
  ADD_TAGS: [
@@ -53,49 +61,18 @@ const sanitizeHTMLConfigure = (config = {}) => {
53
61
  ...(isRichtext ? ['link'] : [])
54
62
  ],
55
63
  ADD_ATTR: [
56
- ...(allowTargetAttr ? ['target'] : []),
64
+ ...(allowTarget ? ['target', 'rel'] : []),
57
65
  ...(allowIframe ? ['sandbox'] : [])
58
66
  ],
67
+ // Admits the framework's spyne-* elements (CMS proxy wrappers) and any
68
+ // elements registered via allowCustomElements/config.customElements.
69
+ // The tag check is a closure, so late plugin registration is honored.
70
+ CUSTOM_ELEMENT_HANDLING: customElementHandling,
59
71
  ...(allowIframe !== true && isRichtext ? { FORBID_TAGS: ['iframe'] } : {})
60
72
  }
61
73
 
62
74
  const sanitizeWithPolicy = (html) => DOMPurify.sanitize(html, domPurifyConfig)
63
75
 
64
- if (allowTargetAttr) {
65
- DOMPurify.addHook('afterSanitizeAttributes', (node) => {
66
- if (!node || typeof node.getAttribute !== 'function') return
67
- if (node.tagName !== 'A') return
68
-
69
- const href = node.getAttribute('href')
70
- const target = node.getAttribute('target')
71
-
72
- // remove target on anchors without href
73
- if (!href && target) {
74
- node.removeAttribute('target')
75
- return
76
- }
77
-
78
- if (!target) return
79
-
80
- const validTargets = ['_blank', '_self', '_parent', '_top']
81
- if (!validTargets.includes(target)) {
82
- node.removeAttribute('target')
83
- return
84
- }
85
-
86
- if (target === '_blank' && addNoopener) {
87
- const currentRel = node.getAttribute('rel') || ''
88
- const relParts = currentRel.split(/\s+/).filter(Boolean)
89
- const relSet = new Set(relParts)
90
-
91
- relSet.add('noopener')
92
- relSet.add('noreferrer')
93
-
94
- node.setAttribute('rel', Array.from(relSet).join(' '))
95
- }
96
- })
97
- }
98
-
99
76
  _sanitizeHTML = sanitizeWithPolicy
100
77
 
101
78
  if (strict === true && window.trustedTypes) {
@@ -110,7 +87,7 @@ const sanitizeHTMLConfigure = (config = {}) => {
110
87
  console.log('SPYNE: Trusted Types default policy registered. Note that browser enforcement additionally requires the CSP header: require-trusted-types-for \'script\'.')
111
88
  }
112
89
  } catch (err) {
113
- console.warn('SPYNE WARNING: A Trusted Types "default" policy already exists on this page. Falling back to direct sanitization.', err)
90
+ spyneWarn('SPYNE WARNING: A Trusted Types "default" policy already exists on this page. Falling back to direct sanitization.', err)
114
91
  }
115
92
  }
116
93
 
@@ -9,6 +9,8 @@ let _channelsMap
9
9
  let _initialized
10
10
  let _debug = true
11
11
  let _enableCMSProxies = false
12
+ let _cmsRehydrator
13
+ let _cmsLineageWarner
12
14
  const _excludeChannelsFromConsole = []
13
15
  /* eslint-disable */
14
16
  let _linksData
@@ -250,6 +252,34 @@ class SpyneAppPropertiesClass {
250
252
  return _proxiesMap.get(proxyName)
251
253
  }
252
254
 
255
+ /**
256
+ * Registers the CMS re-hydration hook consulted by DomElementTemplate when
257
+ * it receives plain (unproxied) data while CMS proxies are enabled. The
258
+ * hook receives the template data and returns either revived proxy data
259
+ * for that content or undefined to decline. Implementations must be cheap
260
+ * for non-CMS data — ideally a single own-property read before bailing.
261
+ */
262
+ registerCmsRehydrator(method) {
263
+ _cmsRehydrator = method
264
+ }
265
+
266
+ get rehydrateCmsData() {
267
+ return _cmsRehydrator
268
+ }
269
+
270
+ /**
271
+ * Registers the debug-mode hook DomElementTemplate calls when data renders
272
+ * unproxied while CMS proxies are enabled and re-hydration declined —
273
+ * the lineage reporter that turns silent proxy strips into warnings.
274
+ */
275
+ registerCmsLineageWarner(method) {
276
+ _cmsLineageWarner = method
277
+ }
278
+
279
+ get warnUnproxiedCmsData() {
280
+ return _cmsLineageWarner
281
+ }
282
+
253
283
  getPluginConfigByPluginName(pluginName) {
254
284
  return _config.plugins[pluginName]
255
285
  }