CVE-2021-23639 (published 2021-12-10T20:15Z)

The package md-to-pdf before 5.0.0 are vulnerable to Remote Code Execution (RCE) due to utilizing the library gray-matter to parse front matter content, without disabling the JS engine.

Severity: HIGH
CVSS v2 base score: 7.5 (AV:N/AC:L/Au:N/C:P/I:P/A:P)
CVSS v3 base score: 9.8, CRITICAL (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/simonhaenisch/md-to-pdf
commit_details: repo_url: https://github.com/simonhaenisch/md-to-pdf
author: simonhaenisch
author_date: 2021-09-24 11:19:29+02:00
msg: fix: disable JS engine for front-matter by default to prevent RCE

BREAKING CHANGE: If you previously used JS in front-matter, you'll now have to set `--gray-matter-options 'null'` (or `gray_matter_options: undefined`) to overwrite the new default options that disable the JS engine.

Fixes #99.
repository: repo_url: https://github.com/simonhaenisch/md-to-pdf
repo_name: simonhaenisch/md-to-pdf
description: Hackable CLI tool for converting Markdown files to PDF using Node.js and headless Chrome.
repo_language: TypeScript
owner: simonhaenisch
file_changes: filename: readme.md
change_type: ModificationType.MODIFY
diff: @@ -66,6 +66,7 @@ Options:
   --marked-options ......... Set custom options for marked (as a JSON string)
   --pdf-options ............ Set custom options for the generated PDF (as a JSON string)
   --launch-options ......... Set custom launch options for Puppeteer
+  --gray-matter-options .... Set custom options for gray-matter
   --port ................... Set the port to run the http server on
   --md-file-encoding ....... Set the file encoding for the markdown file
   --stylesheet-encoding .... Set the file encoding for the stylesheet
@@ -192,6 +193,7 @@ For default and advanced options see the following links. The default highlight.
 | `--marked-options`      | `'{ "gfm": false }'`                                                  |
 | `--pdf-options`         | `'{ "format": "Letter", "margin": "20mm", "printBackground": true }'` |
 | `--launch-options`      | `'{ "args": ["--no-sandbox"] }'`                                      |
+| `--gray-matter-options` | `null`                                                                |
 | `--port`                | `3000`                                                                |
 | `--md-file-encoding`    | `utf-8`, `windows1252`                                                |
 | `--stylesheet-encoding` | `utf-8`, `windows1252`                                                |
@@ -203,6 +205,8 @@ For default and advanced options see the following links. The default highlight.
 
 The options can also be set with front-matter or a config file (except `--md-file-encoding` can't be set by front-matter). In that case, remove the leading two hyphens (`--`) from the cli argument name and replace the hyphens (`-`) with underscores (`_`). `--stylesheet` and `--body-class` can be passed multiple times (i. e. to create an array). It's possible to set the output path for the PDF as `dest` in the config. If the same config option exists in multiple places, the priority (from low to high) is: defaults, config file, front-matter, cli arguments.
 
+The JS engine for front-matter is disabled by default for security reasons. You can enable it by overwriting the default gray-matter options (`--gray-matter-options null`, or `gray_matter_options: undefined` in the API).
+
 Example front-matter:
 
 ```markdown
@@ -267,10 +271,16 @@ css: |-
 ---
 ```
 
-## Security Consideration
+## Security Considerations
+
+### Local file server
 
 By default, this tool serves the current working directory via a http server on `localhost` on a relatively random port (or the port you specify), and that server gets shut down when the process exits (or as soon as it is killed). Please be aware that for the duration of the process this server will be accessible on your local network, and therefore all files within the served folder that the process has permission to read. So as a suggestion, maybe don't run this in watch mode in your system's root folder. 😉
 
+### Don't trust markdown content you don't control
+
+If you intend to use this tool to convert user-provided markdown content, please be aware that - as always - you should sanitize it before processing it with `md-to-pdf`.
+
 ## Customization/Development
 
 After cloning and linking/installing globally (`npm link`), just run the transpiler in watch mode (`npm start`). Then you can start making changes to the files and Typescript will transpile them on save. NPM 5+ uses symlinks for locally installed global packages, so all changes are reflected immediately without needing to re-install the package (except when there have been changes to required packages, then re-install using `npm i`). This also means that you can just do a `git pull` to get the latest version onto your machine.

programming_language: Markdown
filename: cli.ts
change_type: ModificationType.MODIFY
diff: @@ -37,6 +37,7 @@ export const cliFlags = arg({
 	'--html-pdf-options': String,
 	'--pdf-options': String,
 	'--launch-options': String,
+	'--gray-matter-options': String,
 	'--port': Number,
 	'--md-file-encoding': String,
 	'--stylesheet-encoding': String,

programming_language: TypeScript
filename: config.ts
change_type: ModificationType.MODIFY
diff: @@ -1,4 +1,5 @@
 import { WatchOptions } from 'chokidar';
+import { GrayMatterOption } from 'gray-matter';
 import { MarkedOptions } from 'marked';
 import { resolve } from 'path';
 import { FrameAddScriptTagOptions, launch, PDFOptions } from 'puppeteer';
@@ -24,6 +25,14 @@ export const defaultConfig: Config = {
 		},
 	},
 	launch_options: {},
+	gray_matter_options: {
+		engines: {
+			js: () =>
+				new Error(
+					'The JS engine for front-matter is disabled by default for security reasons. You can enable it by configuring graymatter_options.',
+				),
+		},
+	},
 	md_file_encoding: 'utf-8',
 	stylesheet_encoding: 'utf-8',
 	as_html: false,
@@ -122,6 +131,13 @@ interface BasicConfig {
 	 */
 	launch_options: PuppeteerLaunchOptions;
 
+	/**
+	 * Options for gray-matter (front-matter parser).
+	 *
+	 * @see https://github.com/jonschlinkert/gray-matter#options
+	 */
+	gray_matter_options: GrayMatterOption<string, any>;
+
 	/**
 	 * Markdown file encoding. Default: `utf-8`.
 	 */

programming_language: TypeScript
filename: help.ts
change_type: ModificationType.MODIFY
diff: @@ -19,6 +19,7 @@ const helpText = `
     --marked-options ${chalk.dim('.........')} Set custom options for marked (as a JSON string)
     --pdf-options ${chalk.dim('............')} Set custom options for the generated PDF (as a JSON string)
     --launch-options ${chalk.dim('.........')} Set custom launch options for Puppeteer
+		--gray-matter-options ${chalk.dim('....')} Set custom options for gray-matter
     --port ${chalk.dim('...................')} Set the port to run the http server on
     --md-file-encoding ${chalk.dim('.......')} Set the file encoding for the markdown file
     --stylesheet-encoding ${chalk.dim('....')} Set the file encoding for the stylesheet

programming_language: Shell
filename: md-to-pdf.ts
change_type: ModificationType.MODIFY
diff: @@ -23,14 +23,21 @@ export const convertMdToPdf = async (
 			? input.content
 			: await readFile(input.path, args['--md-file-encoding'] ?? config.md_file_encoding);
 
-	const { content: md, data: frontMatterConfig } = grayMatter(mdFileContent);
+	const { content: md, data: frontMatterConfig } = grayMatter(
+		mdFileContent,
+		args['--gray-matter-options'] ? JSON.parse(args['--gray-matter-options']) : config.gray_matter_options,
+	);
 
 	// merge front-matter config
-	config = {
-		...config,
-		...(frontMatterConfig as Config),
-		pdf_options: { ...config.pdf_options, ...frontMatterConfig.pdf_options },
-	};
+	if (frontMatterConfig instanceof Error) {
+		console.warn('Warning: the front-matter was ignored because it could not be parsed:\n', frontMatterConfig);
+	} else {
+		config = {
+			...config,
+			...(frontMatterConfig as Config),
+			pdf_options: { ...config.pdf_options, ...frontMatterConfig.pdf_options },
+		};
+	}
 
 	const { headerTemplate, footerTemplate, displayHeaderFooter } = config.pdf_options;
 

programming_language: TypeScript
filename: api.spec.ts
change_type: ModificationType.MODIFY
diff: @@ -4,6 +4,14 @@ import { basename, resolve } from 'path';
 import { getDocument } from 'pdfjs-dist/legacy/build/pdf';
 import { mdToPdf } from '..';
 
+const getPdfTextContent = async (content: Buffer) => {
+	const doc = await getDocument({ data: content }).promise;
+	const page = await doc.getPage(1);
+	const textContent = (await page.getTextContent()).items.map(({ str }) => str).join('');
+
+	return textContent;
+};
+
 before(() => {
 	const filesToDelete = [resolve(__dirname, 'basic', 'api-test.pdf'), resolve(__dirname, 'basic', 'api-test.html')];
 
@@ -62,10 +70,28 @@ test('compile the MathJax test', async (t) => {
 	t.is(pdf.filename, '');
 	t.truthy(pdf.content);
 
-	const doc = await getDocument({ data: pdf.content }).promise;
-	const page = await doc.getPage(1);
-	const text = (await page.getTextContent()).items.map(({ str }) => str).join('');
+	const textContent = await getPdfTextContent(pdf.content);
+
+	t.true(textContent.startsWith('Formulas with MathJax'));
+	t.true(textContent.includes('a≠0'));
+});
+
+test('the JS engine is disabled by default', async (t) => {
+	const css = '`body::before { display: block; content: "${"i am injected"}"}`'; // eslint-disable-line no-template-curly-in-string
+
+	const pdf = await mdToPdf({ content: `---js\n{ css: ${css} }\n---` });
+
+	const textContent = await getPdfTextContent(pdf.content);
+
+	t.is(textContent, '');
+});
+
+test('the JS engine for front-matter can be enabled', async (t) => {
+	const css = '`body::before { display: block; content: "${"i am injected"}"}`'; // eslint-disable-line no-template-curly-in-string
+
+	const pdf = await mdToPdf({ content: `---js\n{ css: ${css} }\n---` }, { gray_matter_options: undefined });
+
+	const textContent = await getPdfTextContent(pdf.content);
 
-	t.true(text.startsWith('Formulas with MathJax'));
-	t.true(text.includes('a≠0'));
+	t.is(textContent, 'i am injected');
 });

programming_language: TypeScript