CVE-2021-43788: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: Nodebb is an open source Node.js based forum software. Prior to v1.18.5, a path traversal vulnerability was present that allowed users to access JSON files outside of the expected `languages/` directory. The vulnerability has been patched as of v1.18.5. Users are advised to upgrade as soon as possible.
CWE description: The product uses external input to construct a pathname that is intended to identify a file or directory that is located underneath a restricted parent directory, but the product does not properly neutralize special elements within the pathname that can cause the pathname to resolve to a location that is outside of the restricted directory.
CVSS v3 base score: 5
Published: 2021-11-29T20:15Z
Repository: https://github.com/NodeBB/NodeBB
Language: JavaScript
Files changed: src/languages.js, test/posts.js, test/translator.js
Commit date: 2021-10-25 12:51:56 -0400
Commit message: fix: translator path traversal

Diff: diff --git a/src/languages.js b/src/languages.js
index 200ec2a19..d307a439d 100644
--- a/src/languages.js
+++ b/src/languages.js
@@ -11,11 +11,15 @@ const languagesPath = path.join(__dirname, '../build/public/language');
 
 const files = fs.readdirSync(path.join(paths.nodeModules, '/timeago/locales'));
 Languages.timeagoCodes = files.filter(f => f.startsWith('jquery.timeago')).map(f => f.split('.')[2]);
 
 Languages.get = async function (language, namespace) {
-	const data = await fs.promises.readFile(path.join(languagesPath, language, `${namespace}.json`), 'utf8');
+	const pathToLanguageFile = path.join(languagesPath, language, `${namespace}.json`);
+	if (!pathToLanguageFile.startsWith(languagesPath)) {
+		throw new Error('[[error:invalid-path]]');
+	}
+	const data = await fs.promises.readFile(pathToLanguageFile, 'utf8');
 	const parsed = JSON.parse(data) || {};
 	const result = await plugins.hooks.fire('filter:languages.get', {
 		language,
 		namespace,
 		data: parsed,
diff --git a/test/posts.js b/test/posts.js
index 20a7661da..e6fa09253 100644
--- a/test/posts.js
+++ b/test/posts.js
@@ -1,9 +1,9 @@
 'use strict';
 
 
-const	assert = require('assert');
+const assert = require('assert');
 const async = require('async');
 const request = require('request');
 const nconf = require('nconf');
 const crypto = require('crypto');
 const fs = require('fs');
diff --git a/test/translator.js b/test/translator.js
index b8edb2d3b..02bc6de1e 100644
--- a/test/translator.js
+++ b/test/translator.js
@@ -33,10 +33,15 @@ describe('Translator shim', () => {
 
 		it('should translate empty string properly', async () => {
 			const translated = await shim.translate('', 'en-GB');
 			assert.strictEqual(translated, '');
 		});
+
+		it('should not allow path traversal', async () => {
+			const t = await shim.translate('[[../../../../config:secret]]');
+			assert.strictEqual(t, 'secret');
+		});
 	});
 });
 
 describe('new Translator(language)', () => {
 	it('should throw if not passed a language', (done) => {

Vulnerable code: 	const data = await fs.promises.readFile(path.join(languagesPath, language, `${namespace}.json`), 'utf8');
const	assert = require('assert');
Fixed code: 	const pathToLanguageFile = path.join(languagesPath, language, `${namespace}.json`);
	if (!pathToLanguageFile.startsWith(languagesPath)) {
		throw new Error('[[error:invalid-path]]');
	}
	const data = await fs.promises.readFile(pathToLanguageFile, 'utf8');
const assert = require('assert');

		it('should not allow path traversal', async () => {
			const t = await shim.translate('[[../../../../config:secret]]');
			assert.strictEqual(t, 'secret');
		});