spyne 0.23.0 → 0.25.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-fetch-class.js +14 -5
- package/src/spyne/spyne-app.js +7 -3
- package/src/spyne/spyne.js +5 -2
- package/src/spyne/utils/channel-fetch-util.js +239 -95
- package/src/spyne/utils/sanitize-data.js +320 -35
- package/src/spyne/utils/sanitize-html.js +59 -12
- package/src/spyne/utils/spyne-app-properties.js +35 -6
- package/src/spyne/utils/spyne-warn.js +26 -0
- package/src/spyne/views/dom-element-template.js +5 -0
- package/src/spyne/views/dom-element.js +22 -2
- package/src/spyne/views/view-stream-selector.js +52 -47
- package/src/tests/channels/channel-fetch.test.js +88 -1
- package/src/tests/index.test.js +5 -0
- package/src/tests/utils/channel-fetch-util.test.js +44 -2
- package/src/tests/utils/security.test.js +352 -8
- package/src/tests/views/dom-el-selectors.test.js +7 -7
- package/src/tests/views/view-stream-selector.test.js +178 -0
|
@@ -1,10 +1,78 @@
|
|
|
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
|
|
6
7
|
let isConfigured = false
|
|
7
|
-
let
|
|
8
|
+
let globalDisable = false
|
|
9
|
+
let forceStrict = false // deprecated legacy toggle, see setSanitizeDataForceStrict
|
|
10
|
+
|
|
11
|
+
/* ---------------------------------------------
|
|
12
|
+
* URI policy
|
|
13
|
+
*
|
|
14
|
+
* Allows https?, mailto:, tel:, data:image/ and any scheme-less
|
|
15
|
+
* (relative, fragment, query, protocol-relative) URL.
|
|
16
|
+
* All other schemes (javascript:, vbscript:, data:text/html, etc.) fail.
|
|
17
|
+
* ------------------------------------------- */
|
|
18
|
+
const SAFE_URI_REGEXP = /^(?:(?:https?|mailto|tel):|data:image\/|(?![a-z][a-z0-9+.-]*:)[\s\S]*$)/i
|
|
19
|
+
|
|
20
|
+
// Browsers strip ASCII control characters and whitespace when parsing URL
|
|
21
|
+
// schemes, so "jav\tascript:" executes. Normalize before testing.
|
|
22
|
+
// eslint-disable-next-line no-control-regex -- matching control chars is the point
|
|
23
|
+
const normalizeUriForCheck = (val) => String(val).replace(/[\u0000-\u0020]/g, '')
|
|
24
|
+
|
|
25
|
+
const isSafeUri = (val) => SAFE_URI_REGEXP.test(normalizeUriForCheck(val))
|
|
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
|
+
}
|
|
8
76
|
|
|
9
77
|
/* ---------------------------------------------
|
|
10
78
|
* Mode configurations
|
|
@@ -20,66 +88,203 @@ const SAFE_FOR_RICH_TEXT = {
|
|
|
20
88
|
ADD_TAGS: ['iframe', 'input', 'button', 'link'],
|
|
21
89
|
ALLOWED_ATTR: [
|
|
22
90
|
'href', 'src', 'alt', 'class', 'title', 'width', 'height', 'style', 'target', 'rel',
|
|
23
|
-
'controls', 'poster', 'type', 'rows', 'cols'
|
|
91
|
+
'controls', 'poster', 'type', 'rows', 'cols', 'sandbox'
|
|
24
92
|
],
|
|
25
93
|
ALLOW_DATA_ATTR: true,
|
|
26
94
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
|
|
27
95
|
FORBID_TAGS: ['script', 'object', 'embed', 'meta'],
|
|
28
|
-
|
|
96
|
+
ALLOWED_URI_REGEXP: SAFE_URI_REGEXP,
|
|
97
|
+
CUSTOM_ELEMENT_HANDLING
|
|
29
98
|
}
|
|
30
99
|
|
|
31
100
|
const SAFE_FOR_APP = {
|
|
32
101
|
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'link', 'meta'],
|
|
33
102
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
|
|
34
|
-
ALLOW_DATA_ATTR: false
|
|
103
|
+
ALLOW_DATA_ATTR: false,
|
|
104
|
+
ALLOWED_URI_REGEXP: SAFE_URI_REGEXP,
|
|
105
|
+
CUSTOM_ELEMENT_HANDLING
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* ---------------------------------------------
|
|
109
|
+
* Iframe policy
|
|
110
|
+
*
|
|
111
|
+
* Configured via config.iframes at SpyneApp.init:
|
|
112
|
+
* allow — undefined (mode default: richtext yes, app no) | true | false
|
|
113
|
+
* allowedOrigins — [] (any https origin) | ['https://www.youtube.com', ...]
|
|
114
|
+
* sandbox — sandbox value forced onto iframes lacking one; false = never force
|
|
115
|
+
*
|
|
116
|
+
* A per-call override can be passed as opts.iframes to sanitizeData.
|
|
117
|
+
* ------------------------------------------- */
|
|
118
|
+
const DEFAULT_IFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-popups'
|
|
119
|
+
|
|
120
|
+
let configuredIframePolicy = { allow: undefined, allowedOrigins: [], sandbox: DEFAULT_IFRAME_SANDBOX }
|
|
121
|
+
let activeIframePolicy = configuredIframePolicy
|
|
122
|
+
|
|
123
|
+
const resolveIframePolicy = (override) => {
|
|
124
|
+
if (!override) return configuredIframePolicy
|
|
125
|
+
return {
|
|
126
|
+
allow: override.allow !== undefined ? override.allow : configuredIframePolicy.allow,
|
|
127
|
+
allowedOrigins: override.allowedOrigins !== undefined ? override.allowedOrigins : configuredIframePolicy.allowedOrigins,
|
|
128
|
+
sandbox: override.sandbox !== undefined ? override.sandbox : configuredIframePolicy.sandbox
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const iframeAllowedForMode = (mode, policy = activeIframePolicy) => {
|
|
133
|
+
if (policy.allow === undefined) return mode === 'richtext'
|
|
134
|
+
return policy.allow === true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// localhost is a secure context per the platform's own definition.
|
|
138
|
+
const isTrustworthyLocalHostname = (hostname) =>
|
|
139
|
+
hostname === 'localhost' ||
|
|
140
|
+
hostname === '127.0.0.1' ||
|
|
141
|
+
hostname === '[::1]' ||
|
|
142
|
+
hostname.endsWith('.localhost')
|
|
143
|
+
|
|
144
|
+
const isAllowedIframeSrc = (src, policy = activeIframePolicy) => {
|
|
145
|
+
const normalized = normalizeUriForCheck(src)
|
|
146
|
+
|
|
147
|
+
let resolved
|
|
148
|
+
try {
|
|
149
|
+
resolved = new URL(normalized, window.location.href)
|
|
150
|
+
} catch (e) {
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Executable/pseudo schemes (javascript:, data:, vbscript:) resolve with
|
|
155
|
+
// an opaque 'null' origin and fail both checks below.
|
|
156
|
+
|
|
157
|
+
// Same-origin embeds (including relative src) are as trusted as the
|
|
158
|
+
// page itself and always pass, regardless of scheme or allowlist.
|
|
159
|
+
if (resolved.origin === window.location.origin && resolved.origin !== 'null') {
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Cross-origin embeds must be https, or http on a localhost host
|
|
164
|
+
// (a secure context) to keep local development friction-free.
|
|
165
|
+
const isSecure = resolved.protocol === 'https:' ||
|
|
166
|
+
(resolved.protocol === 'http:' && isTrustworthyLocalHostname(resolved.hostname))
|
|
167
|
+
|
|
168
|
+
if (isSecure !== true) return false
|
|
169
|
+
|
|
170
|
+
const origins = policy.allowedOrigins
|
|
171
|
+
if (!Array.isArray(origins) || origins.length === 0) return true
|
|
172
|
+
|
|
173
|
+
return origins.some(o => {
|
|
174
|
+
try {
|
|
175
|
+
return new URL(o).origin === resolved.origin
|
|
176
|
+
} catch (e) {
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let iframeHookAdded = false
|
|
183
|
+
const addIframeHardeningHook = () => {
|
|
184
|
+
if (iframeHookAdded) return
|
|
185
|
+
iframeHookAdded = true
|
|
186
|
+
|
|
187
|
+
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
|
188
|
+
if (!node || node.tagName !== 'IFRAME') return
|
|
189
|
+
|
|
190
|
+
const src = node.getAttribute('src') || ''
|
|
191
|
+
if (src !== '' && isAllowedIframeSrc(src) !== true) {
|
|
192
|
+
spyneWarn(`SPYNE WARNING: iframe src "${src}" was removed by the iframe policy (same-origin, https, or an allowed origin is required).`)
|
|
193
|
+
node.removeAttribute('src')
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { sandbox } = activeIframePolicy
|
|
197
|
+
if (sandbox !== false && node.hasAttribute('sandbox') !== true) {
|
|
198
|
+
node.setAttribute('sandbox', sandbox)
|
|
199
|
+
}
|
|
200
|
+
})
|
|
35
201
|
}
|
|
36
202
|
|
|
37
203
|
/* ---------------------------------------------
|
|
38
|
-
* Determine sanitization mode
|
|
204
|
+
* Determine sanitization mode
|
|
205
|
+
*
|
|
206
|
+
* Explicit config always wins; the build environment is only a
|
|
207
|
+
* fallback when no mode has been configured.
|
|
39
208
|
* ------------------------------------------- */
|
|
209
|
+
const RICHTEXT_ALIASES = ['richtext', 'authoring', 'development']
|
|
210
|
+
|
|
40
211
|
const resolveDefaultMode = () => {
|
|
212
|
+
const configMode = SpyneAppProperties?.mode
|
|
213
|
+
|
|
214
|
+
if (RICHTEXT_ALIASES.includes(configMode)) return 'richtext'
|
|
215
|
+
if (configMode === 'app') return 'app'
|
|
216
|
+
|
|
41
217
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
218
|
+
if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development') {
|
|
219
|
+
return 'richtext'
|
|
220
|
+
}
|
|
44
221
|
} catch (e) {
|
|
45
|
-
//
|
|
222
|
+
// no build-time env available
|
|
46
223
|
}
|
|
224
|
+
|
|
47
225
|
return 'app'
|
|
48
226
|
}
|
|
49
227
|
|
|
50
228
|
/* --------------------------------------------- */
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const cfg = mode === 'richtext' ? SAFE_FOR_RICH_TEXT : SAFE_FOR_APP
|
|
229
|
+
const buildModeConfig = (mode) => {
|
|
230
|
+
const base = mode === 'richtext' ? SAFE_FOR_RICH_TEXT : SAFE_FOR_APP
|
|
231
|
+
const allowIframe = iframeAllowedForMode(mode)
|
|
57
232
|
|
|
58
|
-
|
|
59
|
-
|
|
233
|
+
if (mode === 'richtext' && allowIframe !== true) {
|
|
234
|
+
return {
|
|
235
|
+
...base,
|
|
236
|
+
ALLOWED_TAGS: base.ALLOWED_TAGS.filter(t => t !== 'iframe'),
|
|
237
|
+
ADD_TAGS: base.ADD_TAGS.filter(t => t !== 'iframe'),
|
|
238
|
+
FORBID_TAGS: [...base.FORBID_TAGS, 'iframe']
|
|
239
|
+
}
|
|
240
|
+
}
|
|
60
241
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
})
|
|
242
|
+
if (mode === 'app' && allowIframe === true) {
|
|
243
|
+
return {
|
|
244
|
+
...base,
|
|
245
|
+
FORBID_TAGS: base.FORBID_TAGS.filter(t => t !== 'iframe'),
|
|
246
|
+
ADD_TAGS: ['iframe'],
|
|
247
|
+
ADD_ATTR: ['sandbox']
|
|
68
248
|
}
|
|
69
|
-
return clean
|
|
70
249
|
}
|
|
250
|
+
|
|
251
|
+
return base
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function makeSanitizeFn(mode = 'app') {
|
|
255
|
+
const cfg = buildModeConfig(mode)
|
|
256
|
+
|
|
257
|
+
return (html) => DOMPurify.sanitize(html, cfg)
|
|
71
258
|
}
|
|
72
259
|
|
|
73
260
|
/* ---------------------------------------------
|
|
74
|
-
* Configure once
|
|
261
|
+
* Configure once
|
|
75
262
|
* ------------------------------------------- */
|
|
76
263
|
const sanitizeDataConfigure = (config = {}) => {
|
|
77
264
|
if (isConfigured) {
|
|
78
|
-
|
|
265
|
+
spyneWarn('sanitizeData is already configured. Reconfiguration is not allowed.')
|
|
79
266
|
return
|
|
80
267
|
}
|
|
81
268
|
|
|
82
|
-
const { strict = false } = config
|
|
269
|
+
const { strict = false, disableSanitize = false, iframes, customElements } = config
|
|
270
|
+
|
|
271
|
+
globalDisable = disableSanitize === true
|
|
272
|
+
|
|
273
|
+
if (customElements !== undefined) {
|
|
274
|
+
allowCustomElements(customElements)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (iframes && typeof iframes === 'object') {
|
|
278
|
+
configuredIframePolicy = resolveIframePolicy(iframes)
|
|
279
|
+
activeIframePolicy = configuredIframePolicy
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (globalDisable) {
|
|
283
|
+
console.warn('SPYNE SECURITY WARNING: All data sanitization is DISABLED via config.disableSanitize. Do not use this setting in production.')
|
|
284
|
+
} else {
|
|
285
|
+
addIframeHardeningHook()
|
|
286
|
+
}
|
|
287
|
+
|
|
83
288
|
const processFactory = (mode) => {
|
|
84
289
|
const sanitizeStr = makeSanitizeFn(mode)
|
|
85
290
|
const process = (val) => {
|
|
@@ -97,14 +302,24 @@ const sanitizeDataConfigure = (config = {}) => {
|
|
|
97
302
|
}
|
|
98
303
|
|
|
99
304
|
_sanitizeData = (data, opts = {}) => {
|
|
100
|
-
const { disableSanitize = false, mode } = opts
|
|
305
|
+
const { disableSanitize: callDisable = false, mode, iframes: iframesOverride } = opts
|
|
306
|
+
if (globalDisable || callDisable) return data
|
|
307
|
+
|
|
101
308
|
const legacyStrict = forceStrict || strict
|
|
102
309
|
const effectiveMode = mode || (legacyStrict ? 'app' : resolveDefaultMode())
|
|
103
|
-
|
|
104
|
-
|
|
310
|
+
|
|
311
|
+
// Sanitization is synchronous, so the per-call iframe policy can be
|
|
312
|
+
// scoped with a swap-and-restore around the processing pass.
|
|
313
|
+
activeIframePolicy = resolveIframePolicy(iframesOverride)
|
|
314
|
+
try {
|
|
315
|
+
return processFactory(effectiveMode)(data)
|
|
316
|
+
} finally {
|
|
317
|
+
activeIframePolicy = configuredIframePolicy
|
|
318
|
+
}
|
|
105
319
|
}
|
|
106
320
|
|
|
107
321
|
sanitizeDataConfigure.sanitizeDataForce = (data, mode) => {
|
|
322
|
+
if (globalDisable) return data
|
|
108
323
|
const effective = mode || resolveDefaultMode()
|
|
109
324
|
return processFactory(effective)(data)
|
|
110
325
|
}
|
|
@@ -119,7 +334,6 @@ const sanitizeData = (data, opts = {}) => {
|
|
|
119
334
|
if (!isConfigured) {
|
|
120
335
|
throw new Error('sanitizeData is not configured. Call sanitizeDataConfigure() first.')
|
|
121
336
|
}
|
|
122
|
-
// return data;
|
|
123
337
|
return _sanitizeData(data, opts)
|
|
124
338
|
}
|
|
125
339
|
|
|
@@ -134,14 +348,75 @@ const sanitizeDataForce = (data, mode) => {
|
|
|
134
348
|
return sanitizeDataConfigure.sanitizeDataForce(data, mode)
|
|
135
349
|
}
|
|
136
350
|
|
|
351
|
+
/* ---------------------------------------------
|
|
352
|
+
* Attribute sanitizer
|
|
353
|
+
*
|
|
354
|
+
* The single value-level guard for the attribute channel
|
|
355
|
+
* (DomElement.setElAttrs). Attribute names are gated by the
|
|
356
|
+
* DomElement/ViewStream allowlists; this validates values.
|
|
357
|
+
*
|
|
358
|
+
* Independent of sanitizeDataConfigure so it is always available.
|
|
359
|
+
* Returns { allowed: Boolean, value }.
|
|
360
|
+
* ------------------------------------------- */
|
|
361
|
+
const URL_ATTRS = [
|
|
362
|
+
'href', 'src', 'action', 'formaction', 'ping', 'poster',
|
|
363
|
+
'cite', 'codebase', 'manifest', 'icon', 'background'
|
|
364
|
+
]
|
|
365
|
+
|
|
366
|
+
const sanitizeAttribute = (key, value, tagName) => {
|
|
367
|
+
if (globalDisable) return { allowed: true, value }
|
|
368
|
+
|
|
369
|
+
const k = String(key).toLowerCase()
|
|
370
|
+
const tag = String(tagName || '').toUpperCase()
|
|
371
|
+
|
|
372
|
+
// Event-handler attributes are never legitimate in SpyneJS —
|
|
373
|
+
// broadcastEvents is the declared mechanism for interactivity.
|
|
374
|
+
if (k.startsWith('on')) {
|
|
375
|
+
return { allowed: false, value }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// srcdoc is a full HTML document; sanitize it as one.
|
|
379
|
+
if (k === 'srcdoc') {
|
|
380
|
+
const cfg = buildModeConfig(resolveDefaultMode())
|
|
381
|
+
return { allowed: true, value: DOMPurify.sanitize(String(value), cfg) }
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Iframe src follows the iframe policy: https-only, plus the
|
|
385
|
+
// configured origin allowlist.
|
|
386
|
+
if (tag === 'IFRAME' && k === 'src') {
|
|
387
|
+
return { allowed: isAllowedIframeSrc(value, configuredIframePolicy), value }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (URL_ATTRS.includes(k) && isSafeUri(value) !== true) {
|
|
391
|
+
return { allowed: false, value }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return { allowed: true, value }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Applies the configured iframe sandbox policy to an element created
|
|
399
|
+
* through the attribute channel (DomElement), mirroring the DOMPurify
|
|
400
|
+
* hook that guards parsed HTML.
|
|
401
|
+
*/
|
|
402
|
+
const applyIframeHardening = (el) => {
|
|
403
|
+
if (globalDisable || !el || el.tagName !== 'IFRAME') return el
|
|
404
|
+
|
|
405
|
+
const { sandbox } = configuredIframePolicy
|
|
406
|
+
if (sandbox !== false && el.hasAttribute('sandbox') !== true) {
|
|
407
|
+
el.setAttribute('sandbox', sandbox)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return el
|
|
411
|
+
}
|
|
412
|
+
|
|
137
413
|
/* ---------------------------------------------
|
|
138
414
|
* Event-target sanitizer
|
|
139
415
|
* ------------------------------------------- */
|
|
140
416
|
const sanitizeEventTarget = (el, mode) => {
|
|
141
|
-
if (!el) return
|
|
417
|
+
if (!el || globalDisable) return
|
|
142
418
|
const effectiveMode = mode || resolveDefaultMode()
|
|
143
419
|
const sanitizeStr = makeSanitizeFn(effectiveMode)
|
|
144
|
-
// const UNSAFE_RE = /<(?:script|iframe|object|embed|form|input|button|link|meta)[^>]*>|on\w+=|javascript:/i
|
|
145
420
|
const UNSAFE_RE = /<(?:script|object|embed|form|input|button|link|meta)[^>]*>|on\w+=|javascript:/i
|
|
146
421
|
if (typeof el.value === 'string' && UNSAFE_RE.test(el.value)) {
|
|
147
422
|
const clean = sanitizeStr(el.value)
|
|
@@ -153,13 +428,23 @@ const sanitizeEventTarget = (el, mode) => {
|
|
|
153
428
|
}
|
|
154
429
|
}
|
|
155
430
|
|
|
156
|
-
/* ---------------------------------------------
|
|
157
|
-
|
|
431
|
+
/* ---------------------------------------------
|
|
432
|
+
* @deprecated Use sanitizeData(data, { mode: 'app' }) per call, or set
|
|
433
|
+
* config.mode at SpyneApp.init. Retained for backward compatibility.
|
|
434
|
+
* ------------------------------------------- */
|
|
435
|
+
const setSanitizeDataForceStrict = (bool = true) => {
|
|
436
|
+
spyneWarn('SPYNE DEPRECATION: setSanitizeDataForceStrict is deprecated. Pass { mode: "app" } to sanitizeData, or set config.mode in SpyneApp.init.')
|
|
437
|
+
forceStrict = !!bool
|
|
438
|
+
}
|
|
158
439
|
|
|
159
440
|
export {
|
|
160
441
|
sanitizeDataConfigure,
|
|
161
442
|
sanitizeData,
|
|
162
443
|
sanitizeDataForce,
|
|
444
|
+
sanitizeAttribute,
|
|
445
|
+
applyIframeHardening,
|
|
446
|
+
allowCustomElements,
|
|
447
|
+
CUSTOM_ELEMENT_HANDLING as customElementHandling,
|
|
163
448
|
sanitizeEventTarget,
|
|
164
449
|
setSanitizeDataForceStrict
|
|
165
450
|
}
|
|
@@ -1,29 +1,68 @@
|
|
|
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
|
|
5
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Configures the template-layer sanitizer.
|
|
10
|
+
*
|
|
11
|
+
* Sanitization is always on. The three-level posture:
|
|
12
|
+
*
|
|
13
|
+
* mode: 'app' (default) — DOMPurify with its standard allowlist
|
|
14
|
+
* mode: 'richtext' — additionally admits iframe/link (+ sandbox attr)
|
|
15
|
+
* so CMS/authoring content survives templates
|
|
16
|
+
* strict: true — additionally registers a Trusted Types 'default'
|
|
17
|
+
* policy so DOM sinks route through sanitization
|
|
18
|
+
* disableSanitize: true — the only full passthrough; logs a security warning
|
|
19
|
+
*/
|
|
6
20
|
const sanitizeHTMLConfigure = (config = {}) => {
|
|
7
21
|
if (isConfigured) {
|
|
8
|
-
|
|
22
|
+
spyneWarn('sanitizeHTML is already configured. Reconfiguration is not allowed.')
|
|
9
23
|
return
|
|
10
24
|
}
|
|
11
25
|
|
|
12
26
|
const {
|
|
13
|
-
strict =
|
|
27
|
+
strict = false,
|
|
28
|
+
mode = 'app',
|
|
29
|
+
disableSanitize = false,
|
|
14
30
|
allowTargetAttr = true,
|
|
15
|
-
addNoopener = true
|
|
31
|
+
addNoopener = true,
|
|
32
|
+
debug = false,
|
|
33
|
+
iframes = {}
|
|
16
34
|
} = config
|
|
17
35
|
|
|
18
|
-
if (
|
|
36
|
+
if (disableSanitize === true) {
|
|
37
|
+
console.warn('SPYNE SECURITY WARNING: All HTML sanitization is DISABLED via config.disableSanitize. Do not use this setting in production.')
|
|
19
38
|
_sanitizeHTML = (html) => html
|
|
20
39
|
isConfigured = true
|
|
21
40
|
return
|
|
22
41
|
}
|
|
23
42
|
|
|
43
|
+
const isRichtext = ['richtext', 'authoring', 'development'].includes(mode)
|
|
44
|
+
|
|
45
|
+
// Iframe admission follows config.iframes.allow when set; otherwise
|
|
46
|
+
// the mode default (richtext yes, app no). The DOMPurify hook added by
|
|
47
|
+
// sanitize-data enforces src origin and sandbox policy on whatever
|
|
48
|
+
// is admitted here.
|
|
49
|
+
const allowIframe = iframes.allow !== undefined ? iframes.allow === true : isRichtext
|
|
50
|
+
|
|
24
51
|
const domPurifyConfig = {
|
|
25
52
|
RETURN_TRUSTED_TYPE: false,
|
|
26
|
-
|
|
53
|
+
ADD_TAGS: [
|
|
54
|
+
...(allowIframe ? ['iframe'] : []),
|
|
55
|
+
...(isRichtext ? ['link'] : [])
|
|
56
|
+
],
|
|
57
|
+
ADD_ATTR: [
|
|
58
|
+
...(allowTargetAttr ? ['target'] : []),
|
|
59
|
+
...(allowIframe ? ['sandbox'] : [])
|
|
60
|
+
],
|
|
61
|
+
// Admits the framework's spyne-* elements (CMS proxy wrappers) and any
|
|
62
|
+
// elements registered via allowCustomElements/config.customElements.
|
|
63
|
+
// The tag check is a closure, so late plugin registration is honored.
|
|
64
|
+
CUSTOM_ELEMENT_HANDLING: customElementHandling,
|
|
65
|
+
...(allowIframe !== true && isRichtext ? { FORBID_TAGS: ['iframe'] } : {})
|
|
27
66
|
}
|
|
28
67
|
|
|
29
68
|
const sanitizeWithPolicy = (html) => DOMPurify.sanitize(html, domPurifyConfig)
|
|
@@ -63,14 +102,22 @@ const sanitizeHTMLConfigure = (config = {}) => {
|
|
|
63
102
|
})
|
|
64
103
|
}
|
|
65
104
|
|
|
66
|
-
|
|
67
|
-
const policy = window.trustedTypes.createPolicy('default', {
|
|
68
|
-
createHTML: (toEscape) => sanitizeWithPolicy(toEscape)
|
|
69
|
-
})
|
|
105
|
+
_sanitizeHTML = sanitizeWithPolicy
|
|
70
106
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
107
|
+
if (strict === true && window.trustedTypes) {
|
|
108
|
+
try {
|
|
109
|
+
const policy = window.trustedTypes.createPolicy('default', {
|
|
110
|
+
createHTML: (toEscape) => sanitizeWithPolicy(toEscape)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
_sanitizeHTML = (html) => policy.createHTML(html)
|
|
114
|
+
|
|
115
|
+
if (debug === true) {
|
|
116
|
+
console.log('SPYNE: Trusted Types default policy registered. Note that browser enforcement additionally requires the CSP header: require-trusted-types-for \'script\'.')
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
spyneWarn('SPYNE WARNING: A Trusted Types "default" policy already exists on this page. Falling back to direct sanitization.', err)
|
|
120
|
+
}
|
|
74
121
|
}
|
|
75
122
|
|
|
76
123
|
isConfigured = true
|
|
@@ -45,6 +45,16 @@ class SpyneAppPropertiesClass {
|
|
|
45
45
|
return _debug
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* The application's sanitization mode, set via config.mode in SpyneApp.init.
|
|
50
|
+
* 'app' (default) is the strict production posture; 'richtext' (aliases:
|
|
51
|
+
* 'authoring', 'development') is the permissive posture for CMS and
|
|
52
|
+
* authoring tools.
|
|
53
|
+
*/
|
|
54
|
+
get mode() {
|
|
55
|
+
return _config?.mode !== undefined ? _config.mode : 'app'
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
/**
|
|
49
59
|
* This is mostly used for debugging purposes
|
|
50
60
|
*
|
|
@@ -105,29 +115,48 @@ class SpyneAppPropertiesClass {
|
|
|
105
115
|
_channelsMap = { getStream, testStream, getProxySubject }
|
|
106
116
|
}
|
|
107
117
|
|
|
118
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
119
|
+
// FIXED: durable setProp/getProp + isolated ephemeral (read-once) tier
|
|
120
|
+
//
|
|
121
|
+
// Contract after fix:
|
|
122
|
+
// setProp(key, val) → durable. Persists for the app lifespan.
|
|
123
|
+
// Returns val.
|
|
124
|
+
// setProp(key, val, true) → ephemeral. Read-once via getProp. Returns val.
|
|
125
|
+
// createTempProp(val) → ephemeral with generated key. Returns the key.
|
|
126
|
+
// getProp(key) → returns durable value if present;
|
|
127
|
+
// otherwise returns ephemeral value ONCE,
|
|
128
|
+
// then deletes it. The two tiers cannot
|
|
129
|
+
// shadow each other.
|
|
130
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
108
132
|
setProp(key, val, isTemp = false) {
|
|
109
133
|
if (isTemp) {
|
|
110
134
|
_config.ephemeralProps[key] = val;
|
|
111
|
-
return val;
|
|
112
135
|
} else {
|
|
113
136
|
_config.tmpProps[key] = val;
|
|
114
137
|
}
|
|
115
|
-
return
|
|
138
|
+
return val; // FIX 2: both tiers return the stored value
|
|
116
139
|
}
|
|
117
140
|
|
|
118
141
|
getProp(key) {
|
|
119
|
-
|
|
142
|
+
// FIX 1+3: durable tier resolves FIRST and independently. A durable value
|
|
143
|
+
// can never be shadowed or consumed by a same-named ephemeral key.
|
|
144
|
+
if (_config?.tmpProps && Object.prototype.hasOwnProperty.call(_config.tmpProps, key)) {
|
|
145
|
+
return _config.tmpProps[key];
|
|
146
|
+
}
|
|
147
|
+
// Ephemeral tier: read-once. Only reached when no durable value exists.
|
|
148
|
+
if (_config?.ephemeralProps && Object.prototype.hasOwnProperty.call(_config.ephemeralProps, key)) {
|
|
120
149
|
const tempVal = _config.ephemeralProps[key];
|
|
121
150
|
delete _config.ephemeralProps[key];
|
|
122
151
|
return tempVal;
|
|
123
152
|
}
|
|
124
|
-
return
|
|
153
|
+
return undefined;
|
|
125
154
|
}
|
|
126
155
|
|
|
127
156
|
createTempProp(value) {
|
|
128
157
|
const key = random6Chars();
|
|
129
|
-
this.setProp(key, value, true);
|
|
130
|
-
return key;
|
|
158
|
+
this.setProp(key, value, true); // isTemp = true
|
|
159
|
+
return key; // returns key (correct — caller needs it to read back)
|
|
131
160
|
}
|
|
132
161
|
|
|
133
162
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central outlet for SpyneJS developer-hint warnings (invalid selectors,
|
|
3
|
+
* sanitized attributes, policy removals, deprecations).
|
|
4
|
+
*
|
|
5
|
+
* Warnings are suppressed when window.SPYNE_SUPPRESS_WARNINGS is true —
|
|
6
|
+
* primarily for test runs, where expected-failure cases would otherwise
|
|
7
|
+
* flood the reporter. The flag lives on window (not module state) so it
|
|
8
|
+
* spans separately bundled test files.
|
|
9
|
+
*
|
|
10
|
+
* Security-posture warnings (e.g. config.disableSanitize) do NOT route
|
|
11
|
+
* through here and are always printed.
|
|
12
|
+
*/
|
|
13
|
+
const spyneWarn = (...args) => {
|
|
14
|
+
const suppressed = typeof window !== 'undefined' && window.SPYNE_SUPPRESS_WARNINGS === true
|
|
15
|
+
if (suppressed !== true) {
|
|
16
|
+
console.warn(...args)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const setSpyneWarningsDisabled = (bool = true) => {
|
|
21
|
+
if (typeof window !== 'undefined') {
|
|
22
|
+
window.SPYNE_SUPPRESS_WARNINGS = bool === true
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { spyneWarn, setSpyneWarningsDisabled }
|
|
@@ -234,6 +234,11 @@ export class DomElementTemplate {
|
|
|
234
234
|
renderToString() {
|
|
235
235
|
let html = this.finalArr.join('')
|
|
236
236
|
html = DomElementTemplate.replaceImgPath(html)
|
|
237
|
+
if (this.testMode !== true) {
|
|
238
|
+
// String(...) unwraps TrustedHTML when strict mode is active,
|
|
239
|
+
// keeping this method's string contract.
|
|
240
|
+
html = String(sanitizeHTML(html))
|
|
241
|
+
}
|
|
237
242
|
window.setTimeout(this.removeThis, 2)
|
|
238
243
|
return html
|
|
239
244
|
}
|