@@ -1041,6 +1041,62 @@ describe('Pages Router', () => {
10411041 expect ( response . status ) . not . toBe ( 500 ) ;
10421042 } ) ;
10431043
1044+ it ( 'rejects locale parameter with path traversal sequences' , async ( ) => {
1045+ const pagesDir = path . join ( __dirname , 'tmp-pages-locale-test' ) ;
1046+ const targetDir = path . join ( __dirname , 'tmp-pages-locale-target' ) ;
1047+
1048+ try {
1049+ await fs . mkdir ( pagesDir , { recursive : true } ) ;
1050+ await fs . mkdir ( targetDir , { recursive : true } ) ;
1051+
1052+ // Copy required HTML files to pagesDir
1053+ const publicDir = path . resolve ( __dirname , '../public' ) ;
1054+ for ( const file of [ 'password_reset_link_invalid.html' , 'password_reset.html' ] ) {
1055+ const content = await fs . readFile ( path . join ( publicDir , file ) , 'utf-8' ) ;
1056+ await fs . writeFile ( path . join ( pagesDir , file ) , content ) ;
1057+ }
1058+
1059+ // Place a probe file in target directory
1060+ await fs . writeFile (
1061+ path . join ( targetDir , 'password_reset_link_invalid.html' ) ,
1062+ '<html><body>secret</body></html>'
1063+ ) ;
1064+
1065+ const traversalLocale = path . relative ( pagesDir , targetDir ) ;
1066+ await reconfigureServer ( {
1067+ ...config ,
1068+ pages : {
1069+ enableLocalization : true ,
1070+ pagesPath : pagesDir ,
1071+ } ,
1072+ } ) ;
1073+
1074+ // Without fix: file exists at traversed path → 404 (oracle)
1075+ // Without fix: file doesn't exist at traversed path → 200 (oracle)
1076+ // With fix: traversal locale is rejected, always returns default page → 200
1077+ const response = await request ( {
1078+ url : `${ config . publicServerURL } /apps/test/request_password_reset?token=x&locale=${ encodeURIComponent ( traversalLocale ) } ` ,
1079+ followRedirects : false ,
1080+ } ) . catch ( e => e ) ;
1081+
1082+ // Should serve the default page (200), not a 404 from bounds check
1083+ expect ( response . status ) . toBe ( 200 ) ;
1084+
1085+ // Now remove the probe file and try again — response should be the same
1086+ await fs . rm ( path . join ( targetDir , 'password_reset_link_invalid.html' ) ) ;
1087+ const response2 = await request ( {
1088+ url : `${ config . publicServerURL } /apps/test/request_password_reset?token=x&locale=${ encodeURIComponent ( traversalLocale ) } ` ,
1089+ followRedirects : false ,
1090+ } ) . catch ( e => e ) ;
1091+
1092+ // Should also be 200 — no difference reveals file existence
1093+ expect ( response2 . status ) . toBe ( 200 ) ;
1094+ } finally {
1095+ await fs . rm ( pagesDir , { recursive : true , force : true } ) ;
1096+ await fs . rm ( targetDir , { recursive : true , force : true } ) ;
1097+ }
1098+ } ) ;
1099+
10441100 it ( 'does not return 500 when page parameter contains CRLF characters' , async ( ) => {
10451101 await reconfigureServer ( config ) ;
10461102 const crlf = 'abc\r\nX-Injected: 1' ;
@@ -1444,15 +1500,17 @@ describe('Pages Router', () => {
14441500 expect ( response . text ) . toContain ( '<img' ) ;
14451501 } ) ;
14461502
1447- it ( 'should escape XSS in locale parameter' , async ( ) => {
1503+ it ( 'should reject XSS payload in locale parameter' , async ( ) => {
14481504 const xssLocale = '"><svg/onload=alert(1)>' ;
14491505 const response = await request ( {
14501506 url : `http://localhost:8378/1/apps/choose_password?locale=${ encodeURIComponent ( xssLocale ) } &appId=test` ,
14511507 } ) ;
14521508
14531509 expect ( response . status ) . toBe ( 200 ) ;
1510+ // Invalid locale is rejected by format validation, so the XSS
1511+ // payload never reaches the page content
14541512 expect ( response . text ) . not . toContain ( '<svg/onload=alert(1)>' ) ;
1455- expect ( response . text ) . toContain ( '"><svg' ) ;
1513+ expect ( response . text ) . not . toContain ( '"><svg' ) ;
14561514 } ) ;
14571515
14581516 it ( 'should handle legitimate usernames with quotes correctly' , async ( ) => {
0 commit comments