From eaddb1b6394d8daa5e97c5945ed087ded427722f Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Wed, 7 May 2025 20:22:17 -0500 Subject: [PATCH 1/4] feat: Properly handle proxy redirection --- integration/test/ParseServerTest.js | 19 ++++++++++++++ src/RESTController.ts | 37 +++++++++++++++++----------- src/__tests__/RESTController-test.js | 37 ++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 14 deletions(-) diff --git a/integration/test/ParseServerTest.js b/integration/test/ParseServerTest.js index 1554d47b9..0430e233c 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,20 @@ describe('ParseServer', () => { await object.save(); expect(object.id).toBeDefined(); }); + + it('can forward redirect', async () => { + http.createServer(function(_, res) { + res.writeHead(301, { Location: 'http://localhost:1337/parse' }); + res.end(); + }).listen(8080); + const serverURL = Parse.serverURL; + 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; + }); }); diff --git a/src/RESTController.ts b/src/RESTController.ts index 2f6cf5cac..c67b4e6d2 100644 --- a/src/RESTController.ts +++ b/src/RESTController.ts @@ -54,6 +54,13 @@ if (typeof XDomainRequest !== 'undefined' && !('withCredentials' in new XMLHttpR useXDomainRequest = true; } +function getPath(url: string, path: string) { + if (url[url.length - 1] !== '/') { + url += '/'; + } + return url + path; +} + function ajaxIE9(method: string, url: string, data: any, _headers?: any, options?: FullOptions) { return new Promise((resolve, reject) => { // @ts-ignore @@ -140,6 +147,7 @@ const RESTController = { method, headers, signal, + redirect: 'manual', }; if (data) { fetchOptions.body = data; @@ -189,6 +197,9 @@ const RESTController = { } else if (status >= 400 && status < 500) { const error = await response.json(); promise.reject(error); + } else if (status === 301 || status === 302 || status === 303 || status === 307) { + const location = response.headers.get('location'); + promise.resolve({ status, location, method: status === 303 ? 'GET' : method }); } else if (status >= 500 || status === 0) { // retry on 5XX or library error if (++attempts < CoreManager.get('REQUEST_ATTEMPT_LIMIT')) { @@ -221,12 +232,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 = {}; if (data && typeof data === 'object') { for (const k in data) { @@ -302,15 +308,18 @@ 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) { + const newURL = getPath(result.location, path); + result = await RESTController.ajax(result.method, newURL, payloadString, {}, options); + } + const { response, status, headers } = result; + if (options.returnStatus) { + return { ...response, _status: status, _headers: headers }; + } else { + return response; } - ); + }); }) .catch(RESTController.handleError); }, diff --git a/src/__tests__/RESTController-test.js b/src/__tests__/RESTController-test.js index f3c3b7817..c2569a245 100644 --- a/src/__tests__/RESTController-test.js +++ b/src/__tests__/RESTController-test.js @@ -433,4 +433,41 @@ 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', undefined); + }); }); From d5f86be4e3e04062cbf4c333bcb2280a3f490512 Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Wed, 7 May 2025 20:39:31 -0500 Subject: [PATCH 2/4] handle 308 redirect --- integration/test/ParseServerTest.js | 4 ++-- src/RESTController.ts | 11 ++++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/integration/test/ParseServerTest.js b/integration/test/ParseServerTest.js index 0430e233c..db2694e73 100644 --- a/integration/test/ParseServerTest.js +++ b/integration/test/ParseServerTest.js @@ -39,11 +39,11 @@ describe('ParseServer', () => { }); it('can forward redirect', async () => { + const serverURL = Parse.serverURL; http.createServer(function(_, res) { - res.writeHead(301, { Location: 'http://localhost:1337/parse' }); + res.writeHead(301, { Location: serverURL }); res.end(); }).listen(8080); - const serverURL = Parse.serverURL; Parse.CoreManager.set('SERVER_URL', 'http://localhost:8080/api'); const object = new TestObject({ foo: 'bar' }); await object.save(); diff --git a/src/RESTController.ts b/src/RESTController.ts index c67b4e6d2..02ae8b947 100644 --- a/src/RESTController.ts +++ b/src/RESTController.ts @@ -197,9 +197,14 @@ const RESTController = { } else if (status >= 400 && status < 500) { const error = await response.json(); promise.reject(error); - } else if (status === 301 || status === 302 || status === 303 || status === 307) { + } else if ([301, 302, 303, 307, 308].includes(status)) { const location = response.headers.get('location'); - promise.resolve({ status, location, method: status === 303 ? 'GET' : method }); + promise.resolve({ + status, + location, + method: status === 303 ? 'GET' : method, + body: status === 303 ? null : data, + }); } else if (status >= 500 || status === 0) { // retry on 5XX or library error if (++attempts < CoreManager.get('REQUEST_ATTEMPT_LIMIT')) { @@ -311,7 +316,7 @@ const RESTController = { return RESTController.ajax(method, url, payloadString, {}, options).then(async (result) => { if (result.location) { const newURL = getPath(result.location, path); - result = await RESTController.ajax(result.method, newURL, payloadString, {}, options); + result = await RESTController.ajax(result.method, newURL, result.body, {}, options); } const { response, status, headers } = result; if (options.returnStatus) { From 80958281fde08b061304a9a465498ea9a2e08a90 Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Wed, 7 May 2025 21:28:35 -0500 Subject: [PATCH 3/4] handle redirection loops --- integration/test/ParseServerTest.js | 3 ++- src/RESTController.ts | 30 ++++++++++++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/integration/test/ParseServerTest.js b/integration/test/ParseServerTest.js index db2694e73..b16f36405 100644 --- a/integration/test/ParseServerTest.js +++ b/integration/test/ParseServerTest.js @@ -40,7 +40,7 @@ describe('ParseServer', () => { it('can forward redirect', async () => { const serverURL = Parse.serverURL; - http.createServer(function(_, res) { + const redirectServer = http.createServer(function(_, res) { res.writeHead(301, { Location: serverURL }); res.end(); }).listen(8080); @@ -52,5 +52,6 @@ describe('ParseServer', () => { expect(result.id).toBe(object.id); expect(result.get('foo')).toBe('bar'); Parse.serverURL = serverURL; + redirectServer.close(); }); }); diff --git a/src/RESTController.ts b/src/RESTController.ts index 02ae8b947..251878e10 100644 --- a/src/RESTController.ts +++ b/src/RESTController.ts @@ -54,11 +54,14 @@ if (typeof XDomainRequest !== 'undefined' && !('withCredentials' in new XMLHttpR useXDomainRequest = true; } -function getPath(url: string, path: string) { - if (url[url.length - 1] !== '/') { - url += '/'; +function getPath(base: string, pathname: string) { + if (base.endsWith('/')) { + base = base.slice(0, -1); } - return url + path; + if (!pathname.startsWith('/')) { + pathname = '/' + pathname; + } + return base + pathname; } function ajaxIE9(method: string, url: string, data: any, _headers?: any, options?: FullOptions) { @@ -203,7 +206,7 @@ const RESTController = { status, location, method: status === 303 ? 'GET' : method, - body: status === 303 ? null : data, + dropBody: status === 303, }); } else if (status >= 500 || status === 0) { // retry on 5XX or library error @@ -315,8 +318,21 @@ const RESTController = { const payloadString = JSON.stringify(payload); return RESTController.ajax(method, url, payloadString, {}, options).then(async (result) => { if (result.location) { - const newURL = getPath(result.location, path); - result = await RESTController.ajax(result.method, newURL, result.body, {}, options); + 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) { From 147973eddb62945960e7b4d6e82b6b97886e95be Mon Sep 17 00:00:00 2001 From: Diamond Lewis Date: Wed, 7 May 2025 21:38:44 -0500 Subject: [PATCH 4/4] improve coverage --- src/__tests__/RESTController-test.js | 41 +++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/__tests__/RESTController-test.js b/src/__tests__/RESTController-test.js index c2569a245..947467ee5 100644 --- a/src/__tests__/RESTController-test.js +++ b/src/__tests__/RESTController-test.js @@ -468,6 +468,45 @@ describe('RESTController', () => { expect(result).toEqual({ success: true }); // Clean up the custom SERVER_URL - CoreManager.set('SERVER_URL', undefined); + 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'); }); });