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.
- package/lib/spyne.esm.js +3 -3
- package/lib/spyne.esm.js.LICENSE.txt +2 -2
- 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 +5 -2
- package/src/spyne/spyne-plugins.js +17 -0
- package/src/spyne/spyne.js +11 -3
- package/src/spyne/utils/channel-fetch-util.js +3 -2
- 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 +244 -23
- package/src/spyne/utils/sanitize-html.js +17 -40
- package/src/spyne/utils/spyne-app-properties.js +30 -0
- package/src/spyne/utils/spyne-warn.js +26 -0
- package/src/spyne/views/dom-element-template.js +93 -0
- package/src/spyne/views/dom-element.js +4 -3
- package/src/spyne/views/view-stream-selector.js +52 -47
- 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/index.test.js +5 -0
- package/src/tests/utils/safe-clone.test.js +193 -0
- package/src/tests/utils/security.test.js +153 -1
- package/src/tests/views/dom-el-selectors.test.js +7 -7
- package/src/tests/views/view-stream-selector.test.js +178 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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'
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
|
255
|
-
// 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.
|
|
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
|
|
545
|
+
* Applies the configured iframe and anchor policies to an element created
|
|
342
546
|
* through the attribute channel (DomElement), mirroring the DOMPurify
|
|
343
|
-
*
|
|
547
|
+
* hooks that guard parsed HTML.
|
|
344
548
|
*/
|
|
345
|
-
const
|
|
346
|
-
if (globalDisable || !el
|
|
549
|
+
const applyElementHardening = (el) => {
|
|
550
|
+
if (globalDisable || !el) return el
|
|
347
551
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
el.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
}
|