adapt-schemas 3.1.0 → 3.1.2
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/Schema.js +14 -20
- package/lib/Schemas.js +36 -20
- package/package.json +1 -1
- package/test.js +335 -1
package/lib/Schema.js
CHANGED
|
@@ -45,12 +45,6 @@ class Schema extends EventEmitter {
|
|
|
45
45
|
*/
|
|
46
46
|
this.enableCache = enableCache
|
|
47
47
|
|
|
48
|
-
/**
|
|
49
|
-
* List of extensions for this schema
|
|
50
|
-
* @type {string[]}
|
|
51
|
-
*/
|
|
52
|
-
this.extensions = []
|
|
53
|
-
|
|
54
48
|
/**
|
|
55
49
|
* File path to the schema
|
|
56
50
|
* @type {string}
|
|
@@ -195,14 +189,13 @@ class Schema extends EventEmitter {
|
|
|
195
189
|
parent = parent.getParent()
|
|
196
190
|
}
|
|
197
191
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
192
|
+
const extensions = this.schemaLibrary.schemaExtensions?.[this.name] ?? []
|
|
193
|
+
extensions.forEach(s => {
|
|
194
|
+
const applyPatch = typeof extensionFilter === 'function' ? extensionFilter(s) : applyExtensions !== false
|
|
195
|
+
if (!applyPatch) return
|
|
196
|
+
const extSchema = this.schemaLibrary.getSchema(s)
|
|
197
|
+
this.patch(built, extSchema.raw, { extendAnnotations: false })
|
|
198
|
+
})
|
|
206
199
|
|
|
207
200
|
this.built = built
|
|
208
201
|
|
|
@@ -355,13 +348,14 @@ class Schema extends EventEmitter {
|
|
|
355
348
|
}
|
|
356
349
|
|
|
357
350
|
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
351
|
+
* Discards any cached build/compiled state. Called when an extension is added
|
|
352
|
+
* or removed against this schema so the next `build()` reflects the change.
|
|
360
353
|
*/
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
354
|
+
invalidateBuild () {
|
|
355
|
+
this.built = undefined
|
|
356
|
+
this.compiled = undefined
|
|
357
|
+
this.compiledWithDefaults = undefined
|
|
358
|
+
this.lastBuildTime = undefined
|
|
365
359
|
}
|
|
366
360
|
|
|
367
361
|
/**
|
package/lib/Schemas.js
CHANGED
|
@@ -42,7 +42,7 @@ class Schemas extends EventEmitter {
|
|
|
42
42
|
this.schemas = {}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
45
|
+
* Maps base schema name to the names of schemas that extend it (via $patch).
|
|
46
46
|
* @type {Object<string, string[]>}
|
|
47
47
|
*/
|
|
48
48
|
this.schemaExtensions = {}
|
|
@@ -110,9 +110,16 @@ class Schemas extends EventEmitter {
|
|
|
110
110
|
* Empties the schema registry (with the exception of the base schema)
|
|
111
111
|
*/
|
|
112
112
|
resetSchemaRegistry () {
|
|
113
|
+
// Remove previously registered schemas from AJV instances
|
|
114
|
+
for (const name of Object.keys(this.schemas)) {
|
|
115
|
+
for (const v of [this.validator, this.validatorWithDefaults]) {
|
|
116
|
+
if (v.getSchema(name)) v.removeSchema(name)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
113
119
|
this.schemas = {
|
|
114
120
|
base: this.createSchema(path.resolve(__dirname, BASE_SCHEMA_PATH), { enableCache: true })
|
|
115
121
|
}
|
|
122
|
+
this.schemaExtensions = {}
|
|
116
123
|
this.emit('reset')
|
|
117
124
|
}
|
|
118
125
|
|
|
@@ -224,7 +231,15 @@ class Schemas extends EventEmitter {
|
|
|
224
231
|
}
|
|
225
232
|
|
|
226
233
|
this.schemas[schema.name] = schema
|
|
227
|
-
|
|
234
|
+
|
|
235
|
+
// AJV's sync compile() can only resolve $refs against schemas in its internal store to allow `$ref: "schemaName`"
|
|
236
|
+
// Note we only add simple schemas (no $merge/$patch)
|
|
237
|
+
if (!schema.raw.$merge && !schema.raw.$patch) {
|
|
238
|
+
for (const v of [this.validator, this.validatorWithDefaults]) {
|
|
239
|
+
if (v.getSchema(schema.name)) v.removeSchema(schema.name)
|
|
240
|
+
v.addSchema(schema.raw, schema.name)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
228
243
|
|
|
229
244
|
if (schema.raw.$patch) {
|
|
230
245
|
this.extendSchema(schema.raw.$patch?.source?.$ref, schema.name)
|
|
@@ -239,13 +254,16 @@ class Schemas extends EventEmitter {
|
|
|
239
254
|
* @param {string} name Schema name to deregister
|
|
240
255
|
*/
|
|
241
256
|
deregisterSchema (name) {
|
|
242
|
-
|
|
243
|
-
|
|
257
|
+
delete this.schemas[name]
|
|
258
|
+
for (const v of [this.validator, this.validatorWithDefaults]) {
|
|
259
|
+
if (v.getSchema(name)) v.removeSchema(name)
|
|
244
260
|
}
|
|
245
|
-
|
|
246
|
-
Object.entries(this.schemaExtensions)
|
|
261
|
+
delete this.schemaExtensions[name]
|
|
262
|
+
for (const [base, extensions] of Object.entries(this.schemaExtensions)) {
|
|
263
|
+
if (!extensions.includes(name)) continue
|
|
247
264
|
this.schemaExtensions[base] = extensions.filter(s => s !== name)
|
|
248
|
-
|
|
265
|
+
this.schemas[base]?.invalidateBuild()
|
|
266
|
+
}
|
|
249
267
|
this.emit('schemaDeregistered', name)
|
|
250
268
|
}
|
|
251
269
|
|
|
@@ -266,9 +284,6 @@ class Schemas extends EventEmitter {
|
|
|
266
284
|
...options
|
|
267
285
|
})
|
|
268
286
|
|
|
269
|
-
this.schemaExtensions?.[schema.name]?.forEach(s => schema.addExtension(s))
|
|
270
|
-
delete this.schemaExtensions?.[schema.name]
|
|
271
|
-
|
|
272
287
|
return schema.load()
|
|
273
288
|
}
|
|
274
289
|
|
|
@@ -278,14 +293,12 @@ class Schemas extends EventEmitter {
|
|
|
278
293
|
* @param {string} extSchemaName The name of the schema to extend with
|
|
279
294
|
*/
|
|
280
295
|
extendSchema (baseSchemaName, extSchemaName) {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (!this.schemaExtensions[baseSchemaName]) {
|
|
286
|
-
this.schemaExtensions[baseSchemaName] = []
|
|
287
|
-
}
|
|
296
|
+
if (!this.schemaExtensions[baseSchemaName]) {
|
|
297
|
+
this.schemaExtensions[baseSchemaName] = []
|
|
298
|
+
}
|
|
299
|
+
if (!this.schemaExtensions[baseSchemaName].includes(extSchemaName)) {
|
|
288
300
|
this.schemaExtensions[baseSchemaName].push(extSchemaName)
|
|
301
|
+
this.schemas[baseSchemaName]?.invalidateBuild()
|
|
289
302
|
}
|
|
290
303
|
this.emit('schemaExtended', baseSchemaName, extSchemaName)
|
|
291
304
|
}
|
|
@@ -294,7 +307,7 @@ class Schemas extends EventEmitter {
|
|
|
294
307
|
* Retrieves the specified schema
|
|
295
308
|
* @param {string} schemaName The name of the schema to return
|
|
296
309
|
* @param {Object} options
|
|
297
|
-
* @param {boolean} options.useCache
|
|
310
|
+
* @param {boolean} options.useCache When false, builds into a fresh isolated Schema instance so per-call options (extensionFilter, applyExtensions) don't mutate the registry's build state (default: true)
|
|
298
311
|
* @param {boolean} options.compile Compile the schema (default: true)
|
|
299
312
|
* @param {boolean} options.applyExtensions Apply extension schemas (default: true)
|
|
300
313
|
* @param {function} options.extensionFilter Filter function for extensions
|
|
@@ -305,7 +318,10 @@ class Schemas extends EventEmitter {
|
|
|
305
318
|
if (!schema) {
|
|
306
319
|
throw new SchemaError('MISSING_SCHEMA', `Schema '${schemaName}' not found`, { schemaName })
|
|
307
320
|
}
|
|
308
|
-
return schema.build(options)
|
|
321
|
+
if (options.useCache !== false) return schema.build(options)
|
|
322
|
+
// build into a fresh instance so per-call options (e.g. extensionFilter) don't mutate the registry
|
|
323
|
+
const isolated = this.createSchema(schema.filePath, { enableCache: false })
|
|
324
|
+
return isolated.build(options)
|
|
309
325
|
}
|
|
310
326
|
|
|
311
327
|
/**
|
|
@@ -347,7 +363,7 @@ class Schemas extends EventEmitter {
|
|
|
347
363
|
return Object.entries(this.schemas).reduce((info, [name, schema]) => {
|
|
348
364
|
info[name] = {
|
|
349
365
|
filePath: schema.filePath,
|
|
350
|
-
extensions:
|
|
366
|
+
extensions: this.schemaExtensions[name] ?? [],
|
|
351
367
|
hasParent: !!schema.raw?.$merge?.source?.$ref,
|
|
352
368
|
isPatch: !!schema.raw?.$patch
|
|
353
369
|
}
|
package/package.json
CHANGED
package/test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Test script for the Adapt Schema Library
|
|
3
3
|
*/
|
|
4
|
-
import Schemas from './index.js'
|
|
4
|
+
import Schemas, { XSSDefaults } from './index.js'
|
|
5
5
|
import path from 'path'
|
|
6
6
|
import { fileURLToPath } from 'url'
|
|
7
7
|
import fs from 'fs/promises'
|
|
@@ -222,6 +222,35 @@ async function setupTestSchemas () {
|
|
|
222
222
|
JSON.stringify(languagePickerConfigSchema, null, 2)
|
|
223
223
|
)
|
|
224
224
|
|
|
225
|
+
// Schema used for XSS sanitization tests — string fields at top level
|
|
226
|
+
// and nested inside an object so sanitise() recursion is exercised.
|
|
227
|
+
const xssTestSchema = {
|
|
228
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
229
|
+
$anchor: 'xss-test',
|
|
230
|
+
$merge: {
|
|
231
|
+
source: { $ref: 'base' },
|
|
232
|
+
with: {
|
|
233
|
+
properties: {
|
|
234
|
+
body: { type: 'string', default: '' },
|
|
235
|
+
title: { type: 'string', default: '' },
|
|
236
|
+
count: { type: 'number', default: 0 },
|
|
237
|
+
meta: {
|
|
238
|
+
type: 'object',
|
|
239
|
+
properties: {
|
|
240
|
+
description: { type: 'string', default: '' },
|
|
241
|
+
author: { type: 'string', default: '' }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
await fs.writeFile(
|
|
250
|
+
path.join(testSchemaDir, 'xss-test.schema.json'),
|
|
251
|
+
JSON.stringify(xssTestSchema, null, 2)
|
|
252
|
+
)
|
|
253
|
+
|
|
225
254
|
// Create a patch schema that extends course
|
|
226
255
|
const coursePatchSchema = {
|
|
227
256
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
@@ -574,6 +603,311 @@ async function runTests () {
|
|
|
574
603
|
}
|
|
575
604
|
console.log('')
|
|
576
605
|
|
|
606
|
+
// Test 19: XSS sanitization — dangerous content is stripped or escaped
|
|
607
|
+
console.log('Test 19: XSS sanitization — dangerous payloads')
|
|
608
|
+
const xssSchema = await library.getSchema('xss-test')
|
|
609
|
+
const sanitiseBody = input => xssSchema.sanitise({ body: input }, { sanitiseHtml: true, strict: false }).body
|
|
610
|
+
|
|
611
|
+
const dangerousCases = [
|
|
612
|
+
{
|
|
613
|
+
desc: '<script> tag escaped',
|
|
614
|
+
input: '<script>alert(1)</script>',
|
|
615
|
+
check: out => !/<script/i.test(out) && !out.includes('alert(1)</script>')
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
desc: 'onerror handler stripped from <img>',
|
|
619
|
+
input: '<img src=x onerror="alert(1)">',
|
|
620
|
+
check: out => !/onerror/i.test(out)
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
desc: 'onclick handler stripped from allowed <a>',
|
|
624
|
+
input: '<a href="https://example.com" onclick="evil()">link</a>',
|
|
625
|
+
check: out => !/onclick/i.test(out) && out.includes('example.com')
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
desc: 'javascript: URL stripped from <a href>',
|
|
629
|
+
input: '<a href="javascript:alert(1)">click</a>',
|
|
630
|
+
check: out => !/javascript:/i.test(out)
|
|
631
|
+
},
|
|
632
|
+
{
|
|
633
|
+
desc: '<iframe> (not whitelisted) escaped',
|
|
634
|
+
input: '<iframe src="https://evil.com"></iframe>',
|
|
635
|
+
check: out => !/<iframe/i.test(out)
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
desc: '<svg onload> (not whitelisted) escaped',
|
|
639
|
+
input: '<svg onload="alert(1)"></svg>',
|
|
640
|
+
check: out => !/<svg/i.test(out)
|
|
641
|
+
},
|
|
642
|
+
{
|
|
643
|
+
desc: '<object> (not whitelisted) escaped',
|
|
644
|
+
input: '<object data="evil.swf"></object>',
|
|
645
|
+
check: out => !/<object/i.test(out)
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
desc: 'style attribute (not whitelisted) stripped from <div>',
|
|
649
|
+
input: '<div style="background:url(javascript:alert(1))">x</div>',
|
|
650
|
+
check: out => !/style=/i.test(out) && !/javascript:/i.test(out)
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
desc: 'data: URI in href stripped',
|
|
654
|
+
input: '<a href="data:text/html,<script>alert(1)</script>">x</a>',
|
|
655
|
+
check: out => !/data:text\/html/i.test(out) && !/<script/i.test(out)
|
|
656
|
+
}
|
|
657
|
+
]
|
|
658
|
+
|
|
659
|
+
const dangerousResults = dangerousCases.map(({ desc, input, check }) => {
|
|
660
|
+
const out = sanitiseBody(input)
|
|
661
|
+
const ok = check(out)
|
|
662
|
+
return { desc, input, out, ok }
|
|
663
|
+
})
|
|
664
|
+
dangerousResults.forEach(({ desc, input, out, ok }) => {
|
|
665
|
+
console.log(` ${ok ? '✓' : '✗'} ${desc}`)
|
|
666
|
+
if (!ok) console.log(` input: ${JSON.stringify(input)}\n output: ${JSON.stringify(out)}`)
|
|
667
|
+
})
|
|
668
|
+
const dangerousFailed = dangerousResults.filter(r => !r.ok)
|
|
669
|
+
if (dangerousFailed.length) {
|
|
670
|
+
throw new Error(`Dangerous-payload failures: ${dangerousFailed.map(r => r.desc).join(', ')}`)
|
|
671
|
+
}
|
|
672
|
+
console.log('')
|
|
673
|
+
|
|
674
|
+
// Test 20: XSS sanitization — safe content passes through
|
|
675
|
+
console.log('Test 20: XSS sanitization — safe content preserved')
|
|
676
|
+
const safeCases = [
|
|
677
|
+
{ desc: 'plain text unchanged', input: 'Hello, world.', check: out => out === 'Hello, world.' },
|
|
678
|
+
{ desc: 'empty string unchanged', input: '', check: out => out === '' },
|
|
679
|
+
{ desc: '<b> preserved', input: '<b>bold</b>', check: out => /<b>bold<\/b>/.test(out) },
|
|
680
|
+
{ desc: '<strong> preserved', input: '<strong>x</strong>', check: out => /<strong>x<\/strong>/.test(out) },
|
|
681
|
+
{ desc: '<em> preserved', input: '<em>x</em>', check: out => /<em>x<\/em>/.test(out) },
|
|
682
|
+
{
|
|
683
|
+
desc: '<a href=https://> preserved with href',
|
|
684
|
+
input: '<a href="https://example.com">link</a>',
|
|
685
|
+
check: out => /<a[^>]+href="https:\/\/example\.com"[^>]*>link<\/a>/.test(out)
|
|
686
|
+
},
|
|
687
|
+
{
|
|
688
|
+
desc: '<a href=http://> (http) preserved',
|
|
689
|
+
input: '<a href="http://example.com">link</a>',
|
|
690
|
+
check: out => /href="http:\/\/example\.com"/.test(out)
|
|
691
|
+
},
|
|
692
|
+
{
|
|
693
|
+
desc: 'nested allowed tags preserved',
|
|
694
|
+
input: '<p><strong>bold</strong> and <em>italic</em></p>',
|
|
695
|
+
check: out => /<strong>bold<\/strong>/.test(out) && /<em>italic<\/em>/.test(out)
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
desc: 'text with ampersand entities preserved',
|
|
699
|
+
input: 'Fish & chips',
|
|
700
|
+
check: out => out.includes('&') || out.includes('&')
|
|
701
|
+
}
|
|
702
|
+
]
|
|
703
|
+
|
|
704
|
+
const safeResults = safeCases.map(({ desc, input, check }) => {
|
|
705
|
+
const out = sanitiseBody(input)
|
|
706
|
+
return { desc, input, out, ok: check(out) }
|
|
707
|
+
})
|
|
708
|
+
safeResults.forEach(({ desc, input, out, ok }) => {
|
|
709
|
+
console.log(` ${ok ? '✓' : '✗'} ${desc}`)
|
|
710
|
+
if (!ok) console.log(` input: ${JSON.stringify(input)}\n output: ${JSON.stringify(out)}`)
|
|
711
|
+
})
|
|
712
|
+
const safeFailed = safeResults.filter(r => !r.ok)
|
|
713
|
+
if (safeFailed.length) {
|
|
714
|
+
throw new Error(`Safe-content failures: ${safeFailed.map(r => r.desc).join(', ')}`)
|
|
715
|
+
}
|
|
716
|
+
console.log('')
|
|
717
|
+
|
|
718
|
+
// Test 21: XSS sanitization — nested objects and non-string fields
|
|
719
|
+
console.log('Test 21: XSS sanitization — recursion + type handling')
|
|
720
|
+
const nestedInput = {
|
|
721
|
+
body: '<script>alert("top")</script><b>safe</b>',
|
|
722
|
+
title: '<img src=x onerror=alert(1)>',
|
|
723
|
+
count: 42,
|
|
724
|
+
meta: {
|
|
725
|
+
description: '<iframe src="evil"></iframe>clean',
|
|
726
|
+
author: '<a href="javascript:bad()">me</a>'
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
const nestedOut = xssSchema.sanitise(nestedInput, { sanitiseHtml: true, strict: false })
|
|
730
|
+
const nestedChecks = [
|
|
731
|
+
['top-level string sanitized (script removed)', !/<script/i.test(nestedOut.body)],
|
|
732
|
+
['top-level string sanitized (b preserved)', /<b>safe<\/b>/.test(nestedOut.body)],
|
|
733
|
+
['top-level string sanitized (onerror removed)', !/onerror/i.test(nestedOut.title)],
|
|
734
|
+
['number field passed through untouched', nestedOut.count === 42],
|
|
735
|
+
['nested string sanitized (iframe escaped)', !/<iframe/i.test(nestedOut.meta.description)],
|
|
736
|
+
['nested string sanitized (javascript: stripped)', !/javascript:/i.test(nestedOut.meta.author)]
|
|
737
|
+
]
|
|
738
|
+
nestedChecks.forEach(([desc, ok]) => console.log(` ${ok ? '✓' : '✗'} ${desc}: ${ok}`))
|
|
739
|
+
const nestedFailed = nestedChecks.filter(([, ok]) => !ok)
|
|
740
|
+
if (nestedFailed.length) {
|
|
741
|
+
throw new Error(`Nested sanitization failures: ${nestedFailed.map(([d]) => d).join(', ')}`)
|
|
742
|
+
}
|
|
743
|
+
console.log('')
|
|
744
|
+
|
|
745
|
+
// Test 22: xssWhitelistOverride replaces defaults at construction
|
|
746
|
+
console.log('Test 22: xssWhitelistOverride replaces defaults at construction')
|
|
747
|
+
const restrictiveLibrary = new Schemas({
|
|
748
|
+
enableCache: false,
|
|
749
|
+
xssWhitelistOverride: true,
|
|
750
|
+
xssWhitelist: { strong: [] } // only <strong> allowed, no attributes
|
|
751
|
+
})
|
|
752
|
+
restrictiveLibrary.init()
|
|
753
|
+
await restrictiveLibrary.loadSchemas('xss-test.schema.json', { cwd: testSchemaDir })
|
|
754
|
+
const restrictiveSchema = await restrictiveLibrary.getSchema('xss-test')
|
|
755
|
+
const restrictiveSanitise = input =>
|
|
756
|
+
restrictiveSchema.sanitise({ body: input }, { sanitiseHtml: true, strict: false }).body
|
|
757
|
+
|
|
758
|
+
const overrideChecks = [
|
|
759
|
+
['<strong> (in custom list) preserved', /<strong>x<\/strong>/.test(restrictiveSanitise('<strong>x</strong>'))],
|
|
760
|
+
['<b> (in defaults, not custom) escaped', !/<b>x<\/b>/.test(restrictiveSanitise('<b>x</b>'))],
|
|
761
|
+
['<a> (in defaults, not custom) escaped', !/<a /i.test(restrictiveSanitise('<a href="https://example.com">x</a>'))],
|
|
762
|
+
['<script> still escaped', !/<script/i.test(restrictiveSanitise('<script>alert(1)</script>'))]
|
|
763
|
+
]
|
|
764
|
+
overrideChecks.forEach(([desc, ok]) => console.log(` ${ok ? '✓' : '✗'} ${desc}: ${ok}`))
|
|
765
|
+
const overrideFailed = overrideChecks.filter(([, ok]) => !ok)
|
|
766
|
+
if (overrideFailed.length) {
|
|
767
|
+
throw new Error(`xssWhitelistOverride failures: ${overrideFailed.map(([d]) => d).join(', ')}`)
|
|
768
|
+
}
|
|
769
|
+
console.log('')
|
|
770
|
+
|
|
771
|
+
// Test 23: Exhaustive round-trip of XSSDefaults — every allowed tag and
|
|
772
|
+
// every allowed attribute on that tag survives; a disallowed attribute
|
|
773
|
+
// (onclick, not in any default list) is stripped from every tag.
|
|
774
|
+
console.log('Test 23: Exhaustive XSSDefaults coverage')
|
|
775
|
+
const voidTags = new Set(['area', 'br', 'col', 'hr', 'img', 'wbr'])
|
|
776
|
+
const urlAttrs = new Set(['href', 'src', 'cite', 'poster'])
|
|
777
|
+
const numericAttrs = new Set([
|
|
778
|
+
'colspan', 'coords', 'height', 'rowspan', 'size', 'span', 'tabindex', 'width'
|
|
779
|
+
])
|
|
780
|
+
const booleanAttrs = new Set([
|
|
781
|
+
'autoplay', 'controls', 'loop', 'muted', 'open', 'playsinline', 'preload'
|
|
782
|
+
])
|
|
783
|
+
|
|
784
|
+
const safeAttrValue = attr => {
|
|
785
|
+
if (urlAttrs.has(attr)) return 'https://example.com/x'
|
|
786
|
+
if (numericAttrs.has(attr)) return '1'
|
|
787
|
+
if (booleanAttrs.has(attr)) return attr
|
|
788
|
+
if (attr === 'datetime') return '2024-01-01T00:00:00Z'
|
|
789
|
+
if (attr === 'dir') return 'ltr'
|
|
790
|
+
if (attr === 'align' || attr === 'valign') return 'left'
|
|
791
|
+
if (attr === 'shape') return 'rect'
|
|
792
|
+
if (attr === 'lang') return 'en'
|
|
793
|
+
if (attr === 'role') return 'button'
|
|
794
|
+
if (attr === 'color') return 'red'
|
|
795
|
+
return 'test'
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const openTag = (tag, attrs) => {
|
|
799
|
+
const attrStr = attrs ? ` ${attrs}` : ''
|
|
800
|
+
return voidTags.has(tag) ? `<${tag}${attrStr}>` : `<${tag}${attrStr}>x</${tag}>`
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const exhaustiveLibrary = new Schemas({ enableCache: false })
|
|
804
|
+
exhaustiveLibrary.init()
|
|
805
|
+
await exhaustiveLibrary.loadSchemas('xss-test.schema.json', { cwd: testSchemaDir })
|
|
806
|
+
const exhaustiveSchema = await exhaustiveLibrary.getSchema('xss-test')
|
|
807
|
+
const san = input => exhaustiveSchema.sanitise({ body: input }, { sanitiseHtml: true, strict: false }).body
|
|
808
|
+
|
|
809
|
+
const tagFailures = []
|
|
810
|
+
const attrFailures = []
|
|
811
|
+
const disallowedFailures = []
|
|
812
|
+
let tagCount = 0
|
|
813
|
+
let attrCount = 0
|
|
814
|
+
|
|
815
|
+
Object.entries(XSSDefaults).forEach(([tag, attrs]) => {
|
|
816
|
+
tagCount++
|
|
817
|
+
|
|
818
|
+
const tagOut = san(openTag(tag))
|
|
819
|
+
if (!tagOut.includes(`<${tag}`)) {
|
|
820
|
+
tagFailures.push({ tag, output: tagOut })
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
const badInput = openTag(tag, 'onclick="evil()"')
|
|
824
|
+
const badOut = san(badInput)
|
|
825
|
+
if (/onclick/i.test(badOut)) {
|
|
826
|
+
disallowedFailures.push({ tag, output: badOut })
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
attrs.forEach(attr => {
|
|
830
|
+
attrCount++
|
|
831
|
+
const val = safeAttrValue(attr)
|
|
832
|
+
const out = san(openTag(tag, `${attr}="${val}"`))
|
|
833
|
+
if (!out.toLowerCase().includes(`${attr.toLowerCase()}=`)) {
|
|
834
|
+
attrFailures.push({ tag, attr, val, output: out })
|
|
835
|
+
}
|
|
836
|
+
})
|
|
837
|
+
})
|
|
838
|
+
|
|
839
|
+
console.log(` ${tagFailures.length === 0 ? '✓' : '✗'} ${tagCount} tags preserved (${tagFailures.length} failures)`)
|
|
840
|
+
tagFailures.forEach(({ tag, output }) => console.log(` - <${tag}> → ${JSON.stringify(output)}`))
|
|
841
|
+
console.log(` ${attrFailures.length === 0 ? '✓' : '✗'} ${attrCount} (tag, attr) pairs preserved (${attrFailures.length} failures)`)
|
|
842
|
+
attrFailures.forEach(({ tag, attr, val, output }) =>
|
|
843
|
+
console.log(` - <${tag} ${attr}="${val}"> → ${JSON.stringify(output)}`)
|
|
844
|
+
)
|
|
845
|
+
console.log(` ${disallowedFailures.length === 0 ? '✓' : '✗'} ${tagCount} onclick-strip checks (${disallowedFailures.length} failures)`)
|
|
846
|
+
disallowedFailures.forEach(({ tag, output }) => console.log(` - <${tag} onclick> → ${JSON.stringify(output)}`))
|
|
847
|
+
|
|
848
|
+
const totalExhaustiveFailures = tagFailures.length + attrFailures.length + disallowedFailures.length
|
|
849
|
+
if (totalExhaustiveFailures > 0) {
|
|
850
|
+
throw new Error(`XSSDefaults exhaustive check: ${totalExhaustiveFailures} total failures`)
|
|
851
|
+
}
|
|
852
|
+
console.log('')
|
|
853
|
+
|
|
854
|
+
// Test 24: deregisterSchema cleans up extension references (issue #37)
|
|
855
|
+
console.log('Test 24: deregisterSchema cleans up extension references (issue #37)')
|
|
856
|
+
const extPatchPath = path.join(testSchemaDir, 'patch-ext.schema.json')
|
|
857
|
+
await fs.writeFile(extPatchPath, JSON.stringify({
|
|
858
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
859
|
+
$anchor: 'patch-ext',
|
|
860
|
+
type: 'object',
|
|
861
|
+
$patch: { source: { $ref: 'config' }, with: { properties: { _patchAdded: { type: 'string', default: 'added' } } } }
|
|
862
|
+
}, null, 2))
|
|
863
|
+
library.registerSchema(extPatchPath)
|
|
864
|
+
const beforeDeregister = library.schemaExtensions.config?.includes('patch-ext')
|
|
865
|
+
console.log(` ✓ Extension registered against base: ${beforeDeregister}`)
|
|
866
|
+
const builtWithExt = (await library.getSchema('config')).built
|
|
867
|
+
const hasPatchedProperty = '_patchAdded' in (builtWithExt.properties ?? {})
|
|
868
|
+
console.log(` ✓ Patched property visible in cached build: ${hasPatchedProperty}`)
|
|
869
|
+
library.deregisterSchema('patch-ext')
|
|
870
|
+
const afterDeregister = !library.schemaExtensions.config?.includes('patch-ext')
|
|
871
|
+
console.log(` ✓ Extension removed from base after deregister: ${afterDeregister}`)
|
|
872
|
+
const cacheInvalidated = library.schemas.config?.built === undefined
|
|
873
|
+
console.log(` ✓ Base cached build invalidated on deregister: ${cacheInvalidated}`)
|
|
874
|
+
let buildSucceeded = true
|
|
875
|
+
let postRebuildHasPatch = true
|
|
876
|
+
try {
|
|
877
|
+
const rebuilt = (await library.getSchema('config')).built
|
|
878
|
+
postRebuildHasPatch = '_patchAdded' in (rebuilt.properties ?? {})
|
|
879
|
+
} catch (e) {
|
|
880
|
+
buildSucceeded = false
|
|
881
|
+
}
|
|
882
|
+
console.log(` ✓ Cached rebuild of base after deregister succeeds: ${buildSucceeded}`)
|
|
883
|
+
console.log(` ✓ Patched property gone from rebuilt schema: ${!postRebuildHasPatch}`)
|
|
884
|
+
if (!beforeDeregister || !hasPatchedProperty || !afterDeregister || !cacheInvalidated || !buildSucceeded || postRebuildHasPatch) {
|
|
885
|
+
throw new Error('deregisterSchema did not clean up extension references and cached builds correctly')
|
|
886
|
+
}
|
|
887
|
+
console.log('')
|
|
888
|
+
|
|
889
|
+
// Test 25: useCache:false isolates filtered builds from the registry instance
|
|
890
|
+
console.log('Test 25: useCache:false isolates filtered builds from the registry instance')
|
|
891
|
+
const includeLP = s => s === 'languagePicker-config'
|
|
892
|
+
const excludeLP = () => false
|
|
893
|
+
const registrySchema = library.schemas.config
|
|
894
|
+
const withLPSchema = await library.getSchema('config', { useCache: false, extensionFilter: includeLP })
|
|
895
|
+
const withoutLPSchema = await library.getSchema('config', { useCache: false, extensionFilter: excludeLP })
|
|
896
|
+
const withLPHas = '_languagePicker' in (withLPSchema.built.properties ?? {})
|
|
897
|
+
const withoutLPHas = '_languagePicker' in (withoutLPSchema.built.properties ?? {})
|
|
898
|
+
console.log(` ✓ Filter-include build has _languagePicker: ${withLPHas}`)
|
|
899
|
+
console.log(` ✓ Filter-exclude build lacks _languagePicker: ${!withoutLPHas}`)
|
|
900
|
+
// earlier build's properties must not be mutated by the later build (the original race)
|
|
901
|
+
const withLPStillHas = '_languagePicker' in (withLPSchema.built.properties ?? {})
|
|
902
|
+
console.log(` ✓ Earlier filter-include build is unaffected by later call: ${withLPStillHas}`)
|
|
903
|
+
// each filtered call must return a fresh instance, not the registry one
|
|
904
|
+
const isolatedFromRegistry = withLPSchema !== registrySchema && withoutLPSchema !== registrySchema && withLPSchema !== withoutLPSchema
|
|
905
|
+
console.log(` ✓ Filtered builds return isolated Schema instances: ${isolatedFromRegistry}`)
|
|
906
|
+
if (!withLPHas || withoutLPHas || !withLPStillHas || !isolatedFromRegistry) {
|
|
907
|
+
throw new Error('useCache:false did not isolate filtered builds from the registry')
|
|
908
|
+
}
|
|
909
|
+
console.log('')
|
|
910
|
+
|
|
577
911
|
console.log('=== All tests passed! ===')
|
|
578
912
|
} finally {
|
|
579
913
|
if (!hasSpecifiedPath) {
|