spyne 0.25.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.
- package/lib/spyne.esm.js +3 -3
- package/lib/spyne.esm.js.map +1 -1
- package/lib/spyne.umd.js +2 -2
- package/lib/spyne.umd.js.LICENSE.txt +2 -2
- package/package.json +1 -1
- package/src/spyne/channels/channel-payload-class.js +112 -17
- package/src/spyne/channels/channel.js +13 -0
- package/src/spyne/channels/spyne-channel-window.js +95 -3
- package/src/spyne/spyne-app.js +4 -2
- package/src/spyne/spyne-plugins.js +17 -0
- package/src/spyne/spyne.js +6 -1
- package/src/spyne/utils/channel-payload-filter.js +26 -22
- package/src/spyne/utils/safe-clone.js +128 -2
- package/src/spyne/utils/sanitize-data.js +180 -18
- package/src/spyne/utils/sanitize-html.js +9 -38
- package/src/spyne/utils/spyne-app-properties.js +30 -0
- package/src/spyne/views/dom-element-template.js +93 -0
- package/src/spyne/views/dom-element.js +2 -2
- package/src/tests/channels/channel-payload-filter.test.js +26 -0
- package/src/tests/channels/channel-window-custom-events.test.js +130 -0
- package/src/tests/utils/safe-clone.test.js +193 -0
- package/src/tests/utils/security.test.js +88 -0
|
@@ -200,6 +200,117 @@ const addIframeHardeningHook = () => {
|
|
|
200
200
|
})
|
|
201
201
|
}
|
|
202
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
|
+
|
|
203
314
|
/* ---------------------------------------------
|
|
204
315
|
* Determine sanitization mode
|
|
205
316
|
*
|
|
@@ -239,13 +350,27 @@ const buildModeConfig = (mode) => {
|
|
|
239
350
|
}
|
|
240
351
|
}
|
|
241
352
|
|
|
242
|
-
if (mode === 'app'
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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')
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (addAttr.length > 0) {
|
|
370
|
+
cfg.ADD_ATTR = addAttr
|
|
248
371
|
}
|
|
372
|
+
|
|
373
|
+
return cfg
|
|
249
374
|
}
|
|
250
375
|
|
|
251
376
|
return base
|
|
@@ -266,7 +391,7 @@ const sanitizeDataConfigure = (config = {}) => {
|
|
|
266
391
|
return
|
|
267
392
|
}
|
|
268
393
|
|
|
269
|
-
const { strict = false, disableSanitize = false, iframes, customElements } = config
|
|
394
|
+
const { strict = false, disableSanitize = false, iframes, customElements, anchors, allowTargetAttr, addNoopener } = config
|
|
270
395
|
|
|
271
396
|
globalDisable = disableSanitize === true
|
|
272
397
|
|
|
@@ -279,10 +404,20 @@ const sanitizeDataConfigure = (config = {}) => {
|
|
|
279
404
|
activeIframePolicy = configuredIframePolicy
|
|
280
405
|
}
|
|
281
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
|
+
|
|
282
416
|
if (globalDisable) {
|
|
283
417
|
console.warn('SPYNE SECURITY WARNING: All data sanitization is DISABLED via config.disableSanitize. Do not use this setting in production.')
|
|
284
418
|
} else {
|
|
285
419
|
addIframeHardeningHook()
|
|
420
|
+
addAnchorHardeningHook()
|
|
286
421
|
}
|
|
287
422
|
|
|
288
423
|
const processFactory = (mode) => {
|
|
@@ -302,19 +437,21 @@ const sanitizeDataConfigure = (config = {}) => {
|
|
|
302
437
|
}
|
|
303
438
|
|
|
304
439
|
_sanitizeData = (data, opts = {}) => {
|
|
305
|
-
const { disableSanitize: callDisable = false, mode, iframes: iframesOverride } = opts
|
|
440
|
+
const { disableSanitize: callDisable = false, mode, iframes: iframesOverride, anchors: anchorsOverride } = opts
|
|
306
441
|
if (globalDisable || callDisable) return data
|
|
307
442
|
|
|
308
443
|
const legacyStrict = forceStrict || strict
|
|
309
444
|
const effectiveMode = mode || (legacyStrict ? 'app' : resolveDefaultMode())
|
|
310
445
|
|
|
311
|
-
// Sanitization is synchronous, so the per-call iframe
|
|
312
|
-
// scoped with a swap-and-restore around the
|
|
446
|
+
// Sanitization is synchronous, so the per-call iframe and anchor
|
|
447
|
+
// policies can be scoped with a swap-and-restore around the pass.
|
|
313
448
|
activeIframePolicy = resolveIframePolicy(iframesOverride)
|
|
449
|
+
activeAnchorPolicy = resolveAnchorPolicy(anchorsOverride)
|
|
314
450
|
try {
|
|
315
451
|
return processFactory(effectiveMode)(data)
|
|
316
452
|
} finally {
|
|
317
453
|
activeIframePolicy = configuredIframePolicy
|
|
454
|
+
activeAnchorPolicy = configuredAnchorPolicy
|
|
318
455
|
}
|
|
319
456
|
}
|
|
320
457
|
|
|
@@ -387,6 +524,16 @@ const sanitizeAttribute = (key, value, tagName) => {
|
|
|
387
524
|
return { allowed: isAllowedIframeSrc(value, configuredIframePolicy), value }
|
|
388
525
|
}
|
|
389
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
|
+
|
|
390
537
|
if (URL_ATTRS.includes(k) && isSafeUri(value) !== true) {
|
|
391
538
|
return { allowed: false, value }
|
|
392
539
|
}
|
|
@@ -395,16 +542,31 @@ const sanitizeAttribute = (key, value, tagName) => {
|
|
|
395
542
|
}
|
|
396
543
|
|
|
397
544
|
/**
|
|
398
|
-
* Applies the configured iframe
|
|
545
|
+
* Applies the configured iframe and anchor policies to an element created
|
|
399
546
|
* through the attribute channel (DomElement), mirroring the DOMPurify
|
|
400
|
-
*
|
|
547
|
+
* hooks that guard parsed HTML.
|
|
401
548
|
*/
|
|
402
|
-
const
|
|
403
|
-
if (globalDisable || !el
|
|
549
|
+
const applyElementHardening = (el) => {
|
|
550
|
+
if (globalDisable || !el) return el
|
|
551
|
+
|
|
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')
|
|
404
562
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
+
}
|
|
408
570
|
}
|
|
409
571
|
|
|
410
572
|
return el
|
|
@@ -442,7 +604,7 @@ export {
|
|
|
442
604
|
sanitizeData,
|
|
443
605
|
sanitizeDataForce,
|
|
444
606
|
sanitizeAttribute,
|
|
445
|
-
|
|
607
|
+
applyElementHardening,
|
|
446
608
|
allowCustomElements,
|
|
447
609
|
CUSTOM_ELEMENT_HANDLING as customElementHandling,
|
|
448
610
|
sanitizeEventTarget,
|
|
@@ -28,9 +28,9 @@ const sanitizeHTMLConfigure = (config = {}) => {
|
|
|
28
28
|
mode = 'app',
|
|
29
29
|
disableSanitize = false,
|
|
30
30
|
allowTargetAttr = true,
|
|
31
|
-
addNoopener = true,
|
|
32
31
|
debug = false,
|
|
33
|
-
iframes = {}
|
|
32
|
+
iframes = {},
|
|
33
|
+
anchors = {}
|
|
34
34
|
} = config
|
|
35
35
|
|
|
36
36
|
if (disableSanitize === true) {
|
|
@@ -48,6 +48,12 @@ const sanitizeHTMLConfigure = (config = {}) => {
|
|
|
48
48
|
// is admitted here.
|
|
49
49
|
const allowIframe = iframes.allow !== undefined ? iframes.allow === true : isRichtext
|
|
50
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
|
+
|
|
51
57
|
const domPurifyConfig = {
|
|
52
58
|
RETURN_TRUSTED_TYPE: false,
|
|
53
59
|
ADD_TAGS: [
|
|
@@ -55,7 +61,7 @@ const sanitizeHTMLConfigure = (config = {}) => {
|
|
|
55
61
|
...(isRichtext ? ['link'] : [])
|
|
56
62
|
],
|
|
57
63
|
ADD_ATTR: [
|
|
58
|
-
...(
|
|
64
|
+
...(allowTarget ? ['target', 'rel'] : []),
|
|
59
65
|
...(allowIframe ? ['sandbox'] : [])
|
|
60
66
|
],
|
|
61
67
|
// Admits the framework's spyne-* elements (CMS proxy wrappers) and any
|
|
@@ -67,41 +73,6 @@ const sanitizeHTMLConfigure = (config = {}) => {
|
|
|
67
73
|
|
|
68
74
|
const sanitizeWithPolicy = (html) => DOMPurify.sanitize(html, domPurifyConfig)
|
|
69
75
|
|
|
70
|
-
if (allowTargetAttr) {
|
|
71
|
-
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
|
72
|
-
if (!node || typeof node.getAttribute !== 'function') return
|
|
73
|
-
if (node.tagName !== 'A') return
|
|
74
|
-
|
|
75
|
-
const href = node.getAttribute('href')
|
|
76
|
-
const target = node.getAttribute('target')
|
|
77
|
-
|
|
78
|
-
// remove target on anchors without href
|
|
79
|
-
if (!href && target) {
|
|
80
|
-
node.removeAttribute('target')
|
|
81
|
-
return
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (!target) return
|
|
85
|
-
|
|
86
|
-
const validTargets = ['_blank', '_self', '_parent', '_top']
|
|
87
|
-
if (!validTargets.includes(target)) {
|
|
88
|
-
node.removeAttribute('target')
|
|
89
|
-
return
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (target === '_blank' && addNoopener) {
|
|
93
|
-
const currentRel = node.getAttribute('rel') || ''
|
|
94
|
-
const relParts = currentRel.split(/\s+/).filter(Boolean)
|
|
95
|
-
const relSet = new Set(relParts)
|
|
96
|
-
|
|
97
|
-
relSet.add('noopener')
|
|
98
|
-
relSet.add('noreferrer')
|
|
99
|
-
|
|
100
|
-
node.setAttribute('rel', Array.from(relSet).join(' '))
|
|
101
|
-
}
|
|
102
|
-
})
|
|
103
|
-
}
|
|
104
|
-
|
|
105
76
|
_sanitizeHTML = sanitizeWithPolicy
|
|
106
77
|
|
|
107
78
|
if (strict === true && window.trustedTypes) {
|
|
@@ -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
|
}
|
|
@@ -36,6 +36,27 @@ export class DomElementTemplate {
|
|
|
36
36
|
this.isProxyData = data.__cms__isProxy === true
|
|
37
37
|
this.testMode = opts?.testMode
|
|
38
38
|
|
|
39
|
+
// CMS lineage pass — construction-time only, and only in authoring mode.
|
|
40
|
+
// In production (enableCMSProxies false) this costs one boolean read;
|
|
41
|
+
// nothing here ever runs inside the substitution loops.
|
|
42
|
+
if (this.isProxyData === false && SpyneAppProperties.enableCMSProxies === true) {
|
|
43
|
+
const rehydrated = SpyneAppProperties.rehydrateCmsData?.(data)
|
|
44
|
+
if (rehydrated !== undefined) {
|
|
45
|
+
// a stripped clone was re-identified — render with the revived proxy
|
|
46
|
+
data = rehydrated
|
|
47
|
+
this.isProxyData = true
|
|
48
|
+
} else if (DomElementTemplate.templateBindsProxiedChild(data, this.template) === true) {
|
|
49
|
+
// plain wrapper holding proxied values (e.g. a pageData container),
|
|
50
|
+
// AND this template actually references one of those proxied children
|
|
51
|
+
// via a dot-path or loop token — only then is CMS wrapping useful.
|
|
52
|
+
// Flipping on child presence alone put ~40% of all templates through
|
|
53
|
+
// the wrap→inflate→sanitize→strip pipeline for zero bindings.
|
|
54
|
+
this.isProxyData = true
|
|
55
|
+
} else if (SpyneAppProperties.debug === true) {
|
|
56
|
+
SpyneAppProperties.warnUnproxiedCmsData?.(data, this.template)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
39
60
|
// Normalize triple-bracket tags to double-bracket.
|
|
40
61
|
//
|
|
41
62
|
// DomElementTemplate treats {{{key}}} as an alias for {{key}} — both
|
|
@@ -105,6 +126,57 @@ export class DomElementTemplate {
|
|
|
105
126
|
return /({{\.\*?}})/.test(str)
|
|
106
127
|
}
|
|
107
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Shallow, one-level scan for CMS-proxied values inside a plain container,
|
|
131
|
+
* bailing on the first hit. Lets wrappers that hold proxied content (a
|
|
132
|
+
* pageData object, a filtered array of proxied stories) opt in to CMS
|
|
133
|
+
* template wrapping even though the wrapper itself is not a proxy.
|
|
134
|
+
* Only ever called in authoring mode (enableCMSProxies true).
|
|
135
|
+
*/
|
|
136
|
+
/**
|
|
137
|
+
* Wrapper detection with a binding test: returns true only when data holds
|
|
138
|
+
* a proxied child under a key that the template actually references via a
|
|
139
|
+
* dot-path ({{key.prop}}) or loop ({{#key}} / {{#key.path}}) token. A
|
|
140
|
+
* template that never reaches into the proxied child gains nothing from
|
|
141
|
+
* CMS wrapping — its own top-level tokens have no dataId to bind — so
|
|
142
|
+
* flipping it into proxy mode only pays the wrap/sanitize/strip pipeline.
|
|
143
|
+
*/
|
|
144
|
+
static templateBindsProxiedChild(data, template) {
|
|
145
|
+
if (data === null || typeof data !== 'object' || typeof template !== 'string') {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
if (template.indexOf('{{') === -1) {
|
|
149
|
+
return false
|
|
150
|
+
}
|
|
151
|
+
const keys = Object.keys(data)
|
|
152
|
+
for (let i = 0; i < keys.length; i++) {
|
|
153
|
+
const key = keys[i]
|
|
154
|
+
const val = data[key]
|
|
155
|
+
if (val !== null && typeof val === 'object' && val.__cms__isProxy === true) {
|
|
156
|
+
if (template.indexOf(`{{${key}.`) !== -1 ||
|
|
157
|
+
template.indexOf(`{{#${key}}}`) !== -1 ||
|
|
158
|
+
template.indexOf(`{{#${key}.`) !== -1) {
|
|
159
|
+
return true
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
static hasProxiedChild(data) {
|
|
167
|
+
if (data === null || typeof data !== 'object') {
|
|
168
|
+
return false
|
|
169
|
+
}
|
|
170
|
+
const values = Object.values(data)
|
|
171
|
+
for (let i = 0; i < values.length; i++) {
|
|
172
|
+
const val = values[i]
|
|
173
|
+
if (val !== null && typeof val === 'object' && val.__cms__isProxy === true) {
|
|
174
|
+
return true
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
|
|
108
180
|
/**
|
|
109
181
|
* Normalizes triple-bracket tags to double-bracket form.
|
|
110
182
|
*
|
|
@@ -219,8 +291,26 @@ export class DomElementTemplate {
|
|
|
219
291
|
/**
|
|
220
292
|
* @desc Returns a document fragment generated from the template and any added data.
|
|
221
293
|
*/
|
|
294
|
+
/**
|
|
295
|
+
* Unwraps <spyne-cms-item> elements whose data-cms-id resolved to an empty
|
|
296
|
+
* string — tokens that live on a plain wrapper (no __cms__dataId) rather
|
|
297
|
+
* than on CMS content. Leaves their inner text in place, so the rendered
|
|
298
|
+
* DOM matches what a non-CMS render would produce. Only runs in authoring
|
|
299
|
+
* mode (isProxyData true), one pass over the final HTML string.
|
|
300
|
+
*/
|
|
301
|
+
static stripInertCmsItems(html) {
|
|
302
|
+
if (html.indexOf('data-cms-id=""') === -1) {
|
|
303
|
+
return html
|
|
304
|
+
}
|
|
305
|
+
const inertRE = /<spyne-cms-item data-cms-id=""[^>]*>[\s\S]*?<spyne-cms-item-text>([\s\S]*?)<\/spyne-cms-item-text>\s*<\/spyne-cms-item>/g
|
|
306
|
+
return html.replace(inertRE, '$1')
|
|
307
|
+
}
|
|
308
|
+
|
|
222
309
|
renderDocFrag() {
|
|
223
310
|
let html = DomElementTemplate.replaceImgPath(this.finalArr.join(''))
|
|
311
|
+
if (this.isProxyData === true) {
|
|
312
|
+
html = DomElementTemplate.stripInertCmsItems(html)
|
|
313
|
+
}
|
|
224
314
|
if (this.testMode !== true) {
|
|
225
315
|
html = sanitizeHTML(html)
|
|
226
316
|
}
|
|
@@ -234,6 +324,9 @@ export class DomElementTemplate {
|
|
|
234
324
|
renderToString() {
|
|
235
325
|
let html = this.finalArr.join('')
|
|
236
326
|
html = DomElementTemplate.replaceImgPath(html)
|
|
327
|
+
if (this.isProxyData === true) {
|
|
328
|
+
html = DomElementTemplate.stripInertCmsItems(html)
|
|
329
|
+
}
|
|
237
330
|
if (this.testMode !== true) {
|
|
238
331
|
// String(...) unwraps TrustedHTML when strict mode is active,
|
|
239
332
|
// keeping this method's string contract.
|
|
@@ -2,7 +2,7 @@ import { baseCoreMixins } from '../utils/mixins/base-core-mixins.js'
|
|
|
2
2
|
import { spyneWarn } from '../utils/spyne-warn.js'
|
|
3
3
|
import { DomElementTemplate } from './dom-element-template.js'
|
|
4
4
|
import { deepMerge } from '../utils/deep-merge.js'
|
|
5
|
-
import { sanitizeAttribute,
|
|
5
|
+
import { sanitizeAttribute, applyElementHardening } from '../utils/sanitize-data.js'
|
|
6
6
|
import { is, defaultTo, pick, mapObjIndexed, forEachObjIndexed, pipe } from 'ramda'
|
|
7
7
|
|
|
8
8
|
class DomElement {
|
|
@@ -79,7 +79,7 @@ class DomElement {
|
|
|
79
79
|
this.getProp('attrs').forEach(addAttributes)
|
|
80
80
|
|
|
81
81
|
if (testMode !== true) {
|
|
82
|
-
|
|
82
|
+
applyElementHardening(el)
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
return el
|
|
@@ -191,6 +191,32 @@ describe('should test channel payload filters boolean correctness', () => {
|
|
|
191
191
|
|
|
192
192
|
return true
|
|
193
193
|
})
|
|
194
|
+
|
|
195
|
+
it('should return false for an invalid css selector instead of throwing', () => {
|
|
196
|
+
const cpFilter = new ChannelPayloadFilter('!!!not-a-selector')
|
|
197
|
+
const payloadBool = cpFilter(ChannelPayloadToTestFilters)
|
|
198
|
+
expect(payloadBool).to.be.false
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('should not match a structural lookalike of a selector match', () => {
|
|
202
|
+
// two identical siblings: the selector matches only the first, the
|
|
203
|
+
// payload el is the second — Element.matches tests the payload el
|
|
204
|
+
// itself, so the structurally equal non-matching twin must NOT pass
|
|
205
|
+
const ul = document.createElement('ul')
|
|
206
|
+
ul.innerHTML = '<li class="twin"><b>same</b></li><li class="twin"><b>same</b></li>'
|
|
207
|
+
document.body.appendChild(ul)
|
|
208
|
+
const cpFilter = new ChannelPayloadFilter('li.twin:first-child')
|
|
209
|
+
const payloadBool = cpFilter({ srcElement: { el: ul.querySelectorAll('li.twin')[1] } })
|
|
210
|
+
expect(payloadBool).to.be.false
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('should match a detached element against its selector', () => {
|
|
214
|
+
const li = document.createElement('li')
|
|
215
|
+
li.className = 'detached-item'
|
|
216
|
+
const cpFilter = new ChannelPayloadFilter('li.detached-item')
|
|
217
|
+
const payloadBool = cpFilter({ srcElement: { el: li } })
|
|
218
|
+
expect(payloadBool).to.be.true
|
|
219
|
+
})
|
|
194
220
|
})
|
|
195
221
|
|
|
196
222
|
describe('it should test channel payload filter with data packer ', () => {
|