@@ -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 ( / F i l e u p l o a d o f e x t e n s i o n .+ i s d i s a b l e d / ) ,
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 ( / F i l e u p l o a d o f e x t e n s i o n .+ i s d i s a b l e d / ) ,
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 ( / F i l e u p l o a d o f e x t e n s i o n .+ i s d i s a b l e d / ) ,
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 ( / s v g | h t m l | x m l | x h t m l | x s l t | m a t h m l / 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 ( / F i l e u p l o a d o f e x t e n s i o n s v g i s d i s a b l e d / ) ,
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' ,
0 commit comments