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.
@@ -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 forceStrict = false // legacy compatibility toggle
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
- SAFE_URL_PATTERN: /^(https?:|mailto:|tel:|data:image\/)/i
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 from SpyneAppProperties
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
- const mode = SpyneAppProperties?.mode || process?.env?.NODE_ENV
43
- if (mode === 'authoring' || mode === 'development') return 'richtext'
218
+ if (typeof process !== 'undefined' && process?.env?.NODE_ENV === 'development') {
219
+ return 'richtext'
220
+ }
44
221
  } catch (e) {
45
- // Fallback: assume production strict mode
222
+ // no build-time env available
46
223
  }
224
+
47
225
  return 'app'
48
226
  }
49
227
 
50
228
  /* --------------------------------------------- */
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
229
+ const buildModeConfig = (mode) => {
230
+ const base = mode === 'richtext' ? SAFE_FOR_RICH_TEXT : SAFE_FOR_APP
231
+ const allowIframe = iframeAllowedForMode(mode)
57
232
 
58
- return (html) => {
59
- let clean = DOMPurify.sanitize(html, cfg)
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
- // 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
- })
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 (kept for compatibility)
261
+ * Configure once
75
262
  * ------------------------------------------- */
76
263
  const sanitizeDataConfigure = (config = {}) => {
77
264
  if (isConfigured) {
78
- console.warn('sanitizeData is already configured. Reconfiguration is not allowed.')
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
- if (disableSanitize) return data
104
- return processFactory(effectiveMode)(data)
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
- const setSanitizeDataForceStrict = (bool = true) => { forceStrict = !!bool }
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
- console.warn('sanitizeHTML is already configured. Reconfiguration is not allowed.')
22
+ spyneWarn('sanitizeHTML is already configured. Reconfiguration is not allowed.')
9
23
  return
10
24
  }
11
25
 
12
26
  const {
13
- strict = true,
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 (!strict) {
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
- ADD_ATTR: allowTargetAttr ? ['target'] : []
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
- if (window.trustedTypes) {
67
- const policy = window.trustedTypes.createPolicy('default', {
68
- createHTML: (toEscape) => sanitizeWithPolicy(toEscape)
69
- })
105
+ _sanitizeHTML = sanitizeWithPolicy
70
106
 
71
- _sanitizeHTML = (html) => policy.createHTML(html)
72
- } else {
73
- _sanitizeHTML = (html) => sanitizeWithPolicy(html)
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 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
 
@@ -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
  }