spyne 0.23.0 → 0.24.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-fetch-class.js +14 -5
- package/src/spyne/spyne-app.js +6 -3
- package/src/spyne/utils/channel-fetch-util.js +237 -94
- package/src/spyne/utils/sanitize-data.js +260 -34
- package/src/spyne/utils/sanitize-html.js +52 -11
- package/src/spyne/utils/spyne-app-properties.js +35 -6
- package/src/spyne/views/dom-element-template.js +5 -0
- package/src/spyne/views/dom-element.js +21 -2
- package/src/tests/channels/channel-fetch.test.js +88 -1
- package/src/tests/utils/channel-fetch-util.test.js +44 -2
- package/src/tests/utils/security.test.js +288 -8
|
@@ -4,7 +4,24 @@ import { SpyneAppProperties } from './spyne-app-properties.js'
|
|
|
4
4
|
|
|
5
5
|
let _sanitizeData
|
|
6
6
|
let isConfigured = false
|
|
7
|
-
let
|
|
7
|
+
let globalDisable = false
|
|
8
|
+
let forceStrict = false // deprecated legacy toggle, see setSanitizeDataForceStrict
|
|
9
|
+
|
|
10
|
+
/* ---------------------------------------------
|
|
11
|
+
* URI policy
|
|
12
|
+
*
|
|
13
|
+
* Allows https?, mailto:, tel:, data:image/ and any scheme-less
|
|
14
|
+
* (relative, fragment, query, protocol-relative) URL.
|
|
15
|
+
* All other schemes (javascript:, vbscript:, data:text/html, etc.) fail.
|
|
16
|
+
* ------------------------------------------- */
|
|
17
|
+
const SAFE_URI_REGEXP = /^(?:(?:https?|mailto|tel):|data:image\/|(?![a-z][a-z0-9+.-]*:)[\s\S]*$)/i
|
|
18
|
+
|
|
19
|
+
// Browsers strip ASCII control characters and whitespace when parsing URL
|
|
20
|
+
// schemes, so "jav\tascript:" executes. Normalize before testing.
|
|
21
|
+
// eslint-disable-next-line no-control-regex -- matching control chars is the point
|
|
22
|
+
const normalizeUriForCheck = (val) => String(val).replace(/[\u0000-\u0020]/g, '')
|
|
23
|
+
|
|
24
|
+
const isSafeUri = (val) => SAFE_URI_REGEXP.test(normalizeUriForCheck(val))
|
|
8
25
|
|
|
9
26
|
/* ---------------------------------------------
|
|
10
27
|
* Mode configurations
|
|
@@ -20,58 +37,175 @@ const SAFE_FOR_RICH_TEXT = {
|
|
|
20
37
|
ADD_TAGS: ['iframe', 'input', 'button', 'link'],
|
|
21
38
|
ALLOWED_ATTR: [
|
|
22
39
|
'href', 'src', 'alt', 'class', 'title', 'width', 'height', 'style', 'target', 'rel',
|
|
23
|
-
'controls', 'poster', 'type', 'rows', 'cols'
|
|
40
|
+
'controls', 'poster', 'type', 'rows', 'cols', 'sandbox'
|
|
24
41
|
],
|
|
25
42
|
ALLOW_DATA_ATTR: true,
|
|
26
43
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
|
|
27
44
|
FORBID_TAGS: ['script', 'object', 'embed', 'meta'],
|
|
28
|
-
|
|
45
|
+
ALLOWED_URI_REGEXP: SAFE_URI_REGEXP
|
|
29
46
|
}
|
|
30
47
|
|
|
31
48
|
const SAFE_FOR_APP = {
|
|
32
49
|
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'form', 'input', 'button', 'link', 'meta'],
|
|
33
50
|
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover', 'onfocus', 'onblur', 'onchange'],
|
|
34
|
-
ALLOW_DATA_ATTR: false
|
|
51
|
+
ALLOW_DATA_ATTR: false,
|
|
52
|
+
ALLOWED_URI_REGEXP: SAFE_URI_REGEXP
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ---------------------------------------------
|
|
56
|
+
* Iframe policy
|
|
57
|
+
*
|
|
58
|
+
* Configured via config.iframes at SpyneApp.init:
|
|
59
|
+
* allow — undefined (mode default: richtext yes, app no) | true | false
|
|
60
|
+
* allowedOrigins — [] (any https origin) | ['https://www.youtube.com', ...]
|
|
61
|
+
* sandbox — sandbox value forced onto iframes lacking one; false = never force
|
|
62
|
+
*
|
|
63
|
+
* A per-call override can be passed as opts.iframes to sanitizeData.
|
|
64
|
+
* ------------------------------------------- */
|
|
65
|
+
const DEFAULT_IFRAME_SANDBOX = 'allow-scripts allow-same-origin allow-popups'
|
|
66
|
+
|
|
67
|
+
let configuredIframePolicy = { allow: undefined, allowedOrigins: [], sandbox: DEFAULT_IFRAME_SANDBOX }
|
|
68
|
+
let activeIframePolicy = configuredIframePolicy
|
|
69
|
+
|
|
70
|
+
const resolveIframePolicy = (override) => {
|
|
71
|
+
if (!override) return configuredIframePolicy
|
|
72
|
+
return {
|
|
73
|
+
allow: override.allow !== undefined ? override.allow : configuredIframePolicy.allow,
|
|
74
|
+
allowedOrigins: override.allowedOrigins !== undefined ? override.allowedOrigins : configuredIframePolicy.allowedOrigins,
|
|
75
|
+
sandbox: override.sandbox !== undefined ? override.sandbox : configuredIframePolicy.sandbox
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const iframeAllowedForMode = (mode, policy = activeIframePolicy) => {
|
|
80
|
+
if (policy.allow === undefined) return mode === 'richtext'
|
|
81
|
+
return policy.allow === true
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// localhost is a secure context per the platform's own definition.
|
|
85
|
+
const isTrustworthyLocalHostname = (hostname) =>
|
|
86
|
+
hostname === 'localhost' ||
|
|
87
|
+
hostname === '127.0.0.1' ||
|
|
88
|
+
hostname === '[::1]' ||
|
|
89
|
+
hostname.endsWith('.localhost')
|
|
90
|
+
|
|
91
|
+
const isAllowedIframeSrc = (src, policy = activeIframePolicy) => {
|
|
92
|
+
const normalized = normalizeUriForCheck(src)
|
|
93
|
+
|
|
94
|
+
let resolved
|
|
95
|
+
try {
|
|
96
|
+
resolved = new URL(normalized, window.location.href)
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Executable/pseudo schemes (javascript:, data:, vbscript:) resolve with
|
|
102
|
+
// an opaque 'null' origin and fail both checks below.
|
|
103
|
+
|
|
104
|
+
// Same-origin embeds (including relative src) are as trusted as the
|
|
105
|
+
// page itself and always pass, regardless of scheme or allowlist.
|
|
106
|
+
if (resolved.origin === window.location.origin && resolved.origin !== 'null') {
|
|
107
|
+
return true
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Cross-origin embeds must be https, or http on a localhost host
|
|
111
|
+
// (a secure context) to keep local development friction-free.
|
|
112
|
+
const isSecure = resolved.protocol === 'https:' ||
|
|
113
|
+
(resolved.protocol === 'http:' && isTrustworthyLocalHostname(resolved.hostname))
|
|
114
|
+
|
|
115
|
+
if (isSecure !== true) return false
|
|
116
|
+
|
|
117
|
+
const origins = policy.allowedOrigins
|
|
118
|
+
if (!Array.isArray(origins) || origins.length === 0) return true
|
|
119
|
+
|
|
120
|
+
return origins.some(o => {
|
|
121
|
+
try {
|
|
122
|
+
return new URL(o).origin === resolved.origin
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let iframeHookAdded = false
|
|
130
|
+
const addIframeHardeningHook = () => {
|
|
131
|
+
if (iframeHookAdded) return
|
|
132
|
+
iframeHookAdded = true
|
|
133
|
+
|
|
134
|
+
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
|
|
135
|
+
if (!node || node.tagName !== 'IFRAME') return
|
|
136
|
+
|
|
137
|
+
const src = node.getAttribute('src') || ''
|
|
138
|
+
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).`)
|
|
140
|
+
node.removeAttribute('src')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const { sandbox } = activeIframePolicy
|
|
144
|
+
if (sandbox !== false && node.hasAttribute('sandbox') !== true) {
|
|
145
|
+
node.setAttribute('sandbox', sandbox)
|
|
146
|
+
}
|
|
147
|
+
})
|
|
35
148
|
}
|
|
36
149
|
|
|
37
150
|
/* ---------------------------------------------
|
|
38
|
-
* Determine sanitization mode
|
|
151
|
+
* Determine sanitization mode
|
|
152
|
+
*
|
|
153
|
+
* Explicit config always wins; the build environment is only a
|
|
154
|
+
* fallback when no mode has been configured.
|
|
39
155
|
* ------------------------------------------- */
|
|
156
|
+
const RICHTEXT_ALIASES = ['richtext', 'authoring', 'development']
|
|
157
|
+
|
|
40
158
|
const resolveDefaultMode = () => {
|
|
159
|
+
const configMode = SpyneAppProperties?.mode
|
|
160
|
+
|
|
161
|
+
if (RICHTEXT_ALIASES.includes(configMode)) return 'richtext'
|
|
162
|
+
if (configMode === 'app') return 'app'
|
|
163
|
+
|
|
41
164
|
try {
|
|
42
|
-
|
|
43
|
-
|
|
165
|
+
if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development') {
|
|
166
|
+
return 'richtext'
|
|
167
|
+
}
|
|
44
168
|
} catch (e) {
|
|
45
|
-
//
|
|
169
|
+
// no build-time env available
|
|
46
170
|
}
|
|
171
|
+
|
|
47
172
|
return 'app'
|
|
48
173
|
}
|
|
49
174
|
|
|
50
175
|
/* --------------------------------------------- */
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const cfg = mode === 'richtext' ? SAFE_FOR_RICH_TEXT : SAFE_FOR_APP
|
|
176
|
+
const buildModeConfig = (mode) => {
|
|
177
|
+
const base = mode === 'richtext' ? SAFE_FOR_RICH_TEXT : SAFE_FOR_APP
|
|
178
|
+
const allowIframe = iframeAllowedForMode(mode)
|
|
57
179
|
|
|
58
|
-
|
|
59
|
-
|
|
180
|
+
if (mode === 'richtext' && allowIframe !== true) {
|
|
181
|
+
return {
|
|
182
|
+
...base,
|
|
183
|
+
ALLOWED_TAGS: base.ALLOWED_TAGS.filter(t => t !== 'iframe'),
|
|
184
|
+
ADD_TAGS: base.ADD_TAGS.filter(t => t !== 'iframe'),
|
|
185
|
+
FORBID_TAGS: [...base.FORBID_TAGS, 'iframe']
|
|
186
|
+
}
|
|
187
|
+
}
|
|
60
188
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
})
|
|
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']
|
|
68
195
|
}
|
|
69
|
-
return clean
|
|
70
196
|
}
|
|
197
|
+
|
|
198
|
+
return base
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function makeSanitizeFn(mode = 'app') {
|
|
202
|
+
const cfg = buildModeConfig(mode)
|
|
203
|
+
|
|
204
|
+
return (html) => DOMPurify.sanitize(html, cfg)
|
|
71
205
|
}
|
|
72
206
|
|
|
73
207
|
/* ---------------------------------------------
|
|
74
|
-
* Configure once
|
|
208
|
+
* Configure once
|
|
75
209
|
* ------------------------------------------- */
|
|
76
210
|
const sanitizeDataConfigure = (config = {}) => {
|
|
77
211
|
if (isConfigured) {
|
|
@@ -79,7 +213,21 @@ const sanitizeDataConfigure = (config = {}) => {
|
|
|
79
213
|
return
|
|
80
214
|
}
|
|
81
215
|
|
|
82
|
-
const { strict = false } = config
|
|
216
|
+
const { strict = false, disableSanitize = false, iframes } = config
|
|
217
|
+
|
|
218
|
+
globalDisable = disableSanitize === true
|
|
219
|
+
|
|
220
|
+
if (iframes && typeof iframes === 'object') {
|
|
221
|
+
configuredIframePolicy = resolveIframePolicy(iframes)
|
|
222
|
+
activeIframePolicy = configuredIframePolicy
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (globalDisable) {
|
|
226
|
+
console.warn('SPYNE SECURITY WARNING: All data sanitization is DISABLED via config.disableSanitize. Do not use this setting in production.')
|
|
227
|
+
} else {
|
|
228
|
+
addIframeHardeningHook()
|
|
229
|
+
}
|
|
230
|
+
|
|
83
231
|
const processFactory = (mode) => {
|
|
84
232
|
const sanitizeStr = makeSanitizeFn(mode)
|
|
85
233
|
const process = (val) => {
|
|
@@ -97,14 +245,24 @@ const sanitizeDataConfigure = (config = {}) => {
|
|
|
97
245
|
}
|
|
98
246
|
|
|
99
247
|
_sanitizeData = (data, opts = {}) => {
|
|
100
|
-
const { disableSanitize = false, mode } = opts
|
|
248
|
+
const { disableSanitize: callDisable = false, mode, iframes: iframesOverride } = opts
|
|
249
|
+
if (globalDisable || callDisable) return data
|
|
250
|
+
|
|
101
251
|
const legacyStrict = forceStrict || strict
|
|
102
252
|
const effectiveMode = mode || (legacyStrict ? 'app' : resolveDefaultMode())
|
|
103
|
-
|
|
104
|
-
|
|
253
|
+
|
|
254
|
+
// Sanitization is synchronous, so the per-call iframe policy can be
|
|
255
|
+
// scoped with a swap-and-restore around the processing pass.
|
|
256
|
+
activeIframePolicy = resolveIframePolicy(iframesOverride)
|
|
257
|
+
try {
|
|
258
|
+
return processFactory(effectiveMode)(data)
|
|
259
|
+
} finally {
|
|
260
|
+
activeIframePolicy = configuredIframePolicy
|
|
261
|
+
}
|
|
105
262
|
}
|
|
106
263
|
|
|
107
264
|
sanitizeDataConfigure.sanitizeDataForce = (data, mode) => {
|
|
265
|
+
if (globalDisable) return data
|
|
108
266
|
const effective = mode || resolveDefaultMode()
|
|
109
267
|
return processFactory(effective)(data)
|
|
110
268
|
}
|
|
@@ -119,7 +277,6 @@ const sanitizeData = (data, opts = {}) => {
|
|
|
119
277
|
if (!isConfigured) {
|
|
120
278
|
throw new Error('sanitizeData is not configured. Call sanitizeDataConfigure() first.')
|
|
121
279
|
}
|
|
122
|
-
// return data;
|
|
123
280
|
return _sanitizeData(data, opts)
|
|
124
281
|
}
|
|
125
282
|
|
|
@@ -134,14 +291,75 @@ const sanitizeDataForce = (data, mode) => {
|
|
|
134
291
|
return sanitizeDataConfigure.sanitizeDataForce(data, mode)
|
|
135
292
|
}
|
|
136
293
|
|
|
294
|
+
/* ---------------------------------------------
|
|
295
|
+
* Attribute sanitizer
|
|
296
|
+
*
|
|
297
|
+
* The single value-level guard for the attribute channel
|
|
298
|
+
* (DomElement.setElAttrs). Attribute names are gated by the
|
|
299
|
+
* DomElement/ViewStream allowlists; this validates values.
|
|
300
|
+
*
|
|
301
|
+
* Independent of sanitizeDataConfigure so it is always available.
|
|
302
|
+
* Returns { allowed: Boolean, value }.
|
|
303
|
+
* ------------------------------------------- */
|
|
304
|
+
const URL_ATTRS = [
|
|
305
|
+
'href', 'src', 'action', 'formaction', 'ping', 'poster',
|
|
306
|
+
'cite', 'codebase', 'manifest', 'icon', 'background'
|
|
307
|
+
]
|
|
308
|
+
|
|
309
|
+
const sanitizeAttribute = (key, value, tagName) => {
|
|
310
|
+
if (globalDisable) return { allowed: true, value }
|
|
311
|
+
|
|
312
|
+
const k = String(key).toLowerCase()
|
|
313
|
+
const tag = String(tagName || '').toUpperCase()
|
|
314
|
+
|
|
315
|
+
// Event-handler attributes are never legitimate in SpyneJS —
|
|
316
|
+
// broadcastEvents is the declared mechanism for interactivity.
|
|
317
|
+
if (k.startsWith('on')) {
|
|
318
|
+
return { allowed: false, value }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// srcdoc is a full HTML document; sanitize it as one.
|
|
322
|
+
if (k === 'srcdoc') {
|
|
323
|
+
const cfg = buildModeConfig(resolveDefaultMode())
|
|
324
|
+
return { allowed: true, value: DOMPurify.sanitize(String(value), cfg) }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Iframe src follows the iframe policy: https-only, plus the
|
|
328
|
+
// configured origin allowlist.
|
|
329
|
+
if (tag === 'IFRAME' && k === 'src') {
|
|
330
|
+
return { allowed: isAllowedIframeSrc(value, configuredIframePolicy), value }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (URL_ATTRS.includes(k) && isSafeUri(value) !== true) {
|
|
334
|
+
return { allowed: false, value }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { allowed: true, value }
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Applies the configured iframe sandbox policy to an element created
|
|
342
|
+
* through the attribute channel (DomElement), mirroring the DOMPurify
|
|
343
|
+
* hook that guards parsed HTML.
|
|
344
|
+
*/
|
|
345
|
+
const applyIframeHardening = (el) => {
|
|
346
|
+
if (globalDisable || !el || el.tagName !== 'IFRAME') return el
|
|
347
|
+
|
|
348
|
+
const { sandbox } = configuredIframePolicy
|
|
349
|
+
if (sandbox !== false && el.hasAttribute('sandbox') !== true) {
|
|
350
|
+
el.setAttribute('sandbox', sandbox)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return el
|
|
354
|
+
}
|
|
355
|
+
|
|
137
356
|
/* ---------------------------------------------
|
|
138
357
|
* Event-target sanitizer
|
|
139
358
|
* ------------------------------------------- */
|
|
140
359
|
const sanitizeEventTarget = (el, mode) => {
|
|
141
|
-
if (!el) return
|
|
360
|
+
if (!el || globalDisable) return
|
|
142
361
|
const effectiveMode = mode || resolveDefaultMode()
|
|
143
362
|
const sanitizeStr = makeSanitizeFn(effectiveMode)
|
|
144
|
-
// const UNSAFE_RE = /<(?:script|iframe|object|embed|form|input|button|link|meta)[^>]*>|on\w+=|javascript:/i
|
|
145
363
|
const UNSAFE_RE = /<(?:script|object|embed|form|input|button|link|meta)[^>]*>|on\w+=|javascript:/i
|
|
146
364
|
if (typeof el.value === 'string' && UNSAFE_RE.test(el.value)) {
|
|
147
365
|
const clean = sanitizeStr(el.value)
|
|
@@ -153,13 +371,21 @@ const sanitizeEventTarget = (el, mode) => {
|
|
|
153
371
|
}
|
|
154
372
|
}
|
|
155
373
|
|
|
156
|
-
/* ---------------------------------------------
|
|
157
|
-
|
|
374
|
+
/* ---------------------------------------------
|
|
375
|
+
* @deprecated Use sanitizeData(data, { mode: 'app' }) per call, or set
|
|
376
|
+
* config.mode at SpyneApp.init. Retained for backward compatibility.
|
|
377
|
+
* ------------------------------------------- */
|
|
378
|
+
const setSanitizeDataForceStrict = (bool = true) => {
|
|
379
|
+
console.warn('SPYNE DEPRECATION: setSanitizeDataForceStrict is deprecated. Pass { mode: "app" } to sanitizeData, or set config.mode in SpyneApp.init.')
|
|
380
|
+
forceStrict = !!bool
|
|
381
|
+
}
|
|
158
382
|
|
|
159
383
|
export {
|
|
160
384
|
sanitizeDataConfigure,
|
|
161
385
|
sanitizeData,
|
|
162
386
|
sanitizeDataForce,
|
|
387
|
+
sanitizeAttribute,
|
|
388
|
+
applyIframeHardening,
|
|
163
389
|
sanitizeEventTarget,
|
|
164
390
|
setSanitizeDataForceStrict
|
|
165
391
|
}
|
|
@@ -3,6 +3,18 @@ import DOMPurify from 'dompurify'
|
|
|
3
3
|
let _sanitizeHTML
|
|
4
4
|
let isConfigured = false
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Configures the template-layer sanitizer.
|
|
8
|
+
*
|
|
9
|
+
* Sanitization is always on. The three-level posture:
|
|
10
|
+
*
|
|
11
|
+
* mode: 'app' (default) — DOMPurify with its standard allowlist
|
|
12
|
+
* mode: 'richtext' — additionally admits iframe/link (+ sandbox attr)
|
|
13
|
+
* so CMS/authoring content survives templates
|
|
14
|
+
* strict: true — additionally registers a Trusted Types 'default'
|
|
15
|
+
* policy so DOM sinks route through sanitization
|
|
16
|
+
* disableSanitize: true — the only full passthrough; logs a security warning
|
|
17
|
+
*/
|
|
6
18
|
const sanitizeHTMLConfigure = (config = {}) => {
|
|
7
19
|
if (isConfigured) {
|
|
8
20
|
console.warn('sanitizeHTML is already configured. Reconfiguration is not allowed.')
|
|
@@ -10,20 +22,41 @@ const sanitizeHTMLConfigure = (config = {}) => {
|
|
|
10
22
|
}
|
|
11
23
|
|
|
12
24
|
const {
|
|
13
|
-
strict =
|
|
25
|
+
strict = false,
|
|
26
|
+
mode = 'app',
|
|
27
|
+
disableSanitize = false,
|
|
14
28
|
allowTargetAttr = true,
|
|
15
|
-
addNoopener = true
|
|
29
|
+
addNoopener = true,
|
|
30
|
+
debug = false,
|
|
31
|
+
iframes = {}
|
|
16
32
|
} = config
|
|
17
33
|
|
|
18
|
-
if (
|
|
34
|
+
if (disableSanitize === true) {
|
|
35
|
+
console.warn('SPYNE SECURITY WARNING: All HTML sanitization is DISABLED via config.disableSanitize. Do not use this setting in production.')
|
|
19
36
|
_sanitizeHTML = (html) => html
|
|
20
37
|
isConfigured = true
|
|
21
38
|
return
|
|
22
39
|
}
|
|
23
40
|
|
|
41
|
+
const isRichtext = ['richtext', 'authoring', 'development'].includes(mode)
|
|
42
|
+
|
|
43
|
+
// Iframe admission follows config.iframes.allow when set; otherwise
|
|
44
|
+
// the mode default (richtext yes, app no). The DOMPurify hook added by
|
|
45
|
+
// sanitize-data enforces src origin and sandbox policy on whatever
|
|
46
|
+
// is admitted here.
|
|
47
|
+
const allowIframe = iframes.allow !== undefined ? iframes.allow === true : isRichtext
|
|
48
|
+
|
|
24
49
|
const domPurifyConfig = {
|
|
25
50
|
RETURN_TRUSTED_TYPE: false,
|
|
26
|
-
|
|
51
|
+
ADD_TAGS: [
|
|
52
|
+
...(allowIframe ? ['iframe'] : []),
|
|
53
|
+
...(isRichtext ? ['link'] : [])
|
|
54
|
+
],
|
|
55
|
+
ADD_ATTR: [
|
|
56
|
+
...(allowTargetAttr ? ['target'] : []),
|
|
57
|
+
...(allowIframe ? ['sandbox'] : [])
|
|
58
|
+
],
|
|
59
|
+
...(allowIframe !== true && isRichtext ? { FORBID_TAGS: ['iframe'] } : {})
|
|
27
60
|
}
|
|
28
61
|
|
|
29
62
|
const sanitizeWithPolicy = (html) => DOMPurify.sanitize(html, domPurifyConfig)
|
|
@@ -63,14 +96,22 @@ const sanitizeHTMLConfigure = (config = {}) => {
|
|
|
63
96
|
})
|
|
64
97
|
}
|
|
65
98
|
|
|
66
|
-
|
|
67
|
-
const policy = window.trustedTypes.createPolicy('default', {
|
|
68
|
-
createHTML: (toEscape) => sanitizeWithPolicy(toEscape)
|
|
69
|
-
})
|
|
99
|
+
_sanitizeHTML = sanitizeWithPolicy
|
|
70
100
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
101
|
+
if (strict === true && window.trustedTypes) {
|
|
102
|
+
try {
|
|
103
|
+
const policy = window.trustedTypes.createPolicy('default', {
|
|
104
|
+
createHTML: (toEscape) => sanitizeWithPolicy(toEscape)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
_sanitizeHTML = (html) => policy.createHTML(html)
|
|
108
|
+
|
|
109
|
+
if (debug === true) {
|
|
110
|
+
console.log('SPYNE: Trusted Types default policy registered. Note that browser enforcement additionally requires the CSP header: require-trusted-types-for \'script\'.')
|
|
111
|
+
}
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.warn('SPYNE WARNING: A Trusted Types "default" policy already exists on this page. Falling back to direct sanitization.', err)
|
|
114
|
+
}
|
|
74
115
|
}
|
|
75
116
|
|
|
76
117
|
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
|
|
|
@@ -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
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { baseCoreMixins } from '../utils/mixins/base-core-mixins.js'
|
|
2
2
|
import { DomElementTemplate } from './dom-element-template.js'
|
|
3
3
|
import { deepMerge } from '../utils/deep-merge.js'
|
|
4
|
+
import { sanitizeAttribute, applyIframeHardening } from '../utils/sanitize-data.js'
|
|
4
5
|
import { is, defaultTo, pick, mapObjIndexed, forEachObjIndexed, pipe } from 'ramda'
|
|
5
6
|
|
|
6
7
|
class DomElement {
|
|
@@ -53,15 +54,33 @@ class DomElement {
|
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
setElAttrs(el, params) {
|
|
57
|
+
const testMode = this.testMode === true
|
|
56
58
|
const addAttributes = (val, key) => {
|
|
57
59
|
const addToDataset = (val, key) => { el.dataset[key] = val }
|
|
58
60
|
if (key === 'dataset') {
|
|
61
|
+
// Dataset values are inert text and are the framework's payload
|
|
62
|
+
// surface; they are applied as-is.
|
|
59
63
|
forEachObjIndexed(addToDataset, val)
|
|
60
|
-
|
|
61
|
-
el.setAttribute(key, val)
|
|
64
|
+
return
|
|
62
65
|
}
|
|
66
|
+
|
|
67
|
+
if (testMode !== true) {
|
|
68
|
+
const { allowed, value } = sanitizeAttribute(key, val, el.tagName)
|
|
69
|
+
if (allowed !== true) {
|
|
70
|
+
console.warn(`SPYNE WARNING: The attribute "${key}" was removed during sanitization.`, el)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
val = value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
el.setAttribute(key, val)
|
|
63
77
|
}
|
|
64
78
|
this.getProp('attrs').forEach(addAttributes)
|
|
79
|
+
|
|
80
|
+
if (testMode !== true) {
|
|
81
|
+
applyIframeHardening(el)
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
return el
|
|
66
85
|
}
|
|
67
86
|
|