CVE-2023-32235 (published 2023-05-05T05:15Z)

Ghost before 5.42.1 allows remote attackers to read arbitrary files within the active theme's folder via /assets/built%2F..%2F..%2F/ directory traversal. This occurs in frontend/web/middleware/static-theme.js.

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

Weakness information:
cwe_id: CWE-22
cwe_name: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')
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.
extended_description: 
            Many file operations are intended to take place within a restricted directory. By using special elements such as ".." and "/" separators, attackers can escape outside of the restricted location to access files or directories that are elsewhere on the system. One of the most common special elements is the "../" sequence, which in most modern operating systems is interpreted as the parent directory of the current location. This is referred to as relative path traversal. Path traversal also covers the use of absolute pathnames such as "/usr/local/bin" to access unexpected files. This is referred to as absolute path traversal.
         
         

Fix information:
repo_url: https://github.com/TryGhost/Ghost
commit_details: repo_url: https://github.com/TryGhost/Ghost
author: Daniel Lockyer
author_date: 2023-04-07 09:39:41+02:00
msg: 🔒 Fixed path traversal issue in theme files

refs https://github.com/TryGhost/Team/issues/2843

- Using encoded path traversal characters in URL's path allowed to fetch
  any file within active theme's folder, which is disallowed
- credits to: fuomag9 https://kiwi.fuo.fi/@fuomag9
repository: repo_url: https://github.com/TryGhost/Ghost
repo_name: TryGhost/Ghost
description: Independent technology for modern publishing, memberships, subscriptions and newsletters.
repo_language: JavaScript
owner: TryGhost
file_changes: filename: static-theme.js
change_type: ModificationType.MODIFY
diff: @@ -14,15 +14,45 @@ function isDeniedFile(file) {
     return deniedFiles.includes(base) || deniedFileTypes.includes(ext);
 }
 
+/**
+ * Copy from:
+ * https://github.com/pillarjs/send/blob/b69cbb3dc4c09c37917d08a4c13fcd1bac97ade5/index.js#L987-L1003
+ *
+ * Allows V8 to only deoptimize this fn instead of all
+ * of send().
+ *
+ * @param {string} filePath
+ * @returns {string|number} returns -1 number if decode decodeURIComponent throws
+ */
+function decode(filePath) {
+    try {
+        return decodeURIComponent(filePath);
+    } catch (err) {
+        return -1;
+    }
+}
+
+/**
+ *
+ * @param {string} file path to a requested file
+ * @returns {boolean}
+ */
 function isAllowedFile(file) {
+    const decodedFilePath = decode(file);
+    if (decodedFilePath === -1) {
+        return false;
+    }
+
+    const normalizedFilePath = path.normalize(decodedFilePath);
+
     const allowedFiles = ['manifest.json'];
     const allowedPath = '/assets/';
     const alwaysDeny = ['.hbs'];
 
-    const ext = path.extname(file);
-    const base = path.basename(file);
+    const ext = path.extname(normalizedFilePath);
+    const base = path.basename(normalizedFilePath);
 
-    return allowedFiles.includes(base) || (file.startsWith(allowedPath) && !alwaysDeny.includes(ext));
+    return allowedFiles.includes(base) || (normalizedFilePath.startsWith(allowedPath) && !alwaysDeny.includes(ext));
 }
 
 function forwardToExpressStatic(req, res, next) {

programming_language: JavaScript
filename: static-theme.test.js
change_type: ModificationType.MODIFY
diff: @@ -184,4 +184,28 @@ describe('staticTheme', function () {
             done();
         });
     });
+
+    it('should disallow path traversal', function (done) {
+        req.path = '/assets/built%2F..%2F..%2F/package.json';
+        req.method = 'GET';
+
+        staticTheme()(req, res, function next() {
+            activeThemeStub.called.should.be.false();
+            expressStaticStub.called.should.be.false();
+
+            done();
+        });
+    });
+
+    it('should not crash when malformatted URL sequence is passed', function (done) {
+        req.path = '/assets/built%2F..%2F..%2F%E0%A4%A/package.json';
+        req.method = 'GET';
+
+        staticTheme()(req, res, function next() {
+            activeThemeStub.called.should.be.false();
+            expressStaticStub.called.should.be.false();
+
+            done();
+        });
+    });
 });

programming_language: JavaScript