Skip to content

Commit 66484ce

Browse files
authored
fix: Stored XSS via trailing-dot filename bypassing file upload extension blocklist ([GHSA-7wqv-xjf3-x35v](GHSA-7wqv-xjf3-x35v)) (parse-community#10489)
1 parent 1e0d6ce commit 66484ce

5 files changed

Lines changed: 223 additions & 10 deletions

File tree

spec/Utils.spec.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,4 +447,25 @@ describe('Utils', () => {
447447
expect(Utils.isObject(true)).toBe(false);
448448
});
449449
});
450+
451+
describe('getFileExtension', () => {
452+
const cases = [
453+
['file.txt', 'txt'],
454+
['file.tar.gz', 'gz'],
455+
['.hidden', 'hidden'],
456+
['file.', ''],
457+
['file..', ''],
458+
['file', ''],
459+
['', ''],
460+
[null, ''],
461+
[undefined, ''],
462+
['poc.svg.', ''],
463+
['archive.tar.gz.', ''],
464+
];
465+
for (const [input, expected] of cases) {
466+
it(`returns ${JSON.stringify(expected)} for ${JSON.stringify(input)}`, () => {
467+
expect(Utils.getFileExtension(input)).toBe(expected);
468+
});
469+
}
470+
});
450471
});

spec/vulnerabilities.spec.js

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1749,6 +1749,174 @@ describe('Vulnerabilities', () => {
17491749
});
17501750
});
17511751

