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.
@@ -4,7 +4,24 @@ import { SpyneAppProperties } from './spyne-app-properties.js'
4
4
 
5
5
  let _sanitizeData
6
6
  let isConfigured = false
7
- let forceStrict = false // legacy compatibility toggle
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
- SAFE_URL_PATTERN: /^(https?:|mailto:|tel:|data:image\/)/i
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 from SpyneAppProperties
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
- const mode = SpyneAppProperties?.mode || process?.env?.NODE_ENV
43
- if (mode === 'authoring' || mode === 'development') return 'richtext'
165
+ if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development') {
166
+ return 'richtext'
167
+ }
44
168
  } catch (e) {
45
- // Fallback: assume production strict mode
169
+ // no build-time env available
46
170
  }
171
+
47
172
  return 'app'
48
173
  }
49
174
 
50
175
  /* --------------------------------------------- */
51
- function makeSanitizeFn(mode = 'app') {
52
- if (process?.env?.NODE_ENV === 'development') {
53
- mode = 'richtext'
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
- return (html) => {
59
- let clean = DOMPurify.sanitize(html, cfg)
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
- // In richtext mode, enforce safe URL schemes for href/src
62
- if (mode === 'richtext') {
63
- clean = clean.replace(/<(a|img)\b([^>]+?)>/gi, (match) => {
64
- return match.replace(/\b(href|src)="([^"]*)"/gi, (m, attr, val) => {
65
- return m
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 (kept for compatibility)
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
- if (disableSanitize) return data
104
- return processFactory(effectiveMode)(data)
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
- const setSanitizeDataForceStrict = (bool = true) => { forceStrict = !!bool }
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 = true,
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 (!strict) {
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
- ADD_ATTR: allowTargetAttr ? ['target'] : []
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
- if (window.trustedTypes) {
67
- const policy = window.trustedTypes.createPolicy('default', {
68
- createHTML: (toEscape) => sanitizeWithPolicy(toEscape)
69
- })
99
+ _sanitizeHTML = sanitizeWithPolicy
70
100
 
71
- _sanitizeHTML = (html) => policy.createHTML(html)
72
- } else {
73
- _sanitizeHTML = (html) => sanitizeWithPolicy(html)
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 key;
138
+ return val; // FIX 2: both tiers return the stored value
116
139
  }
117
140
 
118
141
  getProp(key) {
119
- if (_config.ephemeralProps.hasOwnProperty(key)) {
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 _config?.tmpProps?.[key];
153
+ return undefined;
125
154
  }
126
155
 
127
156
  createTempProp(value) {
128
157
  const key = random6Chars();
129
- this.setProp(key, value, true); // isTemp = 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
- } else {
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