Repository: parse-community/Parse-SDK-JS
Pull request #2608 (2025-05-08 01:24:12)

fix: HTTP status code 3XX redirection for Parse Server URL not handled properly
## Pull Request

- Report security issues [confidentially](https://github.com/parse-community/Parse-SDK-JS/security/policy).
- Any contribution is under this [license](https://github.com/parse-community/Parse-SDK-JS/blob/alpha/LICENSE).
- Link this pull request to an [issue](https://github.com/parse-community/Parse-SDK-JS/issues?q=is%3Aissue).

## Issue
<!-- Add the link to the issue that this PR closes. -->
The SDK doesn't support 3XX redirection status codes

https://en.wikipedia.org/wiki/List_of_HTTP_status_codes#3xx_redirection

Closes: https://github.com/parse-community/Parse-SDK-JS/issues/1945

## Approach
<!-- Describe the changes in this PR. -->
* Resend request when new redirection url
* Handle redirection loops

## Tasks
<!-- Delete tasks that don't apply. -->

- [x] Add tests
- [x] Add changes to documentation (guides, repository pages, code comments)


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
  - Added support for manual handling of HTTP redirects in network requests, ensuring correct processing of redirects during server communication.

- **Bug Fixes**
  - Improved reliability when interacting with servers or proxies that return HTTP redirects.

- **Tests**
  - Introduced new test cases to verify correct handling of HTTP redirects in both general and batch request scenarios.
  - Added test to confirm redirect forwarding works correctly with object saving and querying.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

parse-github-assistant[bot]: 
🚀 Thanks for opening this pull request! 

<!-- parse-issue-bot-meta-tag-id {"suggestPr":false,"thanksFeature":false,"thanksPr":false} -->


coderabbitai[bot]: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- This is an auto-generated comment: review in progress by coderabbit.ai -->

> [!NOTE]
> Currently processing new changes in this PR. This may take a few minutes, please wait...
> 
> <details>
> <summary>📥 Commits</summary>
> 
> Reviewing files that changed from the base of the PR and between fc416dcdf8b5efeb4d90f2e0dbe589c04626b76c and eaddb1b6394d8daa5e97c5945ed087ded427722f.
> 
> </details>
> 
> <details>
> <summary>📒 Files selected for processing (3)</summary>
> 
> * `integration/test/ParseServerTest.js` (2 hunks)
> * `src/RESTController.ts` (5 hunks)
> * `src/__tests__/RESTController-test.js` (1 hunks)
> 
> </details>
> 
> ```ascii
>  ______________________________________________________________________________________________________________________
> < Adapting old programs to fit new machines usually means adapting new machines to behave like old ones. - Alan Perlis >
>  ----------------------------------------------------------------------------------------------------------------------
>   \
>    \   (\__/)
>        (•ㅅ•)
>        / 　 づ
> ```

<!-- end of auto-generated comment: review in progress by coderabbit.ai -->
<!-- usage_tips_start -->

> [!TIP]
> <details>
> <summary>You can disable sequence diagrams in the walkthrough.</summary>
> 
> Disable the `reviews.sequence_diagrams` setting in your project's settings in CodeRabbit to disable sequence diagrams in the walkthrough.
> 
> </details>

<!-- usage_tips_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=parse-community/Parse-SDK-JS&utm_content=2608):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/parse-community/Parse-SDK-JS/pull/2608?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=parse-community) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`fc416dc`)](https://app.codecov.io/gh/parse-community/Parse-SDK-JS/commit/fc416dcdf8b5efeb4d90f2e0dbe589c04626b76c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=parse-community) to head [(`eaddb1b`)](https://app.codecov.io/gh/parse-community/Parse-SDK-JS/commit/eaddb1b6394d8daa5e97c5945ed087ded427722f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=parse-community).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##             alpha     #2608   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           63        63           
  Lines         6174      6181    +7     
  Branches      1465      1468    +3     
=========================================
+ Hits          6174      6181    +7     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/parse-community/Parse-SDK-JS/pull/2608?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=parse-community).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=parse-community).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
- :package: [JS Bundle Analysis](https://docs.codecov.com/docs/javascript-bundle-analysis): Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
</details>
dplewis: @mtrezza coderabbit isn’t that bad
mtrezza: Changed PR title to bug fix, does that look right?
dplewis: Looks good!

Patch:
diff --git a/src/RESTController.ts b/src/RESTController.ts
index 2f6cf5c..251878e 100644
--- a/src/RESTController.ts
+++ b/src/RESTController.ts
@@ -54,6 +54,16 @@ if (typeof XDomainRequest !== 'undefined' && !('withCredentials' in new XMLHttpR
   useXDomainRequest = true;
 }
 
+function getPath(base: string, pathname: string) {
+  if (base.endsWith('/')) {
+    base = base.slice(0, -1);
+  }
+  if (!pathname.startsWith('/')) {
+    pathname = '/' + pathname;
+  }
+  return base + pathname;
+}
+
 function ajaxIE9(method: string, url: string, data: any, _headers?: any, options?: FullOptions) {
   return new Promise((resolve, reject) => {
     // @ts-ignore
@@ -140,6 +150,7 @@ const RESTController = {
           method,
           headers,
           signal,
+          redirect: 'manual',
         };
         if (data) {
           fetchOptions.body = data;
@@ -189,6 +200,14 @@ const RESTController = {
         } else if (status >= 400 && status < 500) {
           const error = await response.json();
           promise.reject(error);
+        } else if ([301, 302, 303, 307, 308].includes(status)) {
+          const location = response.headers.get('location');
+          promise.resolve({
+            status,
+            location,
+            method: status === 303 ? 'GET' : method,
+            dropBody: status === 303,
+          });
         } else if (status >= 500 || status === 0) {
           // retry on 5XX or library error
           if (++attempts < CoreManager.get('REQUEST_ATTEMPT_LIMIT')) {
@@ -221,12 +240,7 @@ const RESTController = {
 
   request(method: string, path: string, data: any, options?: RequestOptions) {
     options = options || {};
-    let url = CoreManager.get('SERVER_URL');
-    if (url[url.length - 1] !== '/') {
-      url += '/';
-    }
-    url += path;
-
+    const url = getPath(CoreManager.get('SERVER_URL'), path);
     const payload: Partial<PayloadType> = {};
     if (data && typeof data === 'object') {
       for (const k in data) {
@@ -302,15 +316,31 @@ const RESTController = {
         }
 
         const payloadString = JSON.stringify(payload);
-        return RESTController.ajax(method, url, payloadString, {}, options).then(
-          ({ response, status, headers }) => {
-            if (options.returnStatus) {
-              return { ...response, _status: status, _headers: headers };
-            } else {
-              return response;
+        return RESTController.ajax(method, url, payloadString, {}, options).then(async (result) => {
+          if (result.location) {
+            let newURL = getPath(result.location, path);
+            let newMethod = result.method;
+            let newBody = result.dropBody ? undefined : payloadString;
+
+            // Follow up to 5 redirects to avoid loops
+            for (let i = 0; i < 5; i += 1) {
+              const r = await RESTController.ajax(newMethod, newURL, newBody, {}, options);
+              if (!r.location) {
+                result = r;
+                break;
+              }
+              newURL = getPath(r.location, path);
+              newMethod = r.method;
+              newBody = r.dropBody ? undefined : payloadString;
             }
           }
-        );
+          const { response, status, headers } = result;
+          if (options.returnStatus) {
+            return { ...response, _status: status, _headers: headers };
+          } else {
+            return response;
+          }
+        });
       })
       .catch(RESTController.handleError);
   },


Test patch:
diff --git a/integration/test/ParseServerTest.js b/integration/test/ParseServerTest.js
index 1554d47..b16f364 100644
--- a/integration/test/ParseServerTest.js
+++ b/integration/test/ParseServerTest.js
@@ -1,5 +1,8 @@
 'use strict';
 
+const http = require('http');
+const Parse = require('../../node');
+
 describe('ParseServer', () => {
   it('can reconfigure server', async () => {
     let parseServer = await reconfigureServer({ serverURL: 'www.google.com' });
@@ -34,4 +37,21 @@ describe('ParseServer', () => {
     await object.save();
     expect(object.id).toBeDefined();
   });
+
+  it('can forward redirect', async () => {
+    const serverURL = Parse.serverURL;
+    const redirectServer = http.createServer(function(_, res) {
+      res.writeHead(301, { Location: serverURL });
+      res.end();
+    }).listen(8080);
+    Parse.CoreManager.set('SERVER_URL', 'http://localhost:8080/api');
+    const object = new TestObject({ foo: 'bar' });
+    await object.save();
+    const query = new Parse.Query(TestObject);
+    const result = await query.get(object.id);
+    expect(result.id).toBe(object.id);
+    expect(result.get('foo')).toBe('bar');
+    Parse.serverURL = serverURL;
+    redirectServer.close();
+  });
 });
diff --git a/src/__tests__/RESTController-test.js b/src/__tests__/RESTController-test.js
index f3c3b78..947467e 100644
--- a/src/__tests__/RESTController-test.js
+++ b/src/__tests__/RESTController-test.js
@@ -433,4 +433,80 @@ describe('RESTController', () => {
       _SessionToken: '1234',
     });
   });
+
+  it('follows HTTP redirects for batch requests when using a custom SERVER_URL', async () => {
+    // Configure a reverse-proxy style SERVER_URL
+    CoreManager.set('SERVER_URL', 'http://test.host/api');
+
+    // Prepare a minimal batch payload
+    const batchData = {
+      requests: [{
+        method: 'POST',
+        path: '/classes/TestObject',
+        body: { foo: 'bar' }
+      }]
+    };
+
+    // First response: 301 redirect to /parse/batch; second: successful response
+    mockFetch(
+      [
+        { status: 301, response: {} },
+        { status: 200, response: { success: true } }
+      ],
+      { location: 'http://test.host/parse/' }
+    );
+
+    // Issue the batch request
+    const result = await RESTController.request('POST', 'batch', batchData);
+
+    // We expect two fetch calls: one to the original URL, then one to the Location header
+    expect(fetch.mock.calls.length).toBe(2);
+    expect(fetch.mock.calls[0][0]).toEqual('http://test.host/api/batch');
+    expect(fetch.mock.calls[1][0]).toEqual('http://test.host/parse/batch');
+
+    // The final result should be the JSON from the second (successful) response
+    expect(result).toEqual({ success: true });
+
+    // Clean up the custom SERVER_URL
+    CoreManager.set('SERVER_URL', 'https://api.parse.com/1');
+  });
+
+  it('follows multiple HTTP redirects', async () => {
+    // Configure a reverse-proxy style SERVER_URL
+    CoreManager.set('SERVER_URL', 'http://test.host/api');
+
+    // Prepare a minimal batch payload
+    const batchData = {
+      requests: [{
+        method: 'POST',
+        path: '/classes/TestObject',
+        body: { foo: 'bar' }
+      }]
+    };
+
+    // First response: 301 redirect to /parse/batch; second: successful response
+    mockFetch(
+      [
+        { status: 301, response: {} },
+        { status: 301, response: {} },
+        { status: 200, response: { success: true } }
+      ],
+      { location: 'http://test.host/parse/' }
+    );
+
+    // Issue the batch request
+    const result = await RESTController.request('POST', 'batch', batchData);
+
+    // We expect three fetch calls: one to the original URL, then two to the Location header
+    expect(fetch.mock.calls.length).toBe(3);
+    expect(fetch.mock.calls[0][0]).toEqual('http://test.host/api/batch');
+    expect(fetch.mock.calls[1][0]).toEqual('http://test.host/parse/batch');
+    expect(fetch.mock.calls[2][0]).toEqual('http://test.host/parse/batch');
+
+    // The final result should be the JSON from the second (successful) response
+    expect(result).toEqual({ success: true });
+
+    // Clean up the custom SERVER_URL
+    CoreManager.set('SERVER_URL', 'https://api.parse.com/1');
+  });
 });