Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions spec/Utils.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -447,4 +447,25 @@ describe('Utils', () => {
expect(Utils.isObject(true)).toBe(false);
});
});

describe('getFileExtension', () => {
const cases = [
['file.txt', 'txt'],
['file.tar.gz', 'gz'],
['.hidden', 'hidden'],
['file.', ''],
['file..', ''],
['file', ''],
['', ''],
[null, ''],
[undefined, ''],
['poc.svg.', ''],
['archive.tar.gz.', ''],
];
for (const [input, expected] of cases) {
it(`returns ${JSON.stringify(expected)} for ${JSON.stringify(input)}`, () => {
expect(Utils.getFileExtension(input)).toBe(expected);
});
}
});
});
125 changes: 125 additions & 0 deletions spec/vulnerabilities.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1749,6 +1749,131 @@ describe('Vulnerabilities', () => {
});
});

describe('(GHSA-7wqv-xjf3-x35v) Stored XSS via trailing-dot filename bypassing file extension blocklist', () => {
const headers = {
'X-Parse-Application-Id': 'test',
'X-Parse-REST-API-Key': 'rest',
};

beforeEach(async () => {
await reconfigureServer({
fileUpload: {
enableForPublic: true,
},
});
});

it('blocks trailing-dot SVG filename with dangerous _ContentType on JSON-body upload', async () => {
const svgContent = Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
).toString('base64');
// No X-Parse-Application-Id header — must be in JSON body to trigger
// _ContentType extraction via the fileViaJSON middleware path.
await expectAsync(
request({
method: 'POST',
url: 'http://localhost:8378/1/files/poc.svg.',
body: JSON.stringify({
_ApplicationId: 'test',
_JavaScriptKey: 'test',
_ContentType: 'image/svg+xml',
base64: svgContent,
}),
}).catch(e => {
throw new Error(e.data.error);
})
).toBeRejectedWith(jasmine.objectContaining({
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
}));
});

it('blocks trailing-dot SVG filename with dangerous Content-Type on binary upload', async () => {
await expectAsync(
request({
method: 'POST',
headers: {
...headers,
'Content-Type': 'image/svg+xml',
},
url: 'http://localhost:8378/1/files/poc.svg.',
body: '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
}).catch(e => {
throw new Error(e.data.error);
})
).toBeRejectedWith(jasmine.objectContaining({
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
}));
});

it('blocks filename with mixed trailing dots and whitespace', async () => {
for (const filename of ['poc.svg..', 'poc.svg. ', 'poc.svg . ']) {
await expectAsync(
request({
method: 'POST',
headers: {
...headers,
'Content-Type': 'image/svg+xml',
},
url: `http://localhost:8378/1/files/${encodeURIComponent(filename)}`,
body: '<svg/>',
}).catch(e => {
throw new Error(e.data.error);
})
).toBeRejectedWith(jasmine.objectContaining({
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
}));
}
});

it('still allows trailing-dot filename with allowed Content-Type', async () => {
const adapter = Config.get('test').filesController.adapter;
const spy = spyOn(adapter, 'createFile').and.callThrough();
const response = await request({
method: 'POST',
url: 'http://localhost:8378/1/files/notes.txt.',
body: JSON.stringify({
_ApplicationId: 'test',
_JavaScriptKey: 'test',
_ContentType: 'text/plain',
base64: Buffer.from('hello').toString('base64'),
}),
headers,
});
expect(response.status).toBe(201);
expect(spy).toHaveBeenCalled();
});

it('FilesController treats trailing-dot filename as extensionless when appending derived extension via master key upload', async () => {
await reconfigureServer({
fileUpload: {
enableForPublic: true,
},
preserveFileName: true,
});
const adapter = Config.get('test').filesController.adapter;
const spy = spyOn(adapter, 'createFile').and.callThrough();
const response = await request({
method: 'POST',
url: 'http://localhost:8378/1/files/poc.svg.',
headers: {
'X-Parse-Application-Id': 'test',
'X-Parse-Master-Key': 'test',
'Content-Type': 'image/svg+xml',
},
body: '<svg/>',
});
expect(response.status).toBe(201);
expect(spy).toHaveBeenCalled();
const filenameArg = spy.calls.mostRecent().args[0];
const contentTypeArg = spy.calls.mostRecent().args[2];
// Trailing-dot filename is treated as extensionless: derived extension appended without doubling the dot
expect(filenameArg).toBe('poc.svg.svg');
// Caller-supplied Content-Type is preserved on the extensionless path
expect(contentTypeArg).toBe('image/svg+xml');
});

});

describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => {
const headers = {
'Content-Type': 'application/json',
Expand Down
9 changes: 5 additions & 4 deletions src/Controllers/FilesController.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
import { randomHexString } from '../cryptoUtils';
import AdaptableController from './AdaptableController';
import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter';
import path from 'path';
const Parse = require('parse/node').Parse;
const Utils = require('../Utils');

const legacyFilesRegex = new RegExp(
'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}-.*'
Expand All @@ -15,12 +15,13 @@ export class FilesController extends AdaptableController {
}

async createFile(config, filename, data, contentType, options) {
const extname = path.extname(filename);

const extname = Utils.getFileExtension(filename);
const hasExtension = extname.length > 0;
const mime = (await import('mime')).default
if (!hasExtension && contentType && mime.getExtension(contentType)) {
filename = filename + '.' + mime.getExtension(contentType);
// Avoid producing a doubled dot when the filename already ends in one
const separator = filename.endsWith('.') ? '' : '.';
filename = filename + separator + mime.getExtension(contentType);
} else if (hasExtension) {
contentType = mime.getType(filename) || contentType;
}
Expand Down
18 changes: 12 additions & 6 deletions src/Routers/FilesRouter.js
Original file line number Diff line number Diff line change
Expand Up @@ -423,14 +423,20 @@ export class FilesRouter {
}
});
};
let extension = contentType;
if (filename && filename.includes('.')) {
extension = filename.substring(filename.lastIndexOf('.') + 1);
} else if (contentType && contentType.includes('/')) {
extension = contentType.split('/')[1];
}
let extension = Utils.getFileExtension(filename);
// Strip MIME parameters (e.g. ";charset=utf-8") and whitespace
extension = extension?.split(';')[0]?.replace(/\s+/g, '');
// If the filename has no usable extension (no dot, trailing dot, or
// whitespace-only suffix), fall back to the Content-Type subtype — same
// as a dotless filename.
if (!extension && contentType && contentType.includes('/')) {
extension = contentType.split('/')[1]?.split(';')[0]?.replace(/\s+/g, '');
}
// Last resort for malformed inputs (e.g. Content-Type without a slash):
// use the raw Content-Type so the existing rejection path still fires.
if (!extension && contentType) {
extension = contentType.split(';')[0]?.replace(/\s+/g, '');
}

if (extension && !isValidExtension(extension)) {
next(
Expand Down
17 changes: 17 additions & 0 deletions src/Utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,23 @@ class Utils {
return Math.floor(num);
}
}

/**
* Returns the file extension as the substring after the last dot in the
* filename. A trailing dot or a filename without a dot yields an empty
* string. Callers apply any further normalization (whitespace, MIME
* parameters, etc.) for their use case — this is a pure parser, not a
* policy.
*
* @param {string} filename
* @returns {string} the extension, or `''` if none
*/
static getFileExtension(filename) {
if (!filename || !filename.includes('.')) {
return '';
}
return filename.substring(filename.lastIndexOf('.') + 1);
}
}

module.exports = Utils;
Loading