@@ -863,6 +863,220 @@ describe('rate limit', () => {
863863 } ) ;
864864 } ) ;
865865
866+ describe ( 'query string' , ( ) => {
867+ it ( 'enforces rate limit on an exact static path when a query string is appended' , async ( ) => {
868+ await reconfigureServer ( {
869+ rateLimit : [
870+ {
871+ requestPath : '/login' ,
872+ requestTimeWindow : 10000 ,
873+ requestCount : 1 ,
874+ errorResponseMessage : 'Too many login requests' ,
875+ includeInternalRequests : true ,
876+ } ,
877+ ] ,
878+ } ) ;
879+ await Parse . User . signUp ( 'rluser' , 'password' ) ;
880+ // First login attempt carrying a query string — reaches /login and consumes the single token.
881+ const res1 = await request ( {
882+ method : 'POST' ,
883+ headers,
884+ url : 'http://localhost:8378/1/login?bypass=1' ,
885+ body : JSON . stringify ( { username : 'rluser' , password : 'wrong' } ) ,
886+ } ) . catch ( e => e ) ;
887+ expect ( res1 . status ) . toBe ( 404 ) ;
888+ expect ( res1 . data . code ) . toBe ( Parse . Error . OBJECT_NOT_FOUND ) ;
889+ // Second login attempt with a different query string — must be rate limited, not bypassed.
890+ const res2 = await request ( {
891+ method : 'POST' ,
892+ headers,
893+ url : 'http://localhost:8378/1/login?bypass=2' ,
894+ body : JSON . stringify ( { username : 'rluser' , password : 'wrong' } ) ,
895+ } ) . catch ( e => e ) ;
896+ expect ( res2 . status ) . toBe ( 429 ) ;
897+ expect ( res2 . data ) . toEqual ( {
898+ code : Parse . Error . CONNECTION_FAILED ,
899+ error : 'Too many login requests' ,
900+ } ) ;
901+ } ) ;
902+
903+ it ( 'enforces rate limit on GET login when credentials are sent as query parameters' , async ( ) => {
904+ await reconfigureServer ( {
905+ rateLimit : [
906+ {
907+ requestPath : '/login' ,
908+ requestMethods : [ 'GET' ] ,
909+ requestTimeWindow : 10000 ,
910+ requestCount : 1 ,
911+ errorResponseMessage : 'Too many login requests' ,
912+ includeInternalRequests : true ,
913+ } ,
914+ ] ,
915+ } ) ;
916+ await Parse . User . signUp ( 'rluser' , 'password' ) ;
917+ // GET login carries credentials in the query string; the limiter must still match.
918+ const res1 = await request ( {
919+ method : 'GET' ,
920+ headers,
921+ url : 'http://localhost:8378/1/login?username=rluser&password=wrong&r=1' ,
922+ } ) . catch ( e => e ) ;
923+ expect ( res1 . status ) . toBe ( 404 ) ;
924+ const res2 = await request ( {
925+ method : 'GET' ,
926+ headers,
927+ url : 'http://localhost:8378/1/login?username=rluser&password=wrong&r=2' ,
928+ } ) . catch ( e => e ) ;
929+ expect ( res2 . status ) . toBe ( 429 ) ;
930+ expect ( res2 . data ) . toEqual ( {
931+ code : Parse . Error . CONNECTION_FAILED ,
932+ error : 'Too many login requests' ,
933+ } ) ;
934+ } ) ;
935+
936+ it ( 'counts query-string and plain requests against the same rate limit window' , async ( ) => {
937+ await reconfigureServer ( {
938+ rateLimit : [
939+ {
940+ requestPath : '/login' ,
941+ requestTimeWindow : 10000 ,
942+ requestCount : 1 ,
943+ errorResponseMessage : 'Too many login requests' ,
944+ includeInternalRequests : true ,
945+ } ,
946+ ] ,
947+ } ) ;
948+ await Parse . User . signUp ( 'rluser' , 'password' ) ;
949+ // A plain request consumes the single token.
950+ const res1 = await request ( {
951+ method : 'POST' ,
952+ headers,
953+ url : 'http://localhost:8378/1/login' ,
954+ body : JSON . stringify ( { username : 'rluser' , password : 'wrong' } ) ,
955+ } ) . catch ( e => e ) ;
956+ expect ( res1 . status ) . toBe ( 404 ) ;
957+ // A subsequent request that appends a query string must draw from the same window.
958+ const res2 = await request ( {
959+ method : 'POST' ,
960+ headers,
961+ url : 'http://localhost:8378/1/login?bypass=1' ,
962+ body : JSON . stringify ( { username : 'rluser' , password : 'wrong' } ) ,
963+ } ) . catch ( e => e ) ;
964+ expect ( res2 . status ) . toBe ( 429 ) ;
965+ expect ( res2 . data ) . toEqual ( {
966+ code : Parse . Error . CONNECTION_FAILED ,
967+ error : 'Too many login requests' ,
968+ } ) ;
969+ } ) ;
970+
971+ it ( 'does not let a batch sub-request reach an exact static route by appending a query string' , async ( ) => {
972+ await reconfigureServer ( {
973+ rateLimit : [
974+ {
975+ requestPath : '/login' ,
976+ requestTimeWindow : 10000 ,
977+ requestCount : 1 ,
978+ errorResponseMessage : 'Too many login requests' ,
979+ includeInternalRequests : true ,
980+ } ,
981+ ] ,
982+ } ) ;
983+ await Parse . User . signUp ( 'rluser' , 'password' ) ;
984+ // A query-string sub-request path is not normalized to /login: it fails to route
985+ // (the limiter check and the router agree), so it cannot bypass the limiter.
986+ const response = await request ( {
987+ method : 'POST' ,
988+ headers,
989+ url : 'http://localhost:8378/1/batch' ,
990+ body : JSON . stringify ( {
991+ requests : [
992+ { method : 'POST' , path : '/1/login?bypass=1' , body : { username : 'rluser' , password : 'wrong' } } ,
993+ { method : 'POST' , path : '/1/login?bypass=2' , body : { username : 'rluser' , password : 'wrong' } } ,
994+ ] ,
995+ } ) ,
996+ } ) . catch ( e => e ) ;
997+ // The query string is preserved in the sub-request path (path.posix.join does not
998+ // strip it), so the router finds no route for `/login?bypass=1`; tryRouteRequest
999+ // throws synchronously and aborts the whole batch instead of reaching /login. The
1000+ // sub-request therefore cannot bypass the limiter.
1001+ expect ( response . status ) . toBe ( 400 ) ;
1002+ expect ( response . data . code ) . toBe ( Parse . Error . INVALID_JSON ) ;
1003+ expect ( response . data . error ) . toContain ( 'cannot route' ) ;
1004+ } ) ;
1005+
1006+ it ( 'enforces rate limit on requestPasswordReset when a query string is appended' , async ( ) => {
1007+ await reconfigureServer ( {
1008+ rateLimit : [
1009+ {
1010+ requestPath : '/requestPasswordReset' ,
1011+ requestTimeWindow : 10000 ,
1012+ requestCount : 1 ,
1013+ errorResponseMessage : 'Too many reset requests' ,
1014+ includeInternalRequests : true ,
1015+ } ,
1016+ ] ,
1017+ } ) ;
1018+ // First reset request carrying a query string reaches the handler and consumes the
1019+ // single token; the handler's own outcome is irrelevant — only that it is counted.
1020+ const res1 = await request ( {
1021+ method : 'POST' ,
1022+ headers,
1023+ url : 'http://localhost:8378/1/requestPasswordReset?bypass=1' ,
1024+ body : JSON . stringify ( { email : 'nobody@example.com' } ) ,
1025+ } ) . catch ( e => e ) ;
1026+ expect ( res1 . status ) . not . toBe ( 429 ) ;
1027+ // Second reset request with a different query string must be rate limited.
1028+ const res2 = await request ( {
1029+ method : 'POST' ,
1030+ headers,
1031+ url : 'http://localhost:8378/1/requestPasswordReset?bypass=2' ,
1032+ body : JSON . stringify ( { email : 'nobody@example.com' } ) ,
1033+ } ) . catch ( e => e ) ;
1034+ expect ( res2 . status ) . toBe ( 429 ) ;
1035+ expect ( res2 . data ) . toEqual ( {
1036+ code : Parse . Error . CONNECTION_FAILED ,
1037+ error : 'Too many reset requests' ,
1038+ } ) ;
1039+ } ) ;
1040+
1041+ it ( 'does not split the user-zone rate limit window for /sessions/me via a query string' , async ( ) => {
1042+ await reconfigureServer ( {
1043+ rateLimit : [
1044+ {
1045+ requestPath : '/sessions/me' ,
1046+ requestTimeWindow : 10000 ,
1047+ requestCount : 1 ,
1048+ zone : Parse . Server . RateLimitZone . user ,
1049+ errorResponseMessage : 'Too many session requests' ,
1050+ includeInternalRequests : true ,
1051+ } ,
1052+ ] ,
1053+ } ) ;
1054+ const user = await Parse . User . signUp ( 'rluser' , 'password' ) ;
1055+ const sessionToken = user . getSessionToken ( ) ;
1056+ const authHeaders = { ...headers , 'X-Parse-Session-Token' : sessionToken } ;
1057+ // First read consumes the single token. The user-zone key resolves to the caller's IP
1058+ // here because the /sessions/me GET branch skips session resolution in the keyGenerator.
1059+ const res1 = await request ( {
1060+ method : 'GET' ,
1061+ headers : authHeaders ,
1062+ url : 'http://localhost:8378/1/sessions/me' ,
1063+ } ) . catch ( e => e ) ;
1064+ expect ( res1 . status ) . toBe ( 200 ) ;
1065+ // Appending a query string must not move the request into a separate window keyed by
1066+ // user id; it must draw from the same window and be rate limited.
1067+ const res2 = await request ( {
1068+ method : 'GET' ,
1069+ headers : authHeaders ,
1070+ url : 'http://localhost:8378/1/sessions/me?bypass=1' ,
1071+ } ) . catch ( e => e ) ;
1072+ expect ( res2 . status ) . toBe ( 429 ) ;
1073+ expect ( res2 . data ) . toEqual ( {
1074+ code : Parse . Error . CONNECTION_FAILED ,
1075+ error : 'Too many session requests' ,
1076+ } ) ;
1077+ } ) ;
1078+ } ) ;
1079+
8661080 describe ( 'method override bypass' , ( ) => {
8671081 it ( 'should enforce rate limit when _method override attempts to change POST to GET' , async ( ) => {
8681082 Parse . Cloud . beforeLogin ( ( ) => { } , {
0 commit comments