CVE-2023-32235: Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal') (CWE-22)
value: 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.
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: 7.5
Published: 2023-05-05T05:15Z
Repository: https://github.com/TryGhost/Ghost
Language: JavaScript
Files changed: ghost/core/core/frontend/web/middleware/static-theme.js, ghost/core/test/unit/frontend/web/middleware/static-theme.test.js
Commit date: 2023-04-07 09:45:59 +0200
Commit message: 🔒 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

Diff: diff --git a/ghost/core/core/frontend/web/middleware/static-theme.js b/ghost/core/core/frontend/web/middleware/static-theme.js
index 4066c92a1d..e2a6d393fa 100644
--- a/ghost/core/core/frontend/web/middleware/static-theme.js
+++ b/ghost/core/core/frontend/web/middleware/static-theme.js
@@ -12,19 +12,49 @@ function isDeniedFile(file) {
     const base = path.basename(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) {
     if (!themeEngine.getActive()) {
         return next();
diff --git a/ghost/core/test/unit/frontend/web/middleware/static-theme.test.js b/ghost/core/test/unit/frontend/web/middleware/static-theme.test.js
index 24e7303c7c..7fabf13b1e 100644
--- a/ghost/core/test/unit/frontend/web/middleware/static-theme.test.js
+++ b/ghost/core/test/unit/frontend/web/middleware/static-theme.test.js
@@ -182,6 +182,30 @@ describe('staticTheme', function () {
             expressStaticStub.called.should.be.false();
 
             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();
+        });
+    });
 });

Vulnerable code:     const ext = path.extname(file);
    const base = path.basename(file);
    return allowedFiles.includes(base) || (file.startsWith(allowedPath) && !alwaysDeny.includes(ext));
Fixed code: /**
 * 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}
 */
    const decodedFilePath = decode(file);
    if (decodedFilePath === -1) {
        return false;
    }

    const normalizedFilePath = path.normalize(decodedFilePath);

    const ext = path.extname(normalizedFilePath);
    const base = path.basename(normalizedFilePath);
    return allowedFiles.includes(base) || (normalizedFilePath.startsWith(allowedPath) && !alwaysDeny.includes(ext));

    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();
        });
    });