spyne 0.22.4 → 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.
@@ -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
 
@@ -85,6 +85,10 @@ export class ViewStreamBroadcaster {
85
85
  const data = {}// convertDomStringMapToObj(q.dataset);
86
86
  data.payload = convertDomStringMapToObj(q.dataset)
87
87
  data.payload = omit(['channel'], data.payload)
88
+ if (data.channel === 'ROUTE') {
89
+ data.payload.endPreventDefault = true
90
+ }
91
+
88
92
  data.channel = channel
89
93
  // payload needs vsid# to pass verification
90
94
 
@@ -1,7 +1,7 @@
1
1
  //const { expect, assert } = require('chai')
2
2
  //const { ChannelFetch } = require('../../spyne/channels/channel-fetch-class')
3
3
  const { expect, assert } = require('chai')
4
- import { ChannelFetch } from '../../spyne/spyne';
4
+ import { ChannelFetch, SpyneApp } from '../../spyne/spyne';
5
5
 
6
6
  describe('should test ChannelFetch', () => {
7
7
  const mapFn = (p) => {
@@ -95,4 +95,91 @@ describe('should test ChannelFetch', () => {
95
95
  const mapTypeUndefined = ChannelFetch.validateMapMethod(props, 'MY_TEST_NAME_2', true)
96
96
  // console.log('map methods is not of correct type added and is valid4 ',{mapTypeUndefined}) ; expect(mapTypeUndefined).to.be.false;
97
97
  })
98
+
99
+ describe('conformed error action', () => {
100
+ const createPayloadContext = () => {
101
+ const context = {
102
+ props: { name: channelName },
103
+ sentActions: [],
104
+ createChannelPayloadItem(payload, action) {
105
+ this.sentActions.push({ payload, action })
106
+ }
107
+ }
108
+ return context
109
+ }
110
+
111
+ it('should register the generic error action', () => {
112
+ const context = { props: {} }
113
+ const actionsArr = ChannelFetch.prototype.addRegisteredActions.call(context)
114
+
115
+ expect(actionsArr).to.include('CHANNEL_ERROR_EVENT')
116
+ expect(actionsArr).to.include('CHANNEL_RESPONSE_EVENT')
117
+ })
118
+
119
+ it('should register the instance-derived error action alongside the response action', () => {
120
+ const context = {
121
+ props: {
122
+ extendedActionsArr: [
123
+ `${channelName}_RESPONSE_EVENT`,
124
+ `${channelName}_ERROR_EVENT`,
125
+ [`${channelName}_REQUEST_EVENT`, 'onFetchUpdate']
126
+ ]
127
+ }
128
+ }
129
+ const actionsArr = ChannelFetch.prototype.addRegisteredActions.call(context)
130
+
131
+ expect(actionsArr).to.include(`${channelName}_ERROR_EVENT`)
132
+ expect(actionsArr).to.include(`${channelName}_RESPONSE_EVENT`)
133
+ })
134
+
135
+ it('should emit the response action for a successful fetch payload', () => {
136
+ const context = createPayloadContext()
137
+ const streamItem = { id: 1, title: 'foo' }
138
+
139
+ ChannelFetch.prototype.onFetchReturned.call(context, streamItem)
140
+
141
+ expect(context.sentActions[0].action).to.equal(`${channelName}_RESPONSE_EVENT`)
142
+ expect(context.sentActions[0].payload).to.deep.equal(streamItem)
143
+ })
144
+
145
+ it('should emit the error action for a conformed fetch error payload', () => {
146
+ const context = createPayloadContext()
147
+ const streamItem = {
148
+ isChannelFetchError: true,
149
+ isError: true,
150
+ error: true,
151
+ errorType: 'FETCH_HTTP_ERROR',
152
+ message: 'Fetch request failed with status 404 Not Found',
153
+ status: 404,
154
+ statusText: 'Not Found',
155
+ url: 'https://jsonplaceholder.typicode.com/posts/does-not-exist'
156
+ }
157
+
158
+ ChannelFetch.prototype.onFetchReturned.call(context, streamItem)
159
+
160
+ expect(context.sentActions[0].action).to.equal(`${channelName}_ERROR_EVENT`)
161
+ expect(context.sentActions[0].payload).to.deep.equal(streamItem)
162
+ })
163
+
164
+ it('should not route a payload to the error action without the discriminator flag', () => {
165
+ const context = createPayloadContext()
166
+ const streamItem = { isError: true, message: 'data that merely resembles an error' }
167
+
168
+ ChannelFetch.prototype.onFetchReturned.call(context, streamItem)
169
+
170
+ expect(context.sentActions[0].action).to.equal(`${channelName}_RESPONSE_EVENT`)
171
+ })
172
+
173
+ it('should add the derived error action to channelActions on a constructed instance', () => {
174
+ SpyneApp.init({})
175
+
176
+ const instanceChannelName = 'CHANNEL_FETCH_ERROR_ACTION_TEST'
177
+ const fetchChannel = new ChannelFetch(instanceChannelName, { url, pause: true })
178
+
179
+ expect(fetchChannel.channelActions).to.have.property(`${instanceChannelName}_ERROR_EVENT`)
180
+ expect(fetchChannel.channelActions).to.have.property(`${instanceChannelName}_RESPONSE_EVENT`)
181
+ expect(fetchChannel.channelActions).to.have.property(`${instanceChannelName}_REQUEST_EVENT`)
182
+ expect(fetchChannel.channelActions).to.have.property('CHANNEL_ERROR_EVENT')
183
+ })
184
+ })
98
185
  })
@@ -49,9 +49,11 @@ describe('ChannelFetchUtil Tests', () => {
49
49
 
50
50
  describe('stringify body method', () => {
51
51
  it('should convert body object to string', () => {
52
- let bodyStr = ChannelFetchUtil.stringifyBodyIfItExists(propsPost)
53
- bodyStr = R.omit(['mapFn'], bodyStr)
52
+ const result = ChannelFetchUtil.stringifyBodyIfItExists(propsPost)
53
+ const bodyStr = R.omit(['mapFn', 'headers'], result)
54
+
54
55
  expect(bodyStr).to.deep.equal(propsStringified)
56
+ expect(result.headers.get('Content-Type')).to.equal('application/json')
55
57
  })
56
58
 
57
59
  it('should not parse body when its a string', () => {
@@ -127,4 +129,44 @@ describe('ChannelFetchUtil Tests', () => {
127
129
  expect(channelFetchUtil.mapFn).to.be.a('function')
128
130
  })
129
131
  })
132
+
133
+ describe('conformed fetch error payload', () => {
134
+ const metadata = {
135
+ channelName: 'CHANNEL_MY_FETCHED_DATA',
136
+ url,
137
+ responseType: 'json'
138
+ }
139
+
140
+ it('should conform an http error into a flat payload with the discriminator flag', () => {
141
+ const httpError = ChannelFetchUtil.createFetchError({
142
+ errorType: 'FETCH_HTTP_ERROR',
143
+ message: 'Fetch request failed with status 404 Not Found',
144
+ metadata: { ...metadata, status: 404, statusText: 'Not Found', ok: false },
145
+ rawBody: '{"error":"not found"}'
146
+ })
147
+
148
+ const errorPayload = ChannelFetchUtil.createFetchErrorPayload(httpError, metadata)
149
+
150
+ expect(errorPayload.isChannelFetchError).to.be.true
151
+ expect(errorPayload.isError).to.be.true
152
+ expect(errorPayload.errorType).to.equal('FETCH_HTTP_ERROR')
153
+ expect(errorPayload.status).to.equal(404)
154
+ expect(errorPayload.statusText).to.equal('Not Found')
155
+ expect(errorPayload.url).to.equal(url)
156
+ expect(errorPayload.channelName).to.equal('CHANNEL_MY_FETCHED_DATA')
157
+ expect(errorPayload.rawBodyPreview).to.equal('{"error":"not found"}')
158
+ })
159
+
160
+ it('should conform a network error using fallback metadata and error type', () => {
161
+ const networkError = new TypeError('Failed to fetch')
162
+
163
+ const errorPayload = ChannelFetchUtil.createFetchErrorPayload(networkError, metadata)
164
+
165
+ expect(errorPayload.isChannelFetchError).to.be.true
166
+ expect(errorPayload.errorType).to.equal('FETCH_UNKNOWN_ERROR')
167
+ expect(errorPayload.message).to.equal('Failed to fetch')
168
+ expect(errorPayload.url).to.equal(url)
169
+ expect(errorPayload.status).to.be.undefined
170
+ })
171
+ })
130
172
  })
@@ -1,10 +1,290 @@
1
- const {
2
- expect,
3
- assert
4
- } = require('chai')
5
-
6
- describe('root test', () => {
7
- it('should run shell tests', () => {
8
- return true
1
+ const { expect } = require('chai')
2
+ import { SpyneApp, SpyneAppProperties } from '../../spyne/spyne'
3
+ import sanitizeData, { sanitizeAttribute } from '../../spyne/utils/sanitize-data'
4
+ import sanitizeHTML from '../../spyne/utils/sanitize-html'
5
+ import { DomElement } from '../../spyne/views/dom-element'
6
+ import { DomElementTemplate } from '../../spyne/views/dom-element-template'
7
+
8
+ describe('sanitization security', () => {
9
+ before(() => {
10
+ // Idempotent: configures the sanitizers if no other suite has already.
11
+ SpyneApp.init({})
12
+ })
13
+
14
+ describe('default posture', () => {
15
+ it('should default SpyneAppProperties.mode to app', () => {
16
+ expect(SpyneAppProperties.mode).to.equal('app')
17
+ })
18
+
19
+ it('should sanitize template HTML even when strict is false', () => {
20
+ const dirty = '<img src="x" onerror="alert(1)"><script>bad()</script><b>keep</b>'
21
+ const clean = String(sanitizeHTML(dirty))
22
+
23
+ expect(clean).to.not.include('onerror')
24
+ expect(clean).to.not.include('<script')
25
+ expect(clean).to.include('<b>keep</b>')
26
+ })
27
+ })
28
+
29
+ describe('sanitizeAttribute', () => {
30
+ it('should block event-handler attributes', () => {
31
+ expect(sanitizeAttribute('onclick', 'evil()').allowed).to.be.false
32
+ expect(sanitizeAttribute('ONERROR', 'evil()').allowed).to.be.false
33
+ })
34
+
35
+ it('should block javascript: URIs on URL attributes', () => {
36
+ expect(sanitizeAttribute('href', 'javascript:alert(1)').allowed).to.be.false
37
+ expect(sanitizeAttribute('src', 'JaVaScRiPt:alert(1)').allowed).to.be.false
38
+ expect(sanitizeAttribute('formaction', 'vbscript:x').allowed).to.be.false
39
+ })
40
+
41
+ it('should block whitespace-obfuscated schemes', () => {
42
+ expect(sanitizeAttribute('href', 'jav\tascript:alert(1)').allowed).to.be.false
43
+ expect(sanitizeAttribute('href', ' javascript:alert(1)').allowed).to.be.false
44
+ expect(sanitizeAttribute('href', 'java\nscript:alert(1)').allowed).to.be.false
45
+ })
46
+
47
+ it('should allow safe and relative URIs', () => {
48
+ expect(sanitizeAttribute('href', 'https://example.com').allowed).to.be.true
49
+ expect(sanitizeAttribute('href', '/page/one').allowed).to.be.true
50
+ expect(sanitizeAttribute('href', '#section').allowed).to.be.true
51
+ expect(sanitizeAttribute('href', 'mailto:a@b.com').allowed).to.be.true
52
+ expect(sanitizeAttribute('src', 'data:image/png;base64,AA==').allowed).to.be.true
53
+ })
54
+
55
+ it('should block data URIs that are not images', () => {
56
+ expect(sanitizeAttribute('src', 'data:text/html,<script>x</script>').allowed).to.be.false
57
+ })
58
+
59
+ it('should sanitize srcdoc as an HTML document', () => {
60
+ const result = sanitizeAttribute('srcdoc', '<script>bad()</script><p>ok</p>')
61
+
62
+ expect(result.allowed).to.be.true
63
+ expect(result.value).to.not.include('<script')
64
+ expect(result.value).to.include('<p>ok</p>')
65
+ })
66
+
67
+ it('should pass non-URL attributes through untouched', () => {
68
+ const result = sanitizeAttribute('id', 'my-id')
69
+
70
+ expect(result.allowed).to.be.true
71
+ expect(result.value).to.equal('my-id')
72
+ })
73
+ })
74
+
75
+ describe('sanitizeData modes', () => {
76
+ it('should strip scripts and iframes in app mode', () => {
77
+ const clean = sanitizeData('<script>bad()</script><iframe src="https://x.com"></iframe><b>keep</b>', { mode: 'app' })
78
+
79
+ expect(clean).to.not.include('<script')
80
+ expect(clean).to.not.include('<iframe')
81
+ expect(clean).to.include('<b>keep</b>')
82
+ })
83
+
84
+ it('should sanitize nested object and array values', () => {
85
+ const data = {
86
+ title: '<script>bad()</script>ok',
87
+ items: ['<b>fine</b>', { deep: '<script>x</script>deep-ok' }]
88
+ }
89
+ const clean = sanitizeData(data, { mode: 'app' })
90
+
91
+ expect(clean.title).to.equal('ok')
92
+ expect(clean.items[0]).to.equal('<b>fine</b>')
93
+ expect(clean.items[1].deep).to.equal('deep-ok')
94
+ })
95
+
96
+ it('should keep iframes in richtext mode with a forced sandbox', () => {
97
+ const clean = sanitizeData('<iframe src="https://example.com/embed"></iframe>', { mode: 'richtext' })
98
+
99
+ expect(clean).to.include('<iframe')
100
+ expect(clean).to.include('sandbox=')
101
+ })
102
+
103
+ it('should remove non-https iframe src in richtext mode', () => {
104
+ const clean = sanitizeData('<iframe src="http://example.com/embed"></iframe>', { mode: 'richtext' })
105
+
106
+ expect(clean).to.include('<iframe')
107
+ expect(clean).to.not.include('src=')
108
+ })
109
+
110
+ it('should drop javascript: hrefs in richtext content', () => {
111
+ const clean = sanitizeData('<a href="javascript:alert(1)">x</a>', { mode: 'richtext' })
112
+
113
+ expect(clean).to.not.include('javascript:')
114
+ })
115
+
116
+ it('should return data untouched with per-call disableSanitize', () => {
117
+ const raw = '<script>kept-by-request()</script>'
118
+
119
+ expect(sanitizeData(raw, { disableSanitize: true })).to.equal(raw)
120
+ })
121
+ })
122
+
123
+ describe('iframe policy', () => {
124
+ it('should allow iframes in app mode with iframes.allow', () => {
125
+ const clean = sanitizeData('<iframe src="https://example.com/embed"></iframe>', {
126
+ mode: 'app',
127
+ iframes: { allow: true }
128
+ })
129
+
130
+ expect(clean).to.include('<iframe')
131
+ expect(clean).to.include('sandbox=')
132
+ })
133
+
134
+ it('should strip iframes from richtext mode with iframes.allow false', () => {
135
+ const clean = sanitizeData('<iframe src="https://example.com/embed"></iframe><b>keep</b>', {
136
+ mode: 'richtext',
137
+ iframes: { allow: false }
138
+ })
139
+
140
+ expect(clean).to.not.include('<iframe')
141
+ expect(clean).to.include('<b>keep</b>')
142
+ })
143
+
144
+ it('should enforce the origin allowlist on iframe src', () => {
145
+ const opts = {
146
+ mode: 'richtext',
147
+ iframes: { allowedOrigins: ['https://www.youtube.com'] }
148
+ }
149
+
150
+ const allowed = sanitizeData('<iframe src="https://www.youtube.com/embed/abc"></iframe>', opts)
151
+ const blocked = sanitizeData('<iframe src="https://evil.example.com/embed"></iframe>', opts)
152
+
153
+ expect(allowed).to.include('src="https://www.youtube.com/embed/abc"')
154
+ expect(blocked).to.include('<iframe')
155
+ expect(blocked).to.not.include('src=')
156
+ })
157
+
158
+ it('should respect a custom sandbox value and sandbox false', () => {
159
+ const custom = sanitizeData('<iframe src="https://example.com/e"></iframe>', {
160
+ mode: 'richtext',
161
+ iframes: { sandbox: 'allow-scripts' }
162
+ })
163
+ const disabled = sanitizeData('<iframe src="https://example.com/e"></iframe>', {
164
+ mode: 'richtext',
165
+ iframes: { sandbox: false }
166
+ })
167
+
168
+ expect(custom).to.include('sandbox="allow-scripts"')
169
+ expect(disabled).to.not.include('sandbox')
170
+ })
171
+
172
+ it('should keep an author-specified sandbox attribute', () => {
173
+ const clean = sanitizeData('<iframe src="https://example.com/e" sandbox="allow-forms"></iframe>', {
174
+ mode: 'richtext'
175
+ })
176
+
177
+ expect(clean).to.include('sandbox="allow-forms"')
178
+ })
179
+
180
+ it('should apply iframe policy on the DomElement attribute channel', () => {
181
+ const el = new DomElement({
182
+ tagName: 'iframe',
183
+ attributes: { src: 'https://example.com/embed' }
184
+ }).render()
185
+
186
+ expect(el.getAttribute('src')).to.equal('https://example.com/embed')
187
+ expect(el.getAttribute('sandbox')).to.be.a('string')
188
+ })
189
+
190
+ it('should block non-https iframe src on the DomElement attribute channel', () => {
191
+ const el = new DomElement({
192
+ tagName: 'iframe',
193
+ attributes: { src: 'http://example.com/embed' }
194
+ }).render()
195
+
196
+ expect(el.hasAttribute('src')).to.be.false
197
+ })
198
+
199
+ it('should allow same-origin relative iframe src', () => {
200
+ const clean = sanitizeData('<iframe src="/static/iframes/tool/index.html"></iframe>', {
201
+ mode: 'richtext'
202
+ })
203
+
204
+ expect(clean).to.include('src="/static/iframes/tool/index.html"')
205
+ })
206
+
207
+ it('should allow same-origin absolute iframe src regardless of scheme', () => {
208
+ const sameOriginUrl = `${window.location.origin}/static/iframes/tool/index.html`
209
+ const clean = sanitizeData(`<iframe src="${sameOriginUrl}"></iframe>`, {
210
+ mode: 'richtext'
211
+ })
212
+
213
+ expect(clean).to.include(`src="${sameOriginUrl}"`)
214
+ })
215
+
216
+ it('should allow http on localhost hosts as a secure context', () => {
217
+ const clean = sanitizeData('<iframe src="http://localhost:8123/dev-tool.html"></iframe>', {
218
+ mode: 'richtext'
219
+ })
220
+
221
+ expect(clean).to.include('src="http://localhost:8123/dev-tool.html"')
222
+ })
223
+
224
+ it('should allow same-origin src even when an origin allowlist is set', () => {
225
+ const clean = sanitizeData('<iframe src="/local/tool.html"></iframe>', {
226
+ mode: 'richtext',
227
+ iframes: { allowedOrigins: ['https://www.youtube.com'] }
228
+ })
229
+
230
+ expect(clean).to.include('src="/local/tool.html"')
231
+ })
232
+ })
233
+
234
+ describe('DomElement attribute guard', () => {
235
+ it('should drop unsafe attribute values and keep safe ones', () => {
236
+ const el = new DomElement({
237
+ tagName: 'a',
238
+ attributes: {
239
+ href: 'javascript:alert(1)',
240
+ onclick: 'evil()',
241
+ id: 'safe-id',
242
+ title: 'safe title'
243
+ }
244
+ }).render()
245
+
246
+ expect(el.hasAttribute('href')).to.be.false
247
+ expect(el.hasAttribute('onclick')).to.be.false
248
+ expect(el.getAttribute('id')).to.equal('safe-id')
249
+ expect(el.getAttribute('title')).to.equal('safe title')
250
+ })
251
+
252
+ it('should keep safe URL attributes', () => {
253
+ const el = new DomElement({
254
+ tagName: 'a',
255
+ attributes: { href: 'https://example.com/page' }
256
+ }).render()
257
+
258
+ expect(el.getAttribute('href')).to.equal('https://example.com/page')
259
+ })
260
+
261
+ it('should apply dataset values untouched', () => {
262
+ const el = new DomElement({
263
+ tagName: 'div',
264
+ attributes: { dataset: { pageId: 'page-1' } }
265
+ }).render()
266
+
267
+ expect(el.dataset.pageId).to.equal('page-1')
268
+ })
269
+ })
270
+
271
+ describe('DomElementTemplate output', () => {
272
+ it('should sanitize renderToString output', () => {
273
+ const tmpl = new DomElementTemplate('<b>{{word}}</b><script>bad()</script>', { word: 'hello' })
274
+ const html = tmpl.renderToString()
275
+
276
+ expect(html).to.not.include('<script')
277
+ expect(html).to.include('<b>hello</b>')
278
+ })
279
+
280
+ it('should sanitize renderDocFrag output', () => {
281
+ const tmpl = new DomElementTemplate('<p>{{word}}</p><img src="x" onerror="alert(1)">', { word: 'hi' })
282
+ const frag = tmpl.renderDocFrag()
283
+ const div = document.createElement('div')
284
+ div.appendChild(frag)
285
+
286
+ expect(div.innerHTML).to.not.include('onerror')
287
+ expect(div.innerHTML).to.include('<p>hi</p>')
288
+ })
9
289
  })
10
290
  })