diff --git a/ghost/admin/app/components/settings/analytics.hbs b/ghost/admin/app/components/settings/analytics.hbs index 4c808e648f..8fb4a1db77 100644 --- a/ghost/admin/app/components/settings/analytics.hbs +++ b/ghost/admin/app/components/settings/analytics.hbs @@ -107,12 +107,10 @@

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 c7f40b0625..c6ee17e30f 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'); + downloadParams.set('limit', 1000); - this.utils.downloadFile(`${exportUrl}?${downloadParams.toString()}`); + yield this.utils.fetchAndDownloadFile(`${exportUrl}?${downloadParams.toString()}`); + + return true; } @action 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 diff --git a/ghost/admin/package.json b/ghost/admin/package.json index f0e59cbdf7..ac983e8a21 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/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/package.json b/ghost/core/package.json index c359ddc314..1a0c17e9fa 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", 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(); + }); + }); });