diff --git a/DEPRECATIONS.md b/DEPRECATIONS.md index f731644914..c3a2f6195d 100644 --- a/DEPRECATIONS.md +++ b/DEPRECATIONS.md @@ -1,23 +1,23 @@ # Deprecation Plan -The following is a list of deprecations, according to the [Deprecation Policy](https://github.com/parse-community/parse-server/blob/master/CONTRIBUTING.md#deprecation-policy). After a feature becomes deprecated, and giving developers time to adapt to the change, the deprecated feature will eventually be removed, leading to a breaking change. Developer feedback during the deprecation period may postpone or even revoke the introduction of the breaking change. +The following is a list of deprecations, according to the [Deprecation Policy](https://github.com/parse-community/parse-server/blob/master/CONTRIBUTING.md#deprecation-policy). After a feature becomes deprecated, and giving developers time to adapt to the change, the deprecated feature will eventually be changed, leading to a breaking change. Developer feedback during the deprecation period may postpone or even revoke the introduction of the breaking change. -| ID | Change | Issue | Deprecation [ℹ️][i_deprecation] | Planned Removal [ℹ️][i_removal] | Status [ℹ️][i_status] | Notes | +| ID | Change | Issue | Deprecation [ℹ️][i_deprecation] | Planned Change [ℹ️][i_change] | Status [ℹ️][i_status] | Notes | |---------|----------------------------------------------------------------------------------------------|----------------------------------------------------------------------|---------------------------------|---------------------------------|-----------------------|-------| -| DEPPS1 | Native MongoDB syntax in aggregation pipeline | [#7338](https://github.com/parse-community/parse-server/issues/7338) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - | -| DEPPS2 | Config option `directAccess` defaults to `true` | [#6636](https://github.com/parse-community/parse-server/pull/6636) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - | -| DEPPS3 | Config option `enforcePrivateUsers` defaults to `true` | [#7319](https://github.com/parse-community/parse-server/pull/7319) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - | -| DEPPS4 | Remove convenience method for http request `Parse.Cloud.httpRequest` | [#7589](https://github.com/parse-community/parse-server/pull/7589) | 5.0.0 (2022) | 6.0.0 (2023) | removed | - | -| DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - | -| DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - | -| DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - | -| DEPPS8 | Login with expired 3rd party authentication token defaults to `false` | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | removed | - | -| DEPPS9 | Rename LiveQuery `fields` option to `keys` | [#8389](https://github.com/parse-community/parse-server/issues/8389) | 6.0.0 (2023) | 7.0.0 (2024) | removed | - | -| DEPPS10 | Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 9.0.0 (2026) | removed | - | -| DEPPS11 | Replace `PublicAPIRouter` with `PagesRouter` | [#7625](https://github.com/parse-community/parse-server/issues/7625) | 8.0.0 (2025) | 9.0.0 (2026) | removed | - | -| DEPPS12 | Database option `allowPublicExplain` defaults to `false` | [#7519](https://github.com/parse-community/parse-server/issues/7519) | 8.5.0 (2025) | 9.0.0 (2026) | removed | - | -| DEPPS13 | Config option `enableInsecureAuthAdapters` defaults to `false` | [#9667](https://github.com/parse-community/parse-server/pull/9667) | 8.0.0 (2025) | 9.0.0 (2026) | removed | - | +| DEPPS1 | Native MongoDB syntax in aggregation pipeline | [#7338](https://github.com/parse-community/parse-server/issues/7338) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - | +| DEPPS2 | Config option `directAccess` defaults to `true` | [#6636](https://github.com/parse-community/parse-server/pull/6636) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - | +| DEPPS3 | Config option `enforcePrivateUsers` defaults to `true` | [#7319](https://github.com/parse-community/parse-server/pull/7319) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - | +| DEPPS4 | Remove convenience method for http request `Parse.Cloud.httpRequest` | [#7589](https://github.com/parse-community/parse-server/pull/7589) | 5.0.0 (2022) | 6.0.0 (2023) | changed | - | +| DEPPS5 | Config option `allowClientClassCreation` defaults to `false` | [#7925](https://github.com/parse-community/parse-server/pull/7925) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - | +| DEPPS6 | Auth providers disabled by default | [#7953](https://github.com/parse-community/parse-server/pull/7953) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - | +| DEPPS7 | Remove file trigger syntax `Parse.Cloud.beforeSaveFile((request) => {})` | [#7966](https://github.com/parse-community/parse-server/pull/7966) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - | +| DEPPS8 | Login with expired 3rd party authentication token defaults to `false` | [#7079](https://github.com/parse-community/parse-server/pull/7079) | 5.3.0 (2022) | 7.0.0 (2024) | changed | - | +| DEPPS9 | Rename LiveQuery `fields` option to `keys` | [#8389](https://github.com/parse-community/parse-server/issues/8389) | 6.0.0 (2023) | 7.0.0 (2024) | changed | - | +| DEPPS10 | Encode `Parse.Object` in Cloud Function and remove option `encodeParseObjectInCloudFunction` | [#8634](https://github.com/parse-community/parse-server/issues/8634) | 6.2.0 (2023) | 9.0.0 (2026) | changed | - | +| DEPPS11 | Replace `PublicAPIRouter` with `PagesRouter` | [#7625](https://github.com/parse-community/parse-server/issues/7625) | 8.0.0 (2025) | 9.0.0 (2026) | changed | - | +| DEPPS12 | Database option `allowPublicExplain` defaults to `false` | [#7519](https://github.com/parse-community/parse-server/issues/7519) | 8.5.0 (2025) | 9.0.0 (2026) | changed | - | +| DEPPS13 | Config option `enableInsecureAuthAdapters` defaults to `false` | [#9667](https://github.com/parse-community/parse-server/pull/9667) | 8.0.0 (2025) | 9.0.0 (2026) | changed | - | [i_deprecation]: ## "The version and date of the deprecation." -[i_removal]: ## "The version and date of the planned removal." -[i_status]: ## "The current status of the deprecation: deprecated (the feature is deprecated and still available), removed (the deprecated feature has been removed and is unavailable), retracted (the deprecation has been retracted and the feature will not be removed." +[i_change]: ## "The version and date of the planned change." +[i_status]: ## "The current status of the deprecation: deprecated (the feature is deprecated but still available), changed (the deprecated feature has been changed), retracted (the deprecation has been retracted and the feature will not be changed." diff --git a/changelogs/CHANGELOG_alpha.md b/changelogs/CHANGELOG_alpha.md index e689b543c2..f81601fd15 100644 --- a/changelogs/CHANGELOG_alpha.md +++ b/changelogs/CHANGELOG_alpha.md @@ -1,3 +1,31 @@ +# [9.1.0-alpha.4](https://github.com/parse-community/parse-server/compare/9.1.0-alpha.3...9.1.0-alpha.4) (2025-12-14) + + +### Features + +* Log more debug info when failing to set duplicate value for field with unique values ([#9919](https://github.com/parse-community/parse-server/issues/9919)) ([a23b192](https://github.com/parse-community/parse-server/commit/a23b1924668920f3c92fec0566b57091d0e8aae8)) + +# [9.1.0-alpha.3](https://github.com/parse-community/parse-server/compare/9.1.0-alpha.2...9.1.0-alpha.3) (2025-12-14) + + +### Bug Fixes + +* Cross-Site Scripting (XSS) via HTML pages for password reset and email verification [GHSA-jhgf-2h8h-ggxv](https://github.com/parse-community/parse-server/security/advisories/GHSA-jhgf-2h8h-ggxv) ([#9985](https://github.com/parse-community/parse-server/issues/9985)) ([3074eb7](https://github.com/parse-community/parse-server/commit/3074eb70f5b58bf72b528ae7b7804ed2d90455ce)) + +# [9.1.0-alpha.2](https://github.com/parse-community/parse-server/compare/9.1.0-alpha.1...9.1.0-alpha.2) (2025-12-14) + + +### Features + +* Add support for custom HTTP status code and headers to Cloud Function response with Express-style syntax ([#9980](https://github.com/parse-community/parse-server/issues/9980)) ([8eeab8d](https://github.com/parse-community/parse-server/commit/8eeab8dc57edef3751aa188d8247f296a270b083)) + +# [9.1.0-alpha.1](https://github.com/parse-community/parse-server/compare/9.0.0...9.1.0-alpha.1) (2025-12-14) + + +### Features + +* Add option `logLevels.signupUsernameTaken` to change log level of username already exists sign-up rejection ([#9962](https://github.com/parse-community/parse-server/issues/9962)) ([f18f307](https://github.com/parse-community/parse-server/commit/f18f3073d70a292bc70b5d572ef58e4845de89ca)) + # [9.0.0-alpha.11](https://github.com/parse-community/parse-server/compare/9.0.0-alpha.10...9.0.0-alpha.11) (2025-12-14) diff --git a/package-lock.json b/package-lock.json index a27676b0ad..40481456e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "parse-server", - "version": "9.0.0", + "version": "9.1.0-alpha.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "parse-server", - "version": "9.0.0", + "version": "9.1.0-alpha.4", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 46560f96aa..fad489e790 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "parse-server", - "version": "9.0.0", + "version": "9.1.0-alpha.4", "description": "An express module providing a Parse-compatible API server", "main": "lib/index.js", "repository": { diff --git a/public/de-AT/email_verification_link_expired.html b/public/de-AT/email_verification_link_expired.html index cae39c7a46..6a664c48cd 100644 --- a/public/de-AT/email_verification_link_expired.html +++ b/public/de-AT/email_verification_link_expired.html @@ -14,9 +14,9 @@

