adapt-schemas 3.1.1 → 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 +19 -23
- 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 = {}
|
|
@@ -119,6 +119,7 @@ class Schemas extends EventEmitter {
|
|
|
119
119
|
this.schemas = {
|
|
120
120
|
base: this.createSchema(path.resolve(__dirname, BASE_SCHEMA_PATH), { enableCache: true })
|
|
121
121
|
}
|
|
122
|
+
this.schemaExtensions = {}
|
|
122
123
|
this.emit('reset')
|
|
123
124
|
}
|
|
124
125
|
|
|
@@ -240,8 +241,6 @@ class Schemas extends EventEmitter {
|
|
|
240
241
|
}
|
|
241
242
|
}
|
|
242
243
|
|
|
243
|
-
this.schemaExtensions?.[schema.name]?.forEach(s => schema.addExtension(s))
|
|
244
|
-
|
|
245
244
|
if (schema.raw.$patch) {
|
|
246
245
|
this.extendSchema(schema.raw.$patch?.source?.$ref, schema.name)
|
|
247
246
|
}
|
|
@@ -255,17 +254,16 @@ class Schemas extends EventEmitter {
|
|
|
255
254
|
* @param {string} name Schema name to deregister
|
|
256
255
|
*/
|
|
257
256
|
deregisterSchema (name) {
|
|
258
|
-
|
|
259
|
-
delete this.schemas[name]
|
|
260
|
-
}
|
|
261
|
-
// Remove from AJV instances
|
|
257
|
+
delete this.schemas[name]
|
|
262
258
|
for (const v of [this.validator, this.validatorWithDefaults]) {
|
|
263
259
|
if (v.getSchema(name)) v.removeSchema(name)
|
|
264
260
|
}
|
|
265
|
-
|
|
266
|
-
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
|
|
267
264
|
this.schemaExtensions[base] = extensions.filter(s => s !== name)
|
|
268
|
-
|
|
265
|
+
this.schemas[base]?.invalidateBuild()
|
|
266
|
+
}
|
|
269
267
|
this.emit('schemaDeregistered', name)
|
|
270
268
|
}
|
|
271
269
|
|
|
@@ -286,9 +284,6 @@ class Schemas extends EventEmitter {
|
|
|
286
284
|
...options
|
|
287
285
|
})
|
|
288
286
|
|
|
289
|
-
this.schemaExtensions?.[schema.name]?.forEach(s => schema.addExtension(s))
|
|
290
|
-
delete this.schemaExtensions?.[schema.name]
|
|
291
|
-
|
|
292
287
|
return schema.load()
|
|
293
288
|
}
|
|
294
289
|
|
|
@@ -298,14 +293,12 @@ class Schemas extends EventEmitter {
|
|
|
298
293
|
* @param {string} extSchemaName The name of the schema to extend with
|
|
299
294
|
*/
|
|
300
295
|
extendSchema (baseSchemaName, extSchemaName) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (!this.schemaExtensions[baseSchemaName]) {
|
|
306
|
-
this.schemaExtensions[baseSchemaName] = []
|
|
307
|
-
}
|
|
296
|
+
if (!this.schemaExtensions[baseSchemaName]) {
|
|
297
|
+
this.schemaExtensions[baseSchemaName] = []
|
|
298
|
+
}
|
|
299
|
+
if (!this.schemaExtensions[baseSchemaName].includes(extSchemaName)) {
|
|
308
300
|
this.schemaExtensions[baseSchemaName].push(extSchemaName)
|
|
301
|
+
this.schemas[baseSchemaName]?.invalidateBuild()
|
|
309
302
|
}
|
|
310
303
|
this.emit('schemaExtended', baseSchemaName, extSchemaName)
|
|
311
304
|
}
|
|
@@ -314,7 +307,7 @@ class Schemas extends EventEmitter {
|
|
|
314
307
|
* Retrieves the specified schema
|
|
315
308
|
* @param {string} schemaName The name of the schema to return
|
|
316
309
|
* @param {Object} options
|
|
317
|
-
* @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)
|
|
318
311
|
* @param {boolean} options.compile Compile the schema (default: true)
|
|
319
312
|
* @param {boolean} options.applyExtensions Apply extension schemas (default: true)
|
|
320
313
|
* @param {function} options.extensionFilter Filter function for extensions
|
|
@@ -325,7 +318,10 @@ class Schemas extends EventEmitter {
|
|
|
325
318
|
if (!schema) {
|
|
326
319
|
throw new SchemaError('MISSING_SCHEMA', `Schema '${schemaName}' not found`, { schemaName })
|
|
327
320
|
}
|
|
328
|
-
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)
|
|
329
325
|
}
|
|
330
326
|
|
|
331
327
|
/**
|
|
@@ -367,7 +363,7 @@ class Schemas extends EventEmitter {
|
|
|
367
363
|
return Object.entries(this.schemas).reduce((info, [name, schema]) => {
|
|
368
364
|
info[name] = {
|
|
369
365
|
filePath: schema.filePath,
|
|
370
|
-
extensions:
|
|
366
|
+
extensions: this.schemaExtensions[name] ?? [],
|
|
371
367
|
hasParent: !!schema.raw?.$merge?.source?.$ref,
|
|
372
368
|
isPatch: !!schema.raw?.$patch
|
|
373
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) {
|