CVE-2021-39199: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: remark-html is an open source nodejs library which compiles Markdown to HTML. In affected versions the documentation of remark-html has mentioned that it was safe by default. In practice the default was never safe and had to be opted into. That is, user input was not sanitized. This means arbitrary HTML can be passed through leading to potential XSS attacks. The problem has been patched in 13.0.2 and 14.0.1: `remark-html` is now safe by default, and the implementation matches the documentation. On older affected versions, pass `sanitize: true` if you cannot update.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2021-09-07T19:15Z
Repository: https://github.com/remarkjs/remark-html
Language: JavaScript
Files changed: index.js, test/index.js
Commit date: 2021-09-07 16:13:12 +0200
Commit message: Fix to sanitize by default

The docs have always said `remark-html` is safe by default.
It wasn’t and this patches that.

If you do want to be unsafe, use `remark-html` with `sanitize: false`:

```diff
  -.use(remarkHtml)
  +.use(remarkHtml, {sanitize: false})
```

Diff: diff --git a/index.js b/index.js
index 3a9498c..57b5061 100644
--- a/index.js
+++ b/index.js
@@ -18,30 +18,40 @@ import {toHast} from 'mdast-util-to-hast'
 /**
  * Plugin to serialize markdown as HTML.
  *
  * @type {import('unified').Plugin<[Options?]|void[], Root, string>}
  */
-export default function remarkHtml(options = {}) {
-  const handlers = options.handlers || {}
-  const schema =
-    options.sanitize && typeof options.sanitize === 'object'
-      ? options.sanitize
-      : undefined
+export default function remarkHtml(settings) {
+  const options = {...(settings || {})}
+  /** @type {boolean|undefined} */
+  let clean
+
+  if (typeof options.sanitize === 'boolean') {
+    clean = options.sanitize
+    options.sanitize = undefined
+  }
+
+  if (typeof clean !== 'boolean') {
+    clean = true
+  }
 
   Object.assign(this, {Compiler: compiler})
 
   /**
    * @type {import('unified').CompilerFunction<Root, string>}
    */
   function compiler(node, file) {
-    const hast = toHast(node, {allowDangerousHtml: !options.sanitize, handlers})
+    const hast = toHast(node, {
+      allowDangerousHtml: !clean,
+      handlers: options.handlers
+    })
     // @ts-expect-error: assume root.
-    const cleanHast = options.sanitize ? sanitize(hast, schema) : hast
+    const cleanHast = clean ? sanitize(hast, options.sanitize) : hast
     const result = toHtml(
       // @ts-expect-error: assume root.
       cleanHast,
-      Object.assign({}, options, {allowDangerousHtml: !options.sanitize})
+      Object.assign({}, options, {allowDangerousHtml: !clean})
     )
 
     if (file.extname) {
       file.extname = '.html'
     }
diff --git a/test/index.js b/test/index.js
index 790a637..8bf1fb2 100644
--- a/test/index.js
+++ b/test/index.js
@@ -39,31 +39,31 @@ test('remarkHtml', (t) => {
     },
     /Expected node, got `\[object Object]`/,
     'should throw when not given a node'
   )
 
-  let processor = remark().use(remarkHtml)
+  let processorDangerous = remark().use(remarkHtml, {sanitize: false})
 
   t.equal(
     // @ts-expect-error: unknown node.
-    processor.stringify({type: 'alpha'}),
+    processorDangerous.stringify({type: 'alpha'}),
     '<div></div>',
     'should stringify unknown nodes'
   )
 
   t.equal(
-    processor.stringify({
+    processorDangerous.stringify({
       // @ts-expect-error: unknown node.
       type: 'alpha',
       children: [{type: 'strong', children: [{type: 'text', value: 'bravo'}]}]
     }),
     '<div><strong>bravo</strong></div>',
     'should stringify unknown nodes'
   )
 
   t.equal(
-    processor.stringify({
+    processorDangerous.stringify({
       // @ts-expect-error: unknown node.
       type: 'alpha',
       children: [{type: 'text', value: 'bravo'}],
       data: {
         hName: 'i',
@@ -73,11 +73,12 @@ test('remarkHtml', (t) => {
     }),
     '<i class="charlie">delta</i>',
     'should stringify unknown nodes'
   )
 
-  processor = remark().use(remarkHtml, {
+  processorDangerous = remark().use(remarkHtml, {
+    sanitize: false,
     handlers: {
       /** @param {Paragraph} node */
       paragraph(h, node) {
         const head = node.children[0]
 
@@ -89,50 +90,52 @@ test('remarkHtml', (t) => {
       }
     }
   })
 
   t.equal(
-    processor.processSync('paragraph text').toString(),
+    processorDangerous.processSync('paragraph text').toString(),
     '<p>changed</p>\n',
     'should allow overriding handlers'
   )
 
-  processor = remark()
+  processorDangerous = remark()
     .use(
       /** @type {import('unified').Plugin<void[], Root>} */
       () => (ast) => {
         // @ts-expect-error: assume it exists.
         ast.children[0].children[0].data = {
           hProperties: {title: 'overwrite'}
         }
       }
     )
-    .use(remarkHtml)
+    .use(remarkHtml, {sanitize: false})
 
   t.equal(
-    processor.processSync('![hello](example.jpg "overwritten")').toString(),
+    processorDangerous
+      .processSync('![hello](example.jpg "overwritten")')
+      .toString(),
     '<p><img src="example.jpg" alt="hello" title="overwrite"></p>\n',
     'should patch and merge attributes'
   )
 
-  processor = remark()
+  processorDangerous = remark()
     .use(
       /** @type {import('unified').Plugin<void[], Root>} */
       () => (ast) => {
         // @ts-expect-error: assume it exists.
         ast.children[0].children[0].data = {hName: 'b'}
       }
     )
-    .use(remarkHtml)
+    .use(remarkHtml, {sanitize: false})
 
   t.equal(
-    processor.processSync('**Bold!**').toString(),
+    processorDangerous.processSync('**Bold!**').toString(),
     '<p><b>Bold!</b></p>\n',
     'should overwrite a tag-name'
   )
 
-  processor = remark()
+  processorDangerous = remark()
     .use(
       /** @type {import('unified').Plugin<void[], Root>} */
       () => (ast) => {
         // @ts-expect-error: assume it exists.
         const code = ast.children[0].children[0]
@@ -147,19 +150,19 @@ test('remarkHtml', (t) => {
             }
           ]
         }
       }
     )
-    .use(remarkHtml)
+    .use(remarkHtml, {sanitize: false})
 
   t.equal(
-    processor.processSync('`var`').toString(),
+    processorDangerous.processSync('`var`').toString(),
     '<p><code><span class="token">var</span></code></p>\n',
     'should overwrite content'
   )
 
-  processor = remark()
+  processorDangerous = remark()
     .use(
       /** @type {import('unified').Plugin<void[], Root>} */
       () => (ast) => {
         // @ts-expect-error: assume it exists.
         const code = ast.children[0].children[0]
@@ -177,39 +180,39 @@ test('remarkHtml', (t) => {
       }
     )
     .use(remarkHtml, {sanitize: true})
 
   t.equal(
-    processor.processSync('`var`').toString(),
+    processorDangerous.processSync('`var`').toString(),
     '<p><code>var</code></p>\n',
     'should not overwrite content in `sanitize` mode'
   )
 
-  processor = remark()
+  processorDangerous = remark()
     .use(
       /** @type {import('unified').Plugin<void[], Root>} */
       () => (ast) => {
         ast.children[0].data = {
           hProperties: {className: 'foo'}
         }
       }
     )
-    .use(remarkHtml)
+    .use(remarkHtml, {sanitize: false})
 
   t.equal(
-    processor.processSync('```js\nvar\n```\n').toString(),
+    processorDangerous.processSync('```js\nvar\n```\n').toString(),
     '<pre><code class="foo">var\n</code></pre>\n',
     'should overwrite classes on code'
   )
 
   t.equal(
     remark()
       .use(remarkHtml)
       .processSync('## Hello <span>world</span>')
       .toString(),
-    '<h2>Hello <span>world</span></h2>\n',
-    'should be `sanitation: false` by default'
+    '<h2>Hello world</h2>\n',
+    'should be `sanitation: true` by default'
   )
 
   t.equal(
     remark()
       .use(remarkHtml, {sanitize: true})
@@ -222,11 +225,11 @@ test('remarkHtml', (t) => {
   t.equal(
     remark()
       .use(remarkHtml, {sanitize: null})
       .processSync('## Hello <span>world</span>')
       .toString(),
-    '<h2>Hello <span>world</span></h2>\n',
+    '<h2>Hello world</h2>\n',
     'should support sanitation: null'
   )
 
   t.equal(
     remark()
@@ -292,11 +295,11 @@ test('CommonMark', (t) => {
       start = index
     }
 
     const actual = unified()
       .use(remarkParse)
-      .use(remarkHtml)
+      .use(remarkHtml, {sanitize: false})
       .processSync(example.markdown)
       .toString()
 
     const reformat = unified()
       .use(rehypeParse, {fragment: true})
@@ -335,11 +338,11 @@ test('Integrations', (t) => {
     const input = String(fs.readFileSync(path.join(base, name, 'input.md')))
     const file = toVFile({path: name + '.md', value: input})
     const result = remark()
       // @ts-expect-error: fine.
       .use(integrationMap[name])
-      .use(remarkHtml)
+      .use(remarkHtml, {sanitize: false})
       .processSync(file)
       .toString()
 
     t.equal(result, output, 'should integrate w/ `' + name + '`')
   }

Vulnerable code: export default function remarkHtml(options = {}) {
  const handlers = options.handlers || {}
  const schema =
    options.sanitize && typeof options.sanitize === 'object'
      ? options.sanitize
      : undefined
    const hast = toHast(node, {allowDangerousHtml: !options.sanitize, handlers})
    const cleanHast = options.sanitize ? sanitize(hast, schema) : hast
      Object.assign({}, options, {allowDangerousHtml: !options.sanitize})
  let processor = remark().use(remarkHtml)
    processor.stringify({type: 'alpha'}),
    processor.stringify({
    processor.stringify({
  processor = remark().use(remarkHtml, {
    processor.processSync('paragraph text').toString(),
  processor = remark()
    .use(remarkHtml)
    processor.processSync('![hello](example.jpg "overwritten")').toString(),
  processor = remark()
    .use(remarkHtml)
    processor.processSync('**Bold!**').toString(),
  processor = remark()
    .use(remarkHtml)
    processor.processSync('`var`').toString(),
  processor = remark()
    processor.processSync('`var`').toString(),
  processor = remark()
    .use(remarkHtml)
    processor.processSync('```js\nvar\n```\n').toString(),
    '<h2>Hello <span>world</span></h2>\n',
    'should be `sanitation: false` by default'
    '<h2>Hello <span>world</span></h2>\n',
      .use(remarkHtml)
      .use(remarkHtml)
Fixed code: export default function remarkHtml(settings) {
  const options = {...(settings || {})}
  /** @type {boolean|undefined} */
  let clean

  if (typeof options.sanitize === 'boolean') {
    clean = options.sanitize
    options.sanitize = undefined
  }

  if (typeof clean !== 'boolean') {
    clean = true
  }
    const hast = toHast(node, {
      allowDangerousHtml: !clean,
      handlers: options.handlers
    })
    const cleanHast = clean ? sanitize(hast, options.sanitize) : hast
      Object.assign({}, options, {allowDangerousHtml: !clean})
  let processorDangerous = remark().use(remarkHtml, {sanitize: false})
    processorDangerous.stringify({type: 'alpha'}),
    processorDangerous.stringify({
    processorDangerous.stringify({
  processorDangerous = remark().use(remarkHtml, {
    sanitize: false,
    processorDangerous.processSync('paragraph text').toString(),
  processorDangerous = remark()
    .use(remarkHtml, {sanitize: false})
    processorDangerous
      .processSync('![hello](example.jpg "overwritten")')
      .toString(),
  processorDangerous = remark()
    .use(remarkHtml, {sanitize: false})
    processorDangerous.processSync('**Bold!**').toString(),
  processorDangerous = remark()
    .use(remarkHtml, {sanitize: false})
    processorDangerous.processSync('`var`').toString(),
  processorDangerous = remark()
    processorDangerous.processSync('`var`').toString(),
  processorDangerous = remark()
    .use(remarkHtml, {sanitize: false})
    processorDangerous.processSync('```js\nvar\n```\n').toString(),
    '<h2>Hello world</h2>\n',
    'should be `sanitation: true` by default'
    '<h2>Hello world</h2>\n',
      .use(remarkHtml, {sanitize: false})
      .use(remarkHtml, {sanitize: false})