CVE-2021-23639: Insufficient Information (NVD-CWE-noinfo)
value: 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.
CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
CVSS v3 base score: 9.8
Published: 2021-12-10T20:15Z
Repository: https://github.com/simonhaenisch/md-to-pdf
Language: TypeScript
Files changed: readme.md, src/cli.ts, src/lib/config.ts, src/lib/help.ts, src/lib/md-to-pdf.ts, src/test/api.spec.ts
Commit date: 2021-09-24 11:19:29 +0200
Commit message: 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.

Diff: diff --git a/readme.md b/readme.md
index 1aba61b..1f04918 100644
--- a/readme.md
+++ b/readme.md
@@ -64,10 +64,11 @@ Options:
   --page-media-type ........ Media type to emulate the page with (default: screen)
   --highlight-style ........ Style to be used by highlight.js (default: github)
   --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
   --as-html ................ Output as HTML instead
   --config-file ............ Path to a JSON or JS configuration file
@@ -190,10 +191,11 @@ For default and advanced options see the following links. The default highlight.
 | `--page-media-type`     | `print`                                                               |
 | `--highlight-style`     | `monokai`, `solarized-light`                                          |
 | `--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`                                                |
 | `--config-file`         | `path/to/config.json`                                                 |
 
@@ -201,10 +203,12 @@ For default and advanced options see the following links. The default highlight.
 
 **`highlight-style`:** if you set a highlight style with a background color, make sure that `"printBackground": true` is set in the pdf options.
 
 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
 ---
 dest: ./path/to/output.pdf
@@ -265,14 +269,20 @@ css: |-
   .markdown-body { font-size: 11px; }
   .markdown-body pre > code { white-space: pre-wrap; }
 ---
 ```
 
-## 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.
 
 Ideas, feature requests and PRs are welcome. Just keep it simple! 🤓
diff --git a/src/cli.ts b/src/cli.ts
index 4bd2edc..236df0d 100755
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -35,10 +35,11 @@ export const cliFlags = arg({
 	'--highlight-style': String,
 	'--marked-options': String,
 	'--html-pdf-options': String,
 	'--pdf-options': String,
 	'--launch-options': String,
+	'--gray-matter-options': String,
 	'--port': Number,
 	'--md-file-encoding': String,
 	'--stylesheet-encoding': String,
 	'--as-html': Boolean,
 	'--config-file': String,
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 6402cab..3384143 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -1,6 +1,7 @@
 import { WatchOptions } from 'chokidar';
+import { GrayMatterOption } from 'gray-matter';
 import { MarkedOptions } from 'marked';
 import { resolve } from 'path';
 import { FrameAddScriptTagOptions, launch, PDFOptions } from 'puppeteer';
 
 export const defaultConfig: Config = {
@@ -22,10 +23,18 @@ export const defaultConfig: Config = {
 			bottom: '30mm',
 			left: '20mm',
 		},
 	},
 	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,
 	devtools: false,
 };
@@ -120,10 +129,17 @@ interface BasicConfig {
 	 *
 	 * @see https://github.com/GoogleChrome/puppeteer/blob/master/docs/api.md#puppeteerlaunchoptions
 	 */
 	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`.
 	 */
 	md_file_encoding: string;
 
diff --git a/src/lib/help.ts b/src/lib/help.ts
index 19c705b..846cecd 100644
--- a/src/lib/help.ts
+++ b/src/lib/help.ts
@@ -17,10 +17,11 @@ const helpText = `
     --page-media-type ${chalk.dim('........')} Media type to emulate the page with (default: screen)
     --highlight-style ${chalk.dim('........')} Style to be used by highlight.js (default: github)
     --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
     --as-html ${chalk.dim('................')} Output as HTML instead
     --config-file ${chalk.dim('............')} Path to a JSON or JS configuration file
diff --git a/src/lib/md-to-pdf.ts b/src/lib/md-to-pdf.ts
index e90f578..62f993e 100644
--- a/src/lib/md-to-pdf.ts
+++ b/src/lib/md-to-pdf.ts
@@ -21,18 +21,25 @@ export const convertMdToPdf = async (
 	const mdFileContent =
 		'content' in input
 			? 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;
 
 	if ((headerTemplate || footerTemplate) && displayHeaderFooter === undefined) {
 		config.pdf_options.displayHeaderFooter = true;
diff --git a/src/test/api.spec.ts b/src/test/api.spec.ts
index 5a67190..b0502bf 100644
--- a/src/test/api.spec.ts
+++ b/src/test/api.spec.ts
@@ -2,10 +2,18 @@ import test, { before } from 'ava';
 import { readFileSync, unlinkSync } from 'fs';
 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')];
 
 	for (const file of filesToDelete) {
 		try {
@@ -60,12 +68,30 @@ test('compile the MathJax test', async (t) => {
 	const pdf = await mdToPdf({ path: resolve(__dirname, 'mathjax', 'math.md') });
 
 	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');
 });

Vulnerable code: ## Security Consideration
	const { content: md, data: frontMatterConfig } = grayMatter(mdFileContent);
	config = {
		...config,
		...(frontMatterConfig as Config),
		pdf_options: { ...config.pdf_options, ...frontMatterConfig.pdf_options },
	};
	const doc = await getDocument({ data: pdf.content }).promise;
	const page = await doc.getPage(1);
	const text = (await page.getTextContent()).items.map(({ str }) => str).join('');
	t.true(text.startsWith('Formulas with MathJax'));
	t.true(text.includes('a≠0'));
Fixed code:   --gray-matter-options .... Set custom options for gray-matter
| `--gray-matter-options` | `null`                                                                |
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).

## Security Considerations

### Local file server
### 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`.

	'--gray-matter-options': String,
import { GrayMatterOption } from 'gray-matter';
	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.',
				),
		},
	},
	/**
	 * Options for gray-matter (front-matter parser).
	 *
	 * @see https://github.com/jonschlinkert/gray-matter#options
	 */
	gray_matter_options: GrayMatterOption<string, any>;

		--gray-matter-options ${chalk.dim('....')} Set custom options for gray-matter
	const { content: md, data: frontMatterConfig } = grayMatter(
		mdFileContent,
		args['--gray-matter-options'] ? JSON.parse(args['--gray-matter-options']) : config.gray_matter_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 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;
};

	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.is(textContent, 'i am injected');