From 3dac3cb4e2cbbfc9473d2ca73349d516a63d883a Mon Sep 17 00:00:00 2001 From: "Fabien \"egg\" O'Carroll" Date: Fri, 7 Apr 2023 12:52:37 +0700 Subject: [PATCH 1/5] Added fetchAndDownloadFile method to utils service The existing approach didn't expose when the download was complete. By fetching the file upfront and downloading from a blob we can better estimate when the download is complete because we load the file in memory, and then download from there. The promise resolves after the file is in memory, so the delay between the promise resolving and the file actually being downloaded is a lot shorter, and based on the size of the file. --- ghost/admin/app/services/utils.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ghost/admin/app/services/utils.js b/ghost/admin/app/services/utils.js index a173c38002..4a02af6ab8 100644 --- a/ghost/admin/app/services/utils.js +++ b/ghost/admin/app/services/utils.js @@ -1,4 +1,5 @@ import Service from '@ember/service'; +import fetch from 'fetch'; export default class UtilsService extends Service { downloadFile(url) { @@ -14,6 +15,29 @@ export default class UtilsService extends Service { iframe.setAttribute('src', url); } + /** + * This method will fetch a file from the server and then download it, resolving + * once the initial fetch is complete, allowing it to be used with loading spinners. + * + * @param {string} url - The URL of the file to download + * @returns {Promise} + */ + async fetchAndDownloadFile(url) { + const response = await fetch(url); + const blob = await response.blob(); + + const anchor = document.createElement('a'); + + anchor.href = window.URL.createObjectURL(blob); + anchor.download = /filename="(.*)"/.exec(response.headers.get('Content-Disposition'))[1]; + + document.body.append(anchor); + + anchor.click(); + + document.body.removeChild(anchor); + } + /** * Remove tracking parameters from a URL * @param {string} url From a890f0b707c39c35c895db926dd4a9cdf97a341f Mon Sep 17 00:00:00 2001 From: "Fabien \"egg\" O'Carroll" Date: Fri, 7 Apr 2023 12:54:31 +0700 Subject: [PATCH 2/5] Updated the posts export button to be a GhTaskButton refs https://github.com/TryGhost/Team/issues/2935 This allows us to track the state with a loading spinner and a success/error message on completion. This is expecially important for larger sites where the download can take a long time, and users are unsure if something is happening. --- ghost/admin/app/components/settings/analytics.hbs | 4 +--- ghost/admin/app/components/settings/analytics.js | 9 ++++++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/ghost/admin/app/components/settings/analytics.hbs b/ghost/admin/app/components/settings/analytics.hbs index 4c808e648f..b82481018e 100644 --- a/ghost/admin/app/components/settings/analytics.hbs +++ b/ghost/admin/app/components/settings/analytics.hbs @@ -110,9 +110,7 @@ Download a CSV file of all your post data for easy analysis in one place

- + diff --git a/ghost/admin/app/components/settings/analytics.js b/ghost/admin/app/components/settings/analytics.js index c7f40b0625..34d4ca4fc4 100644 --- a/ghost/admin/app/components/settings/analytics.js +++ b/ghost/admin/app/components/settings/analytics.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import ghostPaths from 'ghost-admin/utils/ghost-paths'; import {action} from '@ember/object'; import {inject as service} from '@ember/service'; +import {task} from 'ember-concurrency'; export default class Analytics extends Component { @service settings; @@ -16,13 +17,15 @@ export default class Analytics extends Component { this.settings.emailTrackOpens = !this.settings.emailTrackOpens; } - @action - exportData() { + @task + *exportPostsTask() { let exportUrl = ghostPaths().url.api('posts/export'); let downloadParams = new URLSearchParams(); downloadParams.set('limit', 'all'); - this.utils.downloadFile(`${exportUrl}?${downloadParams.toString()}`); + yield this.utils.fetchAndDownloadFile(`${exportUrl}?${downloadParams.toString()}`); + + return true; } @action From c99016fd2fca58eaacbebb9dca6c974aabaa67f6 Mon Sep 17 00:00:00 2001 From: "Fabien \"egg\" O'Carroll" Date: Fri, 7 Apr 2023 13:24:59 +0700 Subject: [PATCH 3/5] Limited post export size to 1000 posts refs https://github.com/TryGhost/Team/issues/2936 We want to make sure that downloads complete in a reasonable number of time, and the simplest way to do that is to cap the size of the file. --- ghost/admin/app/components/settings/analytics.hbs | 2 +- ghost/admin/app/components/settings/analytics.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ghost/admin/app/components/settings/analytics.hbs b/ghost/admin/app/components/settings/analytics.hbs index b82481018e..8fb4a1db77 100644 --- a/ghost/admin/app/components/settings/analytics.hbs +++ b/ghost/admin/app/components/settings/analytics.hbs @@ -107,7 +107,7 @@

Export post analytics

- Download a CSV file of all your post data for easy analysis in one place + Download a CSV file of your last 1000 posts data for easy analysis in one place

diff --git a/ghost/admin/app/components/settings/analytics.js b/ghost/admin/app/components/settings/analytics.js index 34d4ca4fc4..c6ee17e30f 100644 --- a/ghost/admin/app/components/settings/analytics.js +++ b/ghost/admin/app/components/settings/analytics.js @@ -21,7 +21,7 @@ export default class Analytics extends Component { *exportPostsTask() { let exportUrl = ghostPaths().url.api('posts/export'); let downloadParams = new URLSearchParams(); - downloadParams.set('limit', 'all'); + downloadParams.set('limit', 1000); yield this.utils.fetchAndDownloadFile(`${exportUrl}?${downloadParams.toString()}`); From 378dd913aa8d0fd0da29b0ffced8884579598b0f Mon Sep 17 00:00:00 2001 From: Daniel Lockyer Date: Fri, 7 Apr 2023 09:39:41 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=F0=9F=94=92=20Fixed=20path=20traversal=20i?= =?UTF-8?q?ssue=20in=20theme=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../frontend/web/middleware/static-theme.js | 36 +++++++++++++++++-- .../web/middleware/static-theme.test.js | 24 +++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) 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 @@ -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) { 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 @@ -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(); + }); + }); }); From 89cf224a2afeae9410b7a3f4e3e03898c4316baf Mon Sep 17 00:00:00 2001 From: Ghost CI <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 7 Apr 2023 08:51:02 +0100 Subject: [PATCH 5/5] v5.42.1 --- ghost/admin/package.json | 2 +- ghost/core/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ghost/admin/package.json b/ghost/admin/package.json index 98b8dae0e1..0f3da5cc40 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "5.42.0", + "version": "5.42.1", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/core/package.json b/ghost/core/package.json index 27cfd74edd..b00809b06c 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -1,6 +1,6 @@ { "name": "ghost", - "version": "5.42.0", + "version": "5.42.1", "description": "The professional publishing platform", "author": "Ghost Foundation", "homepage": "https://ghost.org",