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.
- 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 +16 -7
- package/src/spyne/spyne-app.js +6 -3
- package/src/spyne/utils/channel-fetch-util.js +238 -92
- package/src/spyne/utils/sanitize-data.js +260 -34
- package/src/spyne/utils/sanitize-html.js +109 -16
- package/src/spyne/utils/spyne-app-properties.js +35 -6
- package/src/spyne/utils/spyne-trait.js +5 -2
- package/src/spyne/views/dom-element-template.js +11 -4
- package/src/spyne/views/dom-element.js +21 -2
- package/src/spyne/views/view-stream-broadcaster.js +4 -0
- 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
|
@@ -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
|
|
|
@@ -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
|
-
|
|
53
|
-
bodyStr = R.omit(['mapFn'],
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
})
|