Skip to content

Commit 770d438

Browse files
Improved storage path validation (#27217)
ref https://linear.app/ghost/issue/ONC-1595/ - shared path normalization across local storage reads and URL conversion so image path handling stays consistent
1 parent ca0c181 commit 770d438

3 files changed

Lines changed: 453 additions & 386 deletions

File tree

ghost/core/core/server/adapters/storage/LocalStorageBase.js

Lines changed: 118 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ const messages = {
1313
notFound: 'File not found',
1414
notFoundWithRef: 'File not found: {file}',
1515
cannotRead: 'Could not read file: {file}',
16-
invalidUrlParameter: `The URL "{url}" is not a valid URL for this site.`
16+
invalidUrlParameter: `The URL "{url}" is not a valid URL for this site.`,
17+
invalidPathParameter: 'The path "{path}" is not valid for this storage.'
1718
};
1819

1920
class LocalStorageBase extends StorageBase {
@@ -38,6 +39,75 @@ class LocalStorageBase extends StorageBase {
3839
this.errorMessages = errorMessages || messages;
3940
}
4041

42+
/**
43+
* Normalizes a relative storage path and rejects traversal outside the storage root.
44+
*
45+
* @param {string} filePath
46+
* @returns {string}
47+
*/
48+
_normalizeStorageRelativePath(filePath) {
49+
const normalized = path.posix.normalize(String(filePath || '')
50+
.replaceAll('\\', '/')
51+
.replace(/^\/+/, '')
52+
.replace(/\/+$/, ''));
53+
54+
if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
55+
throw new errors.IncorrectUsageError({
56+
message: tpl(messages.invalidPathParameter, {path: filePath})
57+
});
58+
}
59+
60+
return normalized;
61+
}
62+
63+
/**
64+
* Resolves a target directory and optional file name into a full path,
65+
* validating the result is inside the storage root.
66+
*
67+
* Supports relative paths (preferred) and absolute paths (legacy).
68+
* TODO: remove absolute path support once all callers pass relative paths
69+
*
70+
* @param {String} [targetDir] absolute or relative directory
71+
* @param {String} [fileName] file name to normalize and append
72+
* @returns {String} resolved absolute path inside storagePath
73+
*/
74+
_resolveAndValidateStoragePath(targetDir, fileName) {
75+
const resolvedRoot = path.resolve(this.storagePath);
76+
77+
// Resolve targetDir: if already inside storage root use as-is, otherwise treat as relative
78+
let resolvedBase;
79+
if (targetDir) {
80+
const resolvedTargetDir = path.resolve(targetDir);
81+
const relToRoot = path.relative(resolvedRoot, resolvedTargetDir);
82+
if (relToRoot === '' || (!relToRoot.startsWith('..') && !path.isAbsolute(relToRoot))) {
83+
resolvedBase = resolvedTargetDir;
84+
} else {
85+
resolvedBase = path.resolve(this.storagePath, targetDir);
86+
}
87+
} else {
88+
resolvedBase = resolvedRoot;
89+
}
90+
91+
// If fileName provided, normalize and resolve
92+
let resolvedPath;
93+
if (fileName) {
94+
const normalizedFileName = this._normalizeStorageRelativePath(fileName);
95+
resolvedPath = path.resolve(resolvedBase, normalizedFileName);
96+
} else {
97+
resolvedPath = resolvedBase;
98+
}
99+
100+
// Validate the resolved path is strictly inside the storage root (not equal to it)
101+
const relative = path.relative(resolvedRoot, resolvedPath);
102+
if (relative === '' || relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
103+
throw new errors.IncorrectUsageError({
104+
message: tpl(messages.invalidPathParameter, {path: fileName || targetDir || ''})
105+
});
106+
}
107+
108+
return resolvedPath;
109+
}
110+
41111
/**
42112
* Saves the file to storage (the file system)
43113
* - returns a promise which ultimately returns the full url to the uploaded file
@@ -49,13 +119,9 @@ class LocalStorageBase extends StorageBase {
49119
async save(file, targetDir) {
50120
let targetFilename;
51121

52-
// Supports relative paths (preferred) and absolute paths (legacy)
53-
// TODO: remove absolute path support once all callers pass relative paths
54-
if (targetDir && !targetDir.startsWith(this.storagePath)) {
55-
targetDir = path.join(this.storagePath, targetDir);
56-
}
57-
58-
targetDir = targetDir || this.getTargetDir(this.storagePath);
122+
targetDir = targetDir
123+
? this._resolveAndValidateStoragePath(targetDir)
124+
: this.getTargetDir(this.storagePath);
59125

60126
const filename = await this.getUniqueFileName(file, targetDir);
61127

@@ -91,7 +157,7 @@ class LocalStorageBase extends StorageBase {
91157
* @returns {Promise<String>} a URL to retrieve the data
92158
*/
93159
async saveRaw(buffer, targetPath) {
94-
const storagePath = path.join(this.storagePath, targetPath);
160+
const storagePath = path.join(this.storagePath, this._normalizeStorageRelativePath(targetPath));
95161
const targetDir = path.dirname(storagePath);
96162

97163
await fs.mkdirs(targetDir);
@@ -131,31 +197,34 @@ class LocalStorageBase extends StorageBase {
131197
});
132198
}
133199

134-
const normalized = path.posix.normalize(relativePath.replace(/^\//, ''));
135-
if (normalized.startsWith('..')) {
200+
try {
201+
return this._normalizeStorageRelativePath(relativePath);
202+
} catch (err) {
136203
throw new errors.IncorrectUsageError({
137204
message: tpl(messages.invalidUrlParameter, {url})
138205
});
139206
}
140-
141-
return normalized;
142207
}
143208

144-
exists(fileName, targetDir) {
145-
// Supports relative paths (preferred) and absolute paths (legacy)
146-
// TODO: remove absolute path support once all callers pass relative paths
147-
if (targetDir && !targetDir.startsWith(this.storagePath)) {
148-
targetDir = path.join(this.storagePath, targetDir);
149-
}
150-
const filePath = path.join(targetDir || this.storagePath, fileName);
209+
async exists(fileName, targetDir) {
210+
let filePath;
151211

152-
return fs.stat(filePath)
153-
.then(() => {
154-
return true;
155-
})
156-
.catch(() => {
212+
try {
213+
filePath = this._resolveAndValidateStoragePath(targetDir, fileName);
214+
} catch (err) {
215+
if (err instanceof errors.IncorrectUsageError) {
157216
return false;
158-
});
217+
}
218+
219+
throw err;
220+
}
221+
222+
try {
223+
await fs.stat(filePath);
224+
return true;
225+
} catch {
226+
return false;
227+
}
159228
}
160229

161230
/**
@@ -210,12 +279,7 @@ class LocalStorageBase extends StorageBase {
210279
* @returns {Promise.<*>}
211280
*/
212281
async delete(fileName, targetDir) {
213-
// Supports relative paths (preferred) and absolute paths (legacy)
214-
// TODO: remove absolute path support once all callers pass relative paths
215-
if (targetDir && !targetDir.startsWith(this.storagePath)) {
216-
targetDir = path.join(this.storagePath, targetDir);
217-
}
218-
const filePath = path.join(targetDir || this.storagePath, fileName);
282+
const filePath = this._resolveAndValidateStoragePath(targetDir, fileName);
219283
return await fs.remove(filePath);
220284
}
221285

@@ -225,41 +289,35 @@ class LocalStorageBase extends StorageBase {
225289
*
226290
* @param options
227291
*/
228-
read(options) {
292+
async read(options) {
229293
options = options || {};
230294

231-
// remove trailing slashes
232-
options.path = (options.path || '').replace(/\/$|\\$/, '');
295+
const normalizedPath = this._normalizeStorageRelativePath(options.path);
296+
const targetPath = path.join(this.storagePath, normalizedPath);
233297

234-
const targetPath = path.join(this.storagePath, options.path);
235-
236-
return new Promise((resolve, reject) => {
237-
fs.readFile(targetPath, (err, bytes) => {
238-
if (err) {
239-
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
240-
return reject(new errors.NotFoundError({
241-
err: err,
242-
message: tpl(this.errorMessages.notFoundWithRef, {file: options.path})
243-
}));
244-
}
245-
246-
if (err.code === 'ENAMETOOLONG') {
247-
return reject(new errors.BadRequestError({err: err}));
248-
}
298+
try {
299+
return await fs.readFile(targetPath);
300+
} catch (err) {
301+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') {
302+
throw new errors.NotFoundError({
303+
err: err,
304+
message: tpl(this.errorMessages.notFoundWithRef, {file: options.path})
305+
});
306+
}
249307

250-
if (err.code === 'EACCES') {
251-
return reject(new errors.NoPermissionError({err: err}));
252-
}
308+
if (err.code === 'ENAMETOOLONG') {
309+
throw new errors.BadRequestError({err: err});
310+
}
253311

254-
return reject(new errors.InternalServerError({
255-
err: err,
256-
message: tpl(this.errorMessages.cannotRead, {file: options.path})
257-
}));
258-
}
312+
if (err.code === 'EACCES') {
313+
throw new errors.NoPermissionError({err: err});
314+
}
259315

260-
resolve(bytes);
316+
throw new errors.InternalServerError({
317+
err: err,
318+
message: tpl(this.errorMessages.cannotRead, {file: options.path})
261319
});
262-
});
320+
}
263321
}
264322
}
265323

ghost/core/test/unit/server/adapters/storage/local-base-storage.test.js

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ const assert = require('assert/strict');
22
const path = require('path');
33
const http = require('http');
44
const express = require('express');
5+
const sinon = require('sinon');
6+
const fs = require('fs-extra');
57
const LocalStorageBase = require('../../../../../core/server/adapters/storage/LocalStorageBase');
68

79
describe('Local Storage Base', function () {
@@ -95,4 +97,121 @@ describe('Local Storage Base', function () {
9597
}, {message: 'The URL "http://anothersite.com/blog/content/media/2021/11/media.mp4" is not a valid URL for this site.'});
9698
});
9799
});
100+
101+
describe('path validation', function () {
102+
it('read rejects if the path resolves outside the storage root', async function () {
103+
const localStorageBase = new LocalStorageBase({
104+
storagePath: '/media-storage/path/',
105+
staticFileURLPrefix: 'content/media',
106+
siteUrl: 'http://example.com/blog/'
107+
});
108+
109+
await assert.rejects(
110+
localStorageBase.read({path: '../../outside-root.txt'}),
111+
{message: 'The path "../../outside-root.txt" is not valid for this storage.'}
112+
);
113+
});
114+
115+
it('exists returns false if the path resolves outside the storage root', async function () {
116+
const localStorageBase = new LocalStorageBase({
117+
storagePath: '/media-storage/path/',
118+
staticFileURLPrefix: 'content/media',
119+
siteUrl: 'http://example.com/blog/'
120+
});
121+
122+
await assert.doesNotReject(async function () {
123+
const exists = await localStorageBase.exists('../../outside-root.txt');
124+
assert.equal(exists, false);
125+
});
126+
});
127+
128+
it('read rejects dot-equivalent paths', async function () {
129+
const localStorageBase = new LocalStorageBase({
130+
storagePath: '/media-storage/path/',
131+
staticFileURLPrefix: 'content/media',
132+
siteUrl: 'http://example.com/blog/'
133+
});
134+
135+
await assert.rejects(
136+
localStorageBase.read({path: 'foo/..'}),
137+
{message: 'The path "foo/.." is not valid for this storage.'}
138+
);
139+
});
140+
141+
it('exists returns false for dot-equivalent paths', async function () {
142+
const localStorageBase = new LocalStorageBase({
143+
storagePath: '/media-storage/path/',
144+
staticFileURLPrefix: 'content/media',
145+
siteUrl: 'http://example.com/blog/'
146+
});
147+
148+
const exists = await localStorageBase.exists('.');
149+
assert.equal(exists, false);
150+
});
151+
152+
it('delete rejects dot-equivalent paths', async function () {
153+
const localStorageBase = new LocalStorageBase({
154+
storagePath: '/media-storage/path/',
155+
staticFileURLPrefix: 'content/media',
156+
siteUrl: 'http://example.com/blog/'
157+
});
158+
159+
await assert.rejects(
160+
localStorageBase.delete(''),
161+
{message: 'The path "" is not valid for this storage.'}
162+
);
163+
});
164+
165+
it('exists rejects when targetDir resolves outside storage root via traversal', async function () {
166+
// Stub fs.stat to always succeed so we can detect traversal
167+
// rather than having it masked by file-not-found
168+
const statStub = sinon.stub(fs, 'stat').resolves({});
169+
170+
try {
171+
const localStorageBase = new LocalStorageBase({
172+
storagePath: '/media-storage/path/',
173+
staticFileURLPrefix: 'content/media',
174+
siteUrl: 'http://example.com/blog/'
175+
});
176+
177+
// targetDir traverses out of storage root — should return false, not attempt access
178+
const exists = await localStorageBase.exists('file.txt', '../../etc');
179+
assert.equal(exists, false, 'should be false because the path escapes the storage root');
180+
} finally {
181+
statStub.restore();
182+
}
183+
});
184+
185+
it('exists rejects when targetDir prefix-matches but is not inside storage root', async function () {
186+
const statStub = sinon.stub(fs, 'stat').resolves({});
187+
188+
try {
189+
const localStorageBase = new LocalStorageBase({
190+
storagePath: '/media-storage/path',
191+
staticFileURLPrefix: 'content/media',
192+
siteUrl: 'http://example.com/blog/'
193+
});
194+
195+
// targetDir starts with storagePath string but is a sibling directory
196+
// naive startsWith would treat this as already absolute + inside root
197+
const exists = await localStorageBase.exists('file.txt', '/media-storage/path-evil');
198+
assert.equal(exists, false, 'should be false because path-evil is not inside path/');
199+
} finally {
200+
statStub.restore();
201+
}
202+
});
203+
204+
it('delete rejects when targetDir resolves outside storage root', async function () {
205+
const localStorageBase = new LocalStorageBase({
206+
storagePath: '/media-storage/path/',
207+
staticFileURLPrefix: 'content/media',
208+
siteUrl: 'http://example.com/blog/'
209+
});
210+
211+
await assert.rejects(
212+
localStorageBase.delete('file.txt', '../../etc'),
213+
{message: 'The path "file.txt" is not valid for this storage.'}
214+
);
215+
});
216+
});
98217
});

0 commit comments

Comments
 (0)