{{appName}}

Expired verification link!

-
- - + + +
diff --git a/public/de-AT/password_reset.html b/public/de-AT/password_reset.html index 49cb65b1aa..73cb1e3d52 100644 --- a/public/de-AT/password_reset.html +++ b/public/de-AT/password_reset.html @@ -23,11 +23,11 @@

Reset Your Password

You can set a new Password for your account: {{username}}


{{error}}

-
+ - - - + + +

New Password

diff --git a/public/de/email_verification_link_expired.html b/public/de/email_verification_link_expired.html index cae39c7a46..6a664c48cd 100644 --- a/public/de/email_verification_link_expired.html +++ b/public/de/email_verification_link_expired.html @@ -14,9 +14,9 @@

{{appName}}

Expired verification link!

- - - + + +
diff --git a/public/de/password_reset.html b/public/de/password_reset.html index 49cb65b1aa..73cb1e3d52 100644 --- a/public/de/password_reset.html +++ b/public/de/password_reset.html @@ -23,11 +23,11 @@

Reset Your Password

You can set a new Password for your account: {{username}}


{{error}}

-
+ - - - + + +

New Password

diff --git a/public/email_verification_link_expired.html b/public/email_verification_link_expired.html index cae39c7a46..6a664c48cd 100644 --- a/public/email_verification_link_expired.html +++ b/public/email_verification_link_expired.html @@ -14,9 +14,9 @@

{{appName}}

Expired verification link!

- - - + + +
diff --git a/public/password_reset.html b/public/password_reset.html index 49cb65b1aa..73cb1e3d52 100644 --- a/public/password_reset.html +++ b/public/password_reset.html @@ -23,11 +23,11 @@

Reset Your Password

You can set a new Password for your account: {{username}}


{{error}}

-
+ - - - + + +

New Password

diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 400efbc380..685210b4e1 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -4788,4 +4788,231 @@ describe('beforePasswordResetRequest hook', () => { Parse.Cloud.beforePasswordResetRequest(Parse.User, () => { }); }).not.toThrow(); }); + + describe('Express-style cloud functions with (req, res) parameters', () => { + it('should support express-style cloud function with res.success()', async () => { + Parse.Cloud.define('expressStyleFunction', (req, res) => { + res.success({ message: 'Hello from express style!' }); + }); + + const result = await Parse.Cloud.run('expressStyleFunction', {}); + expect(result.message).toEqual('Hello from express style!'); + }); + + it('should support express-style cloud function with res.error()', async () => { + Parse.Cloud.define('expressStyleError', (req, res) => { + res.error('Custom error message'); + }); + + await expectAsync(Parse.Cloud.run('expressStyleError', {})).toBeRejectedWith( + new Parse.Error(Parse.Error.SCRIPT_FAILED, 'Custom error message') + ); + }); + + it('should support setting custom HTTP status code with res.status().success()', async () => { + Parse.Cloud.define('customStatusCode', (req, res) => { + res.status(201).success({ created: true }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/customStatusCode', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(201); + expect(response.data.result.created).toBe(true); + }); + + it('should support 401 unauthorized status code with error', async () => { + Parse.Cloud.define('unauthorizedFunction', (req, res) => { + if (!req.user) { + res.status(401).error('Unauthorized access'); + } else { + res.success({ message: 'Authorized' }); + } + }); + + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/unauthorizedFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }) + ).toBeRejected(); + }); + + it('should support 404 not found status code with error', async () => { + Parse.Cloud.define('notFoundFunction', (req, res) => { + res.status(404).error('Resource not found'); + }); + + await expectAsync( + request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/notFoundFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }) + ).toBeRejected(); + }); + + it('should default to 200 status code when not specified', async () => { + Parse.Cloud.define('defaultStatusCode', (req, res) => { + res.success({ message: 'Default status' }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/defaultStatusCode', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(200); + expect(response.data.result.message).toBe('Default status'); + }); + + it('should maintain backward compatibility with single-parameter functions', async () => { + Parse.Cloud.define('traditionalFunction', (req) => { + return { message: 'Traditional style works!' }; + }); + + const result = await Parse.Cloud.run('traditionalFunction', {}); + expect(result.message).toEqual('Traditional style works!'); + }); + + it('should maintain backward compatibility with implicit return functions', async () => { + Parse.Cloud.define('implicitReturnFunction', () => 'Implicit return works!'); + + const result = await Parse.Cloud.run('implicitReturnFunction', {}); + expect(result).toEqual('Implicit return works!'); + }); + + it('should support async express-style functions', async () => { + Parse.Cloud.define('asyncExpressStyle', async (req, res) => { + await new Promise(resolve => setTimeout(resolve, 10)); + res.success({ async: true }); + }); + + const result = await Parse.Cloud.run('asyncExpressStyle', {}); + expect(result.async).toBe(true); + }); + + it('should access request parameters in express-style functions', async () => { + Parse.Cloud.define('expressWithParams', (req, res) => { + const { name } = req.params; + res.success({ greeting: `Hello, ${name}!` }); + }); + + const result = await Parse.Cloud.run('expressWithParams', { name: 'World' }); + expect(result.greeting).toEqual('Hello, World!'); + }); + + it('should access user in express-style functions', async () => { + const user = new Parse.User(); + user.set('username', 'testuser'); + user.set('password', 'testpass'); + await user.signUp(); + + Parse.Cloud.define('expressWithUser', (req, res) => { + if (req.user) { + res.success({ username: req.user.get('username') }); + } else { + res.status(401).error('Not authenticated'); + } + }); + + const result = await Parse.Cloud.run('expressWithUser', {}); + expect(result.username).toEqual('testuser'); + + await Parse.User.logOut(); + }); + + it('should support setting custom headers with res.header()', async () => { + Parse.Cloud.define('customHeaderFunction', (req, res) => { + res.header('X-Custom-Header', 'custom-value').success({ message: 'OK' }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/customHeaderFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(200); + expect(response.headers['x-custom-header']).toBe('custom-value'); + expect(response.data.result.message).toBe('OK'); + }); + + it('should support setting multiple custom headers', async () => { + Parse.Cloud.define('multipleHeadersFunction', (req, res) => { + res.header('X-Header-One', 'value1') + .header('X-Header-Two', 'value2') + .success({ message: 'Multiple headers' }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/multipleHeadersFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(200); + expect(response.headers['x-header-one']).toBe('value1'); + expect(response.headers['x-header-two']).toBe('value2'); + expect(response.data.result.message).toBe('Multiple headers'); + }); + + it('should support combining status code and custom headers', async () => { + Parse.Cloud.define('statusAndHeaderFunction', (req, res) => { + res.status(201) + .header('X-Resource-Id', '12345') + .success({ created: true }); + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/statusAndHeaderFunction', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + }, + json: true, + body: {}, + }); + + expect(response.status).toBe(201); + expect(response.headers['x-resource-id']).toBe('12345'); + expect(response.data.result.created).toBe(true); + }); + }); }); diff --git a/spec/PagesRouter.spec.js b/spec/PagesRouter.spec.js index 0aa5bb357b..009254dfcc 100644 --- a/spec/PagesRouter.spec.js +++ b/spec/PagesRouter.spec.js @@ -1180,4 +1180,72 @@ describe('Pages Router', () => { }); }); }); + + describe('XSS Protection', () => { + beforeEach(async () => { + await reconfigureServer({ + appId: 'test', + appName: 'exampleAppname', + publicServerURL: 'http://localhost:8378/1', + pages: { enableRouter: true }, + }); + }); + + it('should escape XSS payloads in token parameter', async () => { + const xssPayload = '">'; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?token=${encodeURIComponent(xssPayload)}&username=test&appId=test`, + }); + + expect(response.status).toBe(200); + expect(response.text).not.toContain(''); + expect(response.text).toContain('"><script>'); + }); + + it('should escape XSS in username parameter', async () => { + const xssUsername = ''; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(xssUsername)}&appId=test`, + }); + + expect(response.status).toBe(200); + expect(response.text).not.toContain(''); + expect(response.text).toContain('<img'); + }); + + it('should escape XSS in locale parameter', async () => { + const xssLocale = '">'; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?locale=${encodeURIComponent(xssLocale)}&appId=test`, + }); + + expect(response.status).toBe(200); + expect(response.text).not.toContain(''); + expect(response.text).toContain('"><svg'); + }); + + it('should handle legitimate usernames with quotes correctly', async () => { + const username = "O'Brien"; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`, + }); + + expect(response.status).toBe(200); + // Should be properly escaped as HTML entity + expect(response.text).toContain('O'Brien'); + // Should NOT contain unescaped quote that breaks HTML + expect(response.text).not.toContain('value="O\'Brien"'); + }); + + it('should handle legitimate usernames with ampersands correctly', async () => { + const username = 'Smith & Co'; + const response = await request({ + url: `http://localhost:8378/1/apps/choose_password?username=${encodeURIComponent(username)}&appId=test`, + }); + + expect(response.status).toBe(200); + // Should be properly escaped + expect(response.text).toContain('Smith & Co'); + }); + }); }); diff --git a/spec/ParseUser.spec.js b/spec/ParseUser.spec.js index 0380589057..aaae271332 100644 --- a/spec/ParseUser.spec.js +++ b/spec/ParseUser.spec.js @@ -81,6 +81,59 @@ describe('Parse.User testing', () => { } }); + it('logs username taken with configured log level', async () => { + await reconfigureServer({ logLevels: { signupUsernameTaken: 'warn' } }); + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + const loggerWarnSpy = spyOn(logger, 'warn').and.callThrough(); + + const user = new Parse.User(); + user.setUsername('dupUser'); + user.setPassword('pass'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('dupUser'); + user2.setPassword('pass2'); + + expect(loggerWarnSpy).not.toHaveBeenCalled(); + + try { + await user2.signUp(); + fail('should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.USERNAME_TAKEN); + } + + expect(loggerWarnSpy).toHaveBeenCalledTimes(1); + expect(loggerErrorSpy.calls.count()).toBe(0); + }); + + it('can silence username taken log event', async () => { + await reconfigureServer({ logLevels: { signupUsernameTaken: 'silent' } }); + const logger = require('../lib/logger').default; + loggerErrorSpy = spyOn(logger, 'error').and.callThrough(); + const loggerWarnSpy = spyOn(logger, 'warn').and.callThrough(); + + const user = new Parse.User(); + user.setUsername('dupUser'); + user.setPassword('pass'); + await user.signUp(); + + const user2 = new Parse.User(); + user2.setUsername('dupUser'); + user2.setPassword('pass2'); + try { + await user2.signUp(); + fail('should have thrown'); + } catch (e) { + expect(e.code).toBe(Parse.Error.USERNAME_TAKEN); + } + + expect(loggerWarnSpy).not.toHaveBeenCalled(); + expect(loggerErrorSpy.calls.count()).toBe(0); + }); + it('user login with context', async () => { let hit = 0; const context = { foo: 'bar' }; diff --git a/spec/schemas.spec.js b/spec/schemas.spec.js index 5d92ef36e1..f9f29c733e 100644 --- a/spec/schemas.spec.js +++ b/spec/schemas.spec.js @@ -3842,6 +3842,7 @@ describe('schemas', () => { }); it_id('cbd5d897-b938-43a4-8f5a-5d02dd2be9be')(it_exclude_dbs(['postgres']))('cannot update to duplicate value on unique index', done => { + loggerErrorSpy.calls.reset(); const index = { code: 1, }; @@ -3868,6 +3869,12 @@ describe('schemas', () => { .then(done.fail) .catch(error => { expect(error.code).toEqual(Parse.Error.DUPLICATE_VALUE); + // Client should only see generic message (no schema info exposed) + expect(error.message).toEqual('A duplicate value for a field with unique values was provided'); + // Server logs should contain full MongoDB error message with detailed information + expect(loggerErrorSpy).toHaveBeenCalledWith('Duplicate key error:', jasmine.stringContaining('E11000 duplicate key error')); + expect(loggerErrorSpy).toHaveBeenCalledWith('Duplicate key error:', jasmine.stringContaining('test_UniqueIndexClass')); + expect(loggerErrorSpy).toHaveBeenCalledWith('Duplicate key error:', jasmine.stringContaining('code_1')); done(); }); }); diff --git a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js index 50fd348861..24b206aec9 100644 --- a/src/Adapters/Storage/Mongo/MongoStorageAdapter.js +++ b/src/Adapters/Storage/Mongo/MongoStorageAdapter.js @@ -519,7 +519,7 @@ export class MongoStorageAdapter implements StorageAdapter { .then(() => ({ ops: [mongoObject] })) .catch(error => { if (error.code === 11000) { - // Duplicate value + logger.error('Duplicate key error:', error.message); const err = new Parse.Error( Parse.Error.DUPLICATE_VALUE, 'A duplicate value for a field with unique values was provided' @@ -605,6 +605,7 @@ export class MongoStorageAdapter implements StorageAdapter { .then(result => mongoObjectToParseObject(className, result, schema)) .catch(error => { if (error.code === 11000) { + logger.error('Duplicate key error:', error.message); throw new Parse.Error( Parse.Error.DUPLICATE_VALUE, 'A duplicate value for a field with unique values was provided' diff --git a/src/Options/Definitions.js b/src/Options/Definitions.js index 0cf047a84c..25e3257ecd 100644 --- a/src/Options/Definitions.js +++ b/src/Options/Definitions.js @@ -1481,6 +1481,12 @@ module.exports.LogLevels = { 'Log level used by the Cloud Code Functions on success. Default is `info`. See [LogLevel](LogLevel.html) for available values.', default: 'info', }, + signupUsernameTaken: { + env: 'PARSE_SERVER_LOG_LEVELS_SIGNUP_USERNAME_TAKEN', + help: + 'Log level used when a sign-up fails because the username already exists. Default is `info`. See [LogLevel](LogLevel.html) for available values.', + default: 'info', + }, triggerAfter: { env: 'PARSE_SERVER_LOG_LEVELS_TRIGGER_AFTER', help: diff --git a/src/Options/docs.js b/src/Options/docs.js index 4e0ce8577d..f433f7ddc4 100644 --- a/src/Options/docs.js +++ b/src/Options/docs.js @@ -324,6 +324,7 @@ * @interface LogLevels * @property {String} cloudFunctionError Log level used by the Cloud Code Functions on error. Default is `error`. See [LogLevel](LogLevel.html) for available values. * @property {String} cloudFunctionSuccess Log level used by the Cloud Code Functions on success. Default is `info`. See [LogLevel](LogLevel.html) for available values. + * @property {String} signupUsernameTaken Log level used when a sign-up fails because the username already exists. Default is `info`. See [LogLevel](LogLevel.html) for available values. * @property {String} triggerAfter Log level used by the Cloud Code Triggers `afterSave`, `afterDelete`, `afterFind`, `afterLogout`. Default is `info`. See [LogLevel](LogLevel.html) for available values. * @property {String} triggerBeforeError Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on error. Default is `error`. See [LogLevel](LogLevel.html) for available values. * @property {String} triggerBeforeSuccess Log level used by the Cloud Code Triggers `beforeSave`, `beforeDelete`, `beforeFind`, `beforeLogin` on success. Default is `info`. See [LogLevel](LogLevel.html) for available values. diff --git a/src/Options/index.js b/src/Options/index.js index 619a417378..d7dcd5b4c6 100644 --- a/src/Options/index.js +++ b/src/Options/index.js @@ -786,4 +786,8 @@ export interface LogLevels { :DEFAULT: error */ cloudFunctionError: ?string; + /* Log level used when a sign-up fails because the username already exists. Default is `info`. See [LogLevel](LogLevel.html) for available values. + :DEFAULT: info + */ + signupUsernameTaken: ?string; } diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index 9720e4679c..93183f6f76 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -103,20 +103,52 @@ export class FunctionsRouter extends PromiseRouter { }); } - static createResponseObject(resolve, reject) { - return { + static createResponseObject(resolve, reject, statusCode = null) { + let httpStatusCode = statusCode; + const customHeaders = {}; + let responseSent = false; + const responseObject = { success: function (result) { - resolve({ + if (responseSent) { + throw new Error('Cannot call success() after response has already been sent. Make sure to call success() or error() only once per cloud function execution.'); + } + responseSent = true; + const response = { response: { result: Parse._encode(result), }, - }); + }; + if (httpStatusCode !== null) { + response.status = httpStatusCode; + } + if (Object.keys(customHeaders).length > 0) { + response.headers = customHeaders; + } + resolve(response); }, error: function (message) { + if (responseSent) { + throw new Error('Cannot call error() after response has already been sent. Make sure to call success() or error() only once per cloud function execution.'); + } + responseSent = true; const error = triggers.resolveError(message); + // If a custom status code was set, attach it to the error + if (httpStatusCode !== null) { + error.status = httpStatusCode; + } reject(error); }, + status: function (code) { + httpStatusCode = code; + return responseObject; + }, + header: function (key, value) { + customHeaders[key] = value; + return responseObject; + }, + _isResponseSent: () => responseSent, }; + return responseObject; } static handleCloudFunction(req) { const functionName = req.params.functionName; @@ -143,7 +175,7 @@ export class FunctionsRouter extends PromiseRouter { return new Promise(function (resolve, reject) { const userString = req.auth && req.auth.user ? req.auth.user.id : undefined; - const { success, error } = FunctionsRouter.createResponseObject( + const responseObject = FunctionsRouter.createResponseObject( result => { try { if (req.config.logLevels.cloudFunctionSuccess !== 'silent') { @@ -184,14 +216,37 @@ export class FunctionsRouter extends PromiseRouter { } } ); + const { success, error } = responseObject; + return Promise.resolve() .then(() => { return triggers.maybeRunValidator(request, functionName, req.auth); }) .then(() => { - return theFunction(request); + // Check if function expects 2 parameters (req, res) - Express style + if (theFunction.length >= 2) { + return theFunction(request, responseObject); + } else { + // Traditional style - single parameter + return theFunction(request); + } }) - .then(success, error); + .then(result => { + // For Express-style functions, only send response if not already sent + if (theFunction.length >= 2) { + if (!responseObject._isResponseSent()) { + // If Express-style function returns a value without calling res.success/error + if (result !== undefined) { + success(result); + } + // If no response sent and no value returned, this is an error in user code + // but we don't handle it here to maintain backward compatibility + } + } else { + // For traditional functions, always call success with the result (even if undefined) + success(result); + } + }, error); }); } } diff --git a/src/cloud-code/Parse.Cloud.js b/src/cloud-code/Parse.Cloud.js index 3fc38c3aad..5c1a2b156c 100644 --- a/src/cloud-code/Parse.Cloud.js +++ b/src/cloud-code/Parse.Cloud.js @@ -107,22 +107,49 @@ var ParseCloud = {}; * * **Available in Cloud Code only.** * + * **Traditional Style:** * ``` * Parse.Cloud.define('functionName', (request) => { * // code here + * return result; * }, (request) => { * // validation code here * }); * * Parse.Cloud.define('functionName', (request) => { * // code here + * return result; * }, { ...validationObject }); * ``` * + * **Express Style with Custom HTTP Status Codes:** + * ``` + * Parse.Cloud.define('functionName', (request, response) => { + * // Set custom HTTP status code and send response + * response.status(201).success({ message: 'Created' }); + * }); + * + * Parse.Cloud.define('unauthorizedFunction', (request, response) => { + * if (!request.user) { + * response.status(401).error('Unauthorized'); + * } else { + * response.success({ data: 'OK' }); + * } + * }); + * + * Parse.Cloud.define('withCustomHeaders', (request, response) => { + * response.header('X-Custom-Header', 'value').success({ data: 'OK' }); + * }); + * + * Parse.Cloud.define('errorFunction', (request, response) => { + * response.error('Something went wrong'); + * }); + * ``` + * * @static * @memberof Parse.Cloud * @param {String} name The name of the Cloud Function - * @param {Function} data The Cloud Function to register. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}. + * @param {Function} data The Cloud Function to register. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}, or two parameters (request, response) for Express-style functions where response is a {@link Parse.Cloud.FunctionResponse}. * @param {(Object|Function)} validator An optional function to help validating cloud code. This function can be an async function and should take one parameter a {@link Parse.Cloud.FunctionRequest}, or a {@link Parse.Cloud.ValidatorObject}. */ ParseCloud.define = function (functionName, handler, validationHandler) { @@ -788,9 +815,22 @@ module.exports = ParseCloud; * @property {Boolean} master If true, means the master key was used. * @property {Parse.User} user If set, the user that made the request. * @property {Object} params The params passed to the cloud function. + * @property {String} ip The IP address of the client making the request. + * @property {Object} headers The original HTTP headers for the request. + * @property {Object} log The current logger inside Parse Server. + * @property {String} functionName The name of the cloud function. + * @property {Object} context The context of the cloud function call. * @property {Object} config The Parse Server config. */ +/** + * @interface Parse.Cloud.FunctionResponse + * @property {function} success Call this function to return a successful response with an optional result. Usage: `response.success(result)` + * @property {function} error Call this function to return an error response with an error message. Usage: `response.error(message)` + * @property {function} status Call this function to set a custom HTTP status code for the response. Returns the response object for chaining. Usage: `response.status(code).success(result)` or `response.status(code).error(message)` + * @property {function} header Call this function to set a custom HTTP header for the response. Returns the response object for chaining. Usage: `response.header('X-Custom-Header', 'value').success(result)` + */ + /** * @interface Parse.Cloud.JobRequest * @property {Object} params The params passed to the background job. diff --git a/src/middlewares.js b/src/middlewares.js index e01b380751..b6cefca82c 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -466,6 +466,8 @@ export function handleParseErrors(err, req, res, next) { if (req.config && req.config.enableExpressErrorHandler) { return next(err); } + const signupUsernameTakenLevel = + req.config?.logLevels?.signupUsernameTaken || 'info'; let httpStatus; // TODO: fill out this mapping switch (err.code) { @@ -480,7 +482,17 @@ export function handleParseErrors(err, req, res, next) { } res.status(httpStatus); res.json({ code: err.code, error: err.message }); - log.error('Parse error: ', err); + if (err.code === Parse.Error.USERNAME_TAKEN) { + if (signupUsernameTakenLevel !== 'silent') { + const loggerMethod = + typeof log[signupUsernameTakenLevel] === 'function' + ? log[signupUsernameTakenLevel].bind(log) + : log.error.bind(log); + loggerMethod('Parse error: ', err); + } + } else { + log.error('Parse error: ', err); + } } else if (err.status && err.message) { res.status(err.status); res.json({ error: err.message }); diff --git a/types/Options/index.d.ts b/types/Options/index.d.ts index 42e64022e7..8cee0f6ea5 100644 --- a/types/Options/index.d.ts +++ b/types/Options/index.d.ts @@ -296,5 +296,6 @@ export interface LogLevels { triggerBeforeError?: string; cloudFunctionSuccess?: string; cloudFunctionError?: string; + signupUsernameTaken?: string; } export {};