@@ -840,6 +840,69 @@ describe('Pages Router', () => {
840840 followRedirects : false ,
841841 } ) ;
842842 expect ( formResponse . status ) . toEqual ( 303 ) ;
843+ // With emailVerifySuccessOnInvalidEmail: true (default), the resend
844+ // page always redirects to the success page to prevent user enumeration
845+ expect ( formResponse . text ) . toContain (
846+ `/${ locale } /${ pages . emailVerificationSendSuccess . defaultFile } `
847+ ) ;
848+ } ) ;
849+
850+ it ( 'localizes end-to-end for verify email: invalid verification link - link send fail with emailVerifySuccessOnInvalidEmail disabled' , async ( ) => {
851+ config . emailVerifySuccessOnInvalidEmail = false ;
852+ await reconfigureServer ( config ) ;
853+ const sendVerificationEmail = spyOn (
854+ config . emailAdapter ,
855+ 'sendVerificationEmail'
856+ ) . and . callThrough ( ) ;
857+ const user = new Parse . User ( ) ;
858+ user . setUsername ( 'exampleUsername' ) ;
859+ user . setPassword ( 'examplePassword' ) ;
860+ user . set ( 'email' , 'mail@example.com' ) ;
861+ await user . signUp ( ) ;
862+ await jasmine . timeout ( ) ;
863+
864+ const link = sendVerificationEmail . calls . all ( ) [ 0 ] . args [ 0 ] . link ;
865+ const linkWithLocale = new URL ( link ) ;
866+ linkWithLocale . searchParams . append ( pageParams . locale , exampleLocale ) ;
867+ linkWithLocale . searchParams . set ( pageParams . token , 'invalidToken' ) ;
868+
869+ const linkResponse = await request ( {
870+ url : linkWithLocale . toString ( ) ,
871+ followRedirects : false ,
872+ } ) ;
873+ expect ( linkResponse . status ) . toBe ( 200 ) ;
874+
875+ const appId = linkResponse . headers [ 'x-parse-page-param-appid' ] ;
876+ const locale = linkResponse . headers [ 'x-parse-page-param-locale' ] ;
877+ const publicServerUrl = linkResponse . headers [ 'x-parse-page-param-publicserverurl' ] ;
878+ await jasmine . timeout ( ) ;
879+
880+ const invalidVerificationPagePath = pageResponse . calls . all ( ) [ 0 ] . args [ 0 ] ;
881+ expect ( appId ) . toBeDefined ( ) ;
882+ expect ( locale ) . toBe ( exampleLocale ) ;
883+ expect ( publicServerUrl ) . toBeDefined ( ) ;
884+ expect ( invalidVerificationPagePath ) . toMatch (
885+ new RegExp ( `\/${ exampleLocale } \/${ pages . emailVerificationLinkInvalid . defaultFile } ` )
886+ ) ;
887+
888+ spyOn ( UserController . prototype , 'resendVerificationEmail' ) . and . callFake ( ( ) =>
889+ Promise . reject ( 'failed to resend verification email' )
890+ ) ;
891+
892+ const formUrl = `${ publicServerUrl } /apps/${ appId } /resend_verification_email` ;
893+ const formResponse = await request ( {
894+ url : formUrl ,
895+ method : 'POST' ,
896+ body : {
897+ locale,
898+ username : 'exampleUsername' ,
899+ } ,
900+ headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } ,
901+ followRedirects : false ,
902+ } ) ;
903+ expect ( formResponse . status ) . toEqual ( 303 ) ;
904+ // With emailVerifySuccessOnInvalidEmail: false, the resend page
905+ // redirects to the fail page
843906 expect ( formResponse . text ) . toContain (
844907 `/${ locale } /${ pages . emailVerificationSendFail . defaultFile } `
845908 ) ;
@@ -1041,86 +1104,79 @@ describe('Pages Router', () => {
10411104 expect ( response . status ) . not . toBe ( 500 ) ;
10421105 } ) ;
10431106
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 ) ;
1107+ it ( 'does not leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is true' , async ( ) => {
1108+ const emailAdapter = {
1109+ sendVerificationEmail : ( ) => { } ,
1110+ sendPasswordResetEmail : ( ) => { } ,
1111+ sendMail : ( ) => { } ,
1112+ } ;
1113+ await reconfigureServer ( {
1114+ ...config ,
1115+ verifyUserEmails : true ,
1116+ emailVerifySuccessOnInvalidEmail : true ,
1117+ emailAdapter,
1118+ } ) ;
10811119
1082- // Should serve the default page (200), not a 404 from bounds check
1083- expect ( response . status ) . toBe ( 200 ) ;
1120+ // Create a user with unverified email
1121+ const user = new Parse . User ( ) ;
1122+ user . setUsername ( 'realuser' ) ;
1123+ user . setPassword ( 'password123' ) ;
1124+ user . setEmail ( 'real@example.com' ) ;
1125+ await user . signUp ( ) ;
10841126
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 ) ;
1127+ const formUrl = `${ config . publicServerURL } /apps/${ config . appId } /resend_verification_email` ;
10911128
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- } ) ;
1129+ // Resend for existing unverified user
1130+ const existingResponse = await request ( {
1131+ method : 'POST' ,
1132+ url : formUrl ,
1133+ headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } ,
1134+ body : 'username=realuser' ,
1135+ followRedirects : false ,
1136+ } ) . catch ( e => e ) ;
10991137
1100- it ( 'does not return 500 when page parameter contains CRLF characters' , async ( ) => {
1101- await reconfigureServer ( config ) ;
1102- const crlf = 'abc\r\nX-Injected: 1' ;
1103- const url = ` ${ config . publicServerURL } /apps/choose_password?appId=test&token= ${ encodeURIComponent ( crlf ) } &username=testuser` ;
1104- const response = await request ( {
1105- url : url ,
1138+ // Resend for non-existing user
1139+ const nonExistingResponse = await request ( {
1140+ method : 'POST' ,
1141+ url : formUrl ,
1142+ headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } ,
1143+ body : 'username=fakeuser' ,
11061144 followRedirects : false ,
11071145 } ) . catch ( e => e ) ;
1108- expect ( response . status ) . not . toBe ( 500 ) ;
1109- expect ( response . status ) . toBe ( 200 ) ;
1146+
1147+ // Both should redirect to the same page (success) to prevent enumeration
1148+ expect ( existingResponse . status ) . toBe ( 303 ) ;
1149+ expect ( nonExistingResponse . status ) . toBe ( 303 ) ;
1150+ expect ( existingResponse . headers . location ) . toContain ( 'email_verification_send_success' ) ;
1151+ expect ( nonExistingResponse . headers . location ) . toContain ( 'email_verification_send_success' ) ;
11101152 } ) ;
11111153
1112- it ( 'does not return 500 when page parameter contains CRLF characters in redirect response' , async ( ) => {
1113- await reconfigureServer ( config ) ;
1114- const crlf = 'abc\r\nX-Injected: 1' ;
1115- const url = `${ config . publicServerURL } /apps/test/resend_verification_email` ;
1116- const response = await request ( {
1154+ it ( 'does leak email verification status via resend page when emailVerifySuccessOnInvalidEmail is false' , async ( ) => {
1155+ const emailAdapter = {
1156+ sendVerificationEmail : ( ) => { } ,
1157+ sendPasswordResetEmail : ( ) => { } ,
1158+ sendMail : ( ) => { } ,
1159+ } ;
1160+ await reconfigureServer ( {
1161+ ...config ,
1162+ verifyUserEmails : true ,
1163+ emailVerifySuccessOnInvalidEmail : false ,
1164+ emailAdapter,
1165+ } ) ;
1166+
1167+ const formUrl = `${ config . publicServerURL } /apps/${ config . appId } /resend_verification_email` ;
1168+
1169+ // Resend for non-existing user should redirect to fail page
1170+ const nonExistingResponse = await request ( {
11171171 method : 'POST' ,
1118- url : url ,
1172+ url : formUrl ,
11191173 headers : { 'Content-Type' : 'application/x-www-form-urlencoded' } ,
1120- body : ` username=${ encodeURIComponent ( crlf ) } ` ,
1174+ body : ' username=fakeuser' ,
11211175 followRedirects : false ,
11221176 } ) . catch ( e => e ) ;
1123- expect ( response . status ) . not . toBe ( 500 ) ;
1177+
1178+ expect ( nonExistingResponse . status ) . toBe ( 303 ) ;
1179+ expect ( nonExistingResponse . headers . location ) . toContain ( 'email_verification_send_fail' ) ;
11241180 } ) ;
11251181 } ) ;
11261182
0 commit comments