Skip to content

Commit 63c34b6

Browse files
committed
Fix to allow * attributes too if specific also specified
The attributes schema allows both specific tag names (say, `a`) and a generic catch all (`*`). Previously, if a strict specific schema was allowed (such as that only `/^language-./` classes are allowed on `code`), and someone allowed all classes on the `*` wildcard, then this latter intent was not honored. Now, it works, either works. Closes GH-27. Closes GH-28.
1 parent 7fa09c8 commit 63c34b6

File tree

3 files changed

+125
-33
lines changed

3 files changed

+125
-33
lines changed

lib/index.js

+50-27
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,7 @@ function element(state, unsafe) {
363363
let safeElement = false
364364

365365
if (
366-
name.length > 0 &&
366+
name &&
367367
name !== '*' &&
368368
(!state.schema.tagNames || state.schema.tagNames.includes(name))
369369
) {
@@ -511,21 +511,20 @@ function properties(state, properties) {
511511

512512
for (key in props) {
513513
if (own.call(props, key)) {
514-
/** @type {Readonly<PropertyDefinition> | undefined} */
515-
let definition
516-
517-
if (specific) definition = findDefinition(specific, key)
518-
if (!definition && defaults) definition = findDefinition(defaults, key)
519-
520-
if (definition) {
521-
const unsafe = props[key]
522-
const safe = Array.isArray(unsafe)
523-
? propertyValues(state, definition, key, unsafe)
524-
: propertyValue(state, definition, key, unsafe)
514+
const unsafe = props[key]
515+
let safe = propertyValue(
516+
state,
517+
findDefinition(specific, key),
518+
key,
519+
unsafe
520+
)
521+
522+
if (safe === null || safe === undefined) {
523+
safe = propertyValue(state, findDefinition(defaults, key), key, unsafe)
524+
}
525525

526-
if (safe !== null && safe !== undefined) {
527-
result[key] = safe
528-
}
526+
if (safe !== null && safe !== undefined) {
527+
result[key] = safe
529528
}
530529
}
531530
}
@@ -543,6 +542,28 @@ function properties(state, properties) {
543542
return result
544543
}
545544

545+
/**
546+
* Sanitize a property value.
547+
*
548+
* @param {State} state
549+
* Info passed around.
550+
* @param {Readonly<PropertyDefinition> | undefined} definition
551+
* Definition.
552+
* @param {string} key
553+
* Field name.
554+
* @param {Readonly<unknown>} value
555+
* Unsafe value (but an array).
556+
* @returns {Array<number | string> | boolean | number | string | undefined}
557+
* Safe value.
558+
*/
559+
function propertyValue(state, definition, key, value) {
560+
return definition
561+
? Array.isArray(value)
562+
? propertyValueMany(state, definition, key, value)
563+
: propertyValuePrimitive(state, definition, key, value)
564+
: undefined
565+
}
566+
546567
/**
547568
* Sanitize a property value which is a list.
548569
*
@@ -557,13 +578,13 @@ function properties(state, properties) {
557578
* @returns {Array<number | string>}
558579
* Safe value.
559580
*/
560-
function propertyValues(state, definition, key, values) {
581+
function propertyValueMany(state, definition, key, values) {
561582
let index = -1
562583
/** @type {Array<number | string>} */
563584
const result = []
564585

565586
while (++index < values.length) {
566-
const value = propertyValue(state, definition, key, values[index])
587+
const value = propertyValuePrimitive(state, definition, key, values[index])
567588

568589
if (typeof value === 'number' || typeof value === 'string') {
569590
result.push(value)
@@ -574,7 +595,7 @@ function propertyValues(state, definition, key, values) {
574595
}
575596

576597
/**
577-
* Sanitize a property value.
598+
* Sanitize a property value which is a primitive.
578599
*
579600
* @param {State} state
580601
* Info passed around.
@@ -587,7 +608,7 @@ function propertyValues(state, definition, key, values) {
587608
* @returns {boolean | number | string | undefined}
588609
* Safe value.
589610
*/
590-
function propertyValue(state, definition, key, value) {
611+
function propertyValuePrimitive(state, definition, key, value) {
591612
if (
592613
typeof value !== 'boolean' &&
593614
typeof value !== 'number' &&
@@ -713,7 +734,7 @@ function patch(node, unsafe) {
713734

714735
/**
715736
*
716-
* @param {Readonly<Array<PropertyDefinition>>} definitions
737+
* @param {Readonly<Array<PropertyDefinition>> | undefined} definitions
717738
* @param {string} key
718739
* @returns {Readonly<PropertyDefinition> | undefined}
719740
*/
@@ -722,15 +743,17 @@ function findDefinition(definitions, key) {
722743
let dataDefault
723744
let index = -1
724745

725-
while (++index < definitions.length) {
726-
const entry = definitions[index]
727-
const name = typeof entry === 'string' ? entry : entry[0]
746+
if (definitions) {
747+
while (++index < definitions.length) {
748+
const entry = definitions[index]
749+
const name = typeof entry === 'string' ? entry : entry[0]
728750

729-
if (name === key) {
730-
return entry
731-
}
751+
if (name === key) {
752+
return entry
753+
}
732754

733-
if (name === 'data*') dataDefault = entry
755+
if (name === 'data*') dataDefault = entry
756+
}
734757
}
735758

736759
if (key.length > 4 && key.slice(0, 4).toLowerCase() === 'data') {

lib/schema.js

+6-6
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,8 @@ export const defaultSchema = {
3434
del: ['cite'],
3535
div: ['itemScope', 'itemType'],
3636
dl: [...aria],
37-
// Note: these 2 are used by GFM footnotes, they *sometimes* work.
38-
h2: [
39-
['id', 'footnote-label'],
40-
['className', 'sr-only']
41-
],
37+
// Note: this is used by GFM footnotes.
38+
h2: [['className', 'sr-only']],
4239
img: [...aria, 'longDesc', 'src'],
4340
// Note: `input` is not normally allowed by GH, when manually writing
4441
// it in markdown, they add it from tasklists some other way.
@@ -89,7 +86,10 @@ export const defaultSchema = {
8986
'coords',
9087
'dateTime',
9188
'dir',
92-
'disabled',
89+
// Note: `disabled` is technically allowed on all elements by GH.
90+
// But it is useless on everything except `input`.
91+
// Because `input`s are normally not allowed, but we allow them for
92+
// checkboxes due to tasklists, we allow `disabled` only there.
9393
'encType',
9494
'frame',
9595
'hSpace',

test/index.js

+69
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,34 @@ import {u} from 'unist-builder'
99
const own = {}.hasOwnProperty
1010

1111
test('sanitize()', async function (t) {
12+
await t.test('check', async function () {
13+
const attributes = defaultSchema.attributes
14+
assert(attributes)
15+
16+
const generic = new Set(
17+
attributes['*'].map((d) => (typeof d === 'string' ? d : d[0]))
18+
)
19+
20+
for (const tagName in attributes) {
21+
if (tagName !== '*' && Object.hasOwn(attributes, tagName)) {
22+
const allowed = attributes[tagName]
23+
24+
for (const attribute of allowed) {
25+
const name = typeof attribute === 'string' ? attribute : attribute[0]
26+
27+
assert(
28+
generic.has(name) === false,
29+
'unexpected attribute `' +
30+
name +
31+
'` in both `*` and `' +
32+
tagName +
33+
'`, they conflict, please use one or the other'
34+
)
35+
}
36+
}
37+
}
38+
})
39+
1240
await t.test('should expose the public api', async function () {
1341
assert.deepEqual(Object.keys(await import('hast-util-sanitize')).sort(), [
1442
'defaultSchema',
@@ -739,6 +767,47 @@ test('`element`', async function (t) {
739767
)
740768
}
741769
)
770+
771+
await t.test(
772+
'should allow specific values allowed by either a specific or generic attribute definition (1)',
773+
async function () {
774+
assert.deepEqual(
775+
sanitize(
776+
h(null, [h('a', {x: 'y'}), h('a', {x: 'z'}), h('a', {x: 'w'})]),
777+
{...defaultSchema, attributes: {a: [['x', 'y']], '*': [['x', 'z']]}}
778+
),
779+
h(null, [h('a', {x: 'y'}), h('a', {x: 'z'}), h('a')])
780+
)
781+
}
782+
)
783+
784+
await t.test(
785+
'should allow specific values allowed by either a specific or generic attribute definition (2)',
786+
async function () {
787+
assert.deepEqual(
788+
sanitize(
789+
h(null, [h('a', {x: 'y'}), h('a', {x: 'z'}), h('a', {x: 'w'})]),
790+
// `x` on `*` can contain anything:
791+
{...defaultSchema, attributes: {a: ['x'], '*': [['x', 'z']]}}
792+
),
793+
h(null, [h('a', {x: 'y'}), h('a', {x: 'z'}), h('a', {x: 'w'})])
794+
)
795+
}
796+
)
797+
798+
await t.test(
799+
'should allow specific values allowed by either a specific or generic attribute definition (3)',
800+
async function () {
801+
assert.deepEqual(
802+
sanitize(
803+
h(null, [h('a', {x: 'y'}), h('a', {x: 'z'}), h('a', {x: 'w'})]),
804+
// `x` on `*` can contain anything:
805+
{...defaultSchema, attributes: {a: [['x', 'y']], '*': ['x']}}
806+
),
807+
h(null, [h('a', {x: 'y'}), h('a', {x: 'z'}), h('a', {x: 'w'})])
808+
)
809+
}
810+
)
742811
})
743812

744813
test('`root`', async function (t) {

0 commit comments

Comments
 (0)