1752+
describe('(GHSA-7wqv-xjf3-x35v) Stored XSS via trailing-dot filename bypassing file extension blocklist', () => {
1753+
const headers = {
1754+
'X-Parse-Application-Id': 'test',
1755+
'X-Parse-REST-API-Key': 'rest',
1756+
};
1757+
1758+
beforeEach(async () => {
1759+
await reconfigureServer({
1760+
fileUpload: {
1761+
enableForPublic: true,
1762+
},
1763+
});
1764+
});
1765+
1766+
it('blocks trailing-dot SVG filename with dangerous _ContentType on JSON-body upload', async () => {
1767+
const svgContent = Buffer.from(
1768+
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>'
1769+
).toString('base64');
1770+
// No X-Parse-Application-Id header — must be in JSON body to trigger
1771+
// _ContentType extraction via the fileViaJSON middleware path.
1772+
await expectAsync(
1773+
request({
1774+
method: 'POST',
1775+
url: 'http://localhost:8378/1/files/poc.svg.',
1776+
body: JSON.stringify({
1777+
_ApplicationId: 'test',
1778+
_JavaScriptKey: 'test',
1779+
_ContentType: 'image/svg+xml',
1780+
base64: svgContent,
1781+
}),
1782+
}).catch(e => {
1783+
throw new Error(e.data.error);
1784+
})
1785+
).toBeRejectedWith(jasmine.objectContaining({
1786+
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
1787+
}));
1788+
});
1789+
1790+
it('blocks trailing-dot SVG filename with dangerous Content-Type on binary upload', async () => {
1791+
await expectAsync(
1792+
request({
1793+
method: 'POST',
1794+
headers: {
1795+
...headers,
1796+
'Content-Type': 'image/svg+xml',
1797+
},
1798+
url: 'http://localhost:8378/1/files/poc.svg.',
1799+
body: '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
1800+
}).catch(e => {
1801+
throw new Error(e.data.error);
1802+
})
1803+
).toBeRejectedWith(jasmine.objectContaining({
1804+
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
1805+
}));
1806+
});
1807+
1808+
it('blocks filename with mixed trailing dots and whitespace', async () => {
1809+
for (const filename of ['poc.svg..', 'poc.svg. ', 'poc.svg . ']) {
1810+
await expectAsync(
1811+
request({
1812+
method: 'POST',
1813+
headers: {
1814+
...headers,
1815+
'Content-Type': 'image/svg+xml',
1816+
},
1817+
url: `http://localhost:8378/1/files/${encodeURIComponent(filename)}`,
1818+
body: '<svg/>',
1819+
}).catch(e => {
1820+
throw new Error(e.data.error);
1821+
})
1822+
).toBeRejectedWith(jasmine.objectContaining({
1823+
message: jasmine.stringMatching(/File upload of extension .+ is disabled/),
1824+
}));
1825+
}
1826+
});
1827+
1828+
it('still allows trailing-dot filename with allowed Content-Type', async () => {
1829+
const adapter = Config.get('test').filesController.adapter;
1830+
const spy = spyOn(adapter, 'createFile').and.callThrough();
1831+
const response = await request({
1832+
method: 'POST',
1833+
url: 'http://localhost:8378/1/files/notes.txt.',
1834+
body: JSON.stringify({
1835+
_ApplicationId: 'test',
1836+
_JavaScriptKey: 'test',
1837+
_ContentType: 'text/plain',
1838+
base64: Buffer.from('hello').toString('base64'),
1839+
}),
1840+
headers,
1841+
});
1842+
expect(response.status).toBe(201);
1843+
expect(spy).toHaveBeenCalled();
1844+
});
1845+
1846+
it('FilesController treats trailing-dot filename as extensionless when appending derived extension via master key upload', async () => {
1847+
await reconfigureServer({
1848+
fileUpload: {
1849+
enableForPublic: true,
1850+
},
1851+
preserveFileName: true,
1852+
});
1853+
const adapter = Config.get('test').filesController.adapter;
1854+
const spy = spyOn(adapter, 'createFile').and.callThrough();
1855+
const response = await request({
1856+
method: 'POST',
1857+
url: 'http://localhost:8378/1/files/poc.svg.',
1858+
headers: {
1859+
'X-Parse-Application-Id': 'test',
1860+
'X-Parse-Master-Key': 'test',
1861+
'Content-Type': 'image/svg+xml',
1862+
},
1863+
body: '<svg/>',
1864+
});
1865+
expect(response.status).toBe(201);
1866+
expect(spy).toHaveBeenCalled();
1867+
const filenameArg = spy.calls.mostRecent().args[0];
1868+
const contentTypeArg = spy.calls.mostRecent().args[2];
1869+
// Trailing-dot filename is treated as extensionless: derived extension appended without doubling the dot
1870+
expect(filenameArg).toBe('poc.svg.svg');
1871+
// Caller-supplied Content-Type is preserved on the extensionless path
1872+
expect(contentTypeArg).toBe('image/svg+xml');
1873+
});
1874+
1875+
it('allows trailing-dot filename when no Content-Type is supplied (no XSS path)', async () => {
1876+
// Trailing-dot filename with no caller-supplied Content-Type: the
1877+
// blocklist gate skips because no extension can be determined, but no
1878+
// attacker-controlled Content-Type reaches the storage adapter — only
1879+
// the SDK's benign default — so no stored XSS is possible.
1880+
const adapter = Config.get('test').filesController.adapter;
1881+
const spy = spyOn(adapter, 'createFile').and.callThrough();
1882+
const response = await request({
1883+
method: 'POST',
1884+
headers: {
1885+
'X-Parse-Application-Id': 'test',
1886+
'X-Parse-REST-API-Key': 'rest',
1887+
},
1888+
url: 'http://localhost:8378/1/files/poc.svg.',
1889+
body: '<svg/>',
1890+
});
1891+
expect(response.status).toBe(201);
1892+
expect(spy).toHaveBeenCalled();
1893+
const contentTypeArg = spy.calls.mostRecent().args[2];
1894+
expect(contentTypeArg).not.toMatch(/svg|html|xml|xhtml|xslt|mathml/i);
1895+
});
1896+
1897+
it('falls back to raw Content-Type when Content-Type is malformed (no slash)', async () => {
1898+
// Exercises the last-resort branch: when both the filename has no usable
1899+
// extension AND the Content-Type lacks a "/" subtype to parse, the raw
1900+
// Content-Type is used as the extension so a malformed header that
1901+
// matches a blocked pattern still trips the blocklist.
1902+
await expectAsync(
1903+
request({
1904+
method: 'POST',
1905+
headers: {
1906+
...headers,
1907+
'Content-Type': 'svg',
1908+
},
1909+
url: 'http://localhost:8378/1/files/poc',
1910+
body: '<svg/>',
1911+
}).catch(e => {
1912+
throw new Error(e.data.error);
1913+
})
1914+
).toBeRejectedWith(jasmine.objectContaining({
1915+
message: jasmine.stringMatching(/File upload of extension svg is disabled/),
1916+
}));
1917+
});
1918+
});
1919+
17521920
describe('(GHSA-q3vj-96h2-gwvg) SQL Injection via Increment amount on nested Object field', () => {
17531921
const headers = {
17541922
'Content-Type': 'application/json',

src/Controllers/FilesController.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
import { randomHexString } from '../cryptoUtils';
33
import AdaptableController from './AdaptableController';
44
import { validateFilename, FilesAdapter } from '../Adapters/Files/FilesAdapter';
5-
import path from 'path';
65
const Parse = require('parse/node').Parse;
6+
const Utils = require('../Utils');
77

88
const legacyFilesRegex = new RegExp(
99
'^[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}-.*'
@@ -15,12 +15,13 @@ export class FilesController extends AdaptableController {
1515
}
1616

1717
async createFile(config, filename, data, contentType, options) {
18-
const extname = path.extname(filename);
19-
18+
const extname = Utils.getFileExtension(filename);
2019
const hasExtension = extname.length > 0;
2120
const mime = (await import('mime')).default
2221
if (!hasExtension && contentType && mime.getExtension(contentType)) {
23-
filename = filename + '.' + mime.getExtension(contentType);
22+
// Avoid producing a doubled dot when the filename already ends in one
23+
const separator = filename.endsWith('.') ? '' : '.';
24+
filename = filename + separator + mime.getExtension(contentType);
2425
} else if (hasExtension) {
2526
contentType = mime.getType(filename) || contentType;
2627
}

src/Routers/FilesRouter.js

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -423,14 +423,20 @@ export class FilesRouter {
423423
}
424424
});
425425
};
426-
let extension = contentType;
427-
if (filename && filename.includes('.')) {
428-
extension = filename.substring(filename.lastIndexOf('.') + 1);
429-
} else if (contentType && contentType.includes('/')) {
430-
extension = contentType.split('/')[1];
431-
}
426+
let extension = Utils.getFileExtension(filename);
432427
// Strip MIME parameters (e.g. ";charset=utf-8") and whitespace
433428
extension = extension?.split(';')[0]?.replace(/\s+/g, '');
429+
// If the filename has no usable extension (no dot, trailing dot, or
430+
// whitespace-only suffix), fall back to the Content-Type subtype — same
431+
// as a dotless filename.
432+
if (!extension && contentType && contentType.includes('/')) {
433+
extension = contentType.split('/')[1]?.split(';')[0]?.replace(/\s+/g, '');
434+
}
435+
// Last resort for malformed inputs (e.g. Content-Type without a slash):
436+
// use the raw Content-Type so the existing rejection path still fires.
437+
if (!extension && contentType) {
438+
extension = contentType.split(';')[0]?.replace(/\s+/g, '');
439+
}
434440

435441
if (extension && !isValidExtension(extension)) {
436442
next(

src/Utils.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,23 @@ class Utils {
576576
return Math.floor(num);
577577
}
578578
}
579+
580+
/**
581+
* Returns the file extension as the substring after the last dot in the
582+
* filename. A trailing dot or a filename without a dot yields an empty
583+
* string. Callers apply any further normalization (whitespace, MIME
584+
* parameters, etc.) for their use case — this is a pure parser, not a
585+
* policy.
586+
*
587+
* @param {string} filename
588+
* @returns {string} the extension, or `''` if none
589+
*/
590+
static getFileExtension(filename) {
591+
if (!filename || !filename.includes('.')) {
592+
return '';
593+
}
594+
return filename.substring(filename.lastIndexOf('.') + 1);
595+
}
579596
}
580597

581598
module.exports = Utils;

0 commit comments

Comments
 (0)