From d3e6ac86916d209d3a617cf7970c1c9c111a7780 Mon Sep 17 00:00:00 2001 From: Joseph Hajjar Date: Sat, 13 Dec 2025 01:42:25 +0100 Subject: [PATCH 1/4] feat: add custom http status code and headers support for cloud functions --- spec/CloudCode.spec.js | 105 +++++++++++++++++++++++++++++++++ src/Routers/FunctionsRouter.js | 25 ++++++-- 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 400efbc380..471eca0b9e 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -93,6 +93,111 @@ describe('Cloud Code', () => { }); }); + it('can return custom HTTP status code', async () => { + Parse.Cloud.define('customStatus', () => { + return { + __httpResponse: true, + status: 201, + result: { message: 'Created' }, + }; + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/customStatus', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + + expect(response.status).toEqual(201); + expect(response.data.result.message).toEqual('Created'); + }); + + it('can return custom HTTP headers', async () => { + Parse.Cloud.define('customHeaders', () => { + return { + __httpResponse: true, + headers: { + 'X-Custom-Header': 'custom-value', + 'X-Another-Header': 'another-value', + }, + result: { success: true }, + }; + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/customHeaders', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + + expect(response.status).toEqual(200); + expect(response.headers['x-custom-header']).toEqual('custom-value'); + expect(response.headers['x-another-header']).toEqual('another-value'); + expect(response.data.result.success).toEqual(true); + }); + + it('can return custom HTTP status code and headers together', async () => { + Parse.Cloud.define('customStatusAndHeaders', () => { + return { + __httpResponse: true, + status: 401, + headers: { + 'WWW-Authenticate': 'Bearer realm="api"', + }, + result: { error: 'Authentication required' }, + }; + }); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/customStatusAndHeaders', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + fail('Expected request to reject with 401'); + } catch (response) { + expect(response.status).toEqual(401); + expect(response.headers['www-authenticate']).toEqual('Bearer realm="api"'); + expect(response.data.result.error).toEqual('Authentication required'); + } + }); + + it('returns normal response when __httpResponse is not set', async () => { + Parse.Cloud.define('normalResponse', () => { + return { status: 201, result: 'this should be the result' }; + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/normalResponse', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + + expect(response.status).toEqual(200); + expect(response.data.result.status).toEqual(201); + expect(response.data.result.result).toEqual('this should be the result'); + }); + it('can get config', () => { const config = Parse.Server; let currentConfig = Config.get('test'); diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index 9720e4679c..4558b0fa32 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -106,11 +106,26 @@ export class FunctionsRouter extends PromiseRouter { static createResponseObject(resolve, reject) { return { success: function (result) { - resolve({ - response: { - result: Parse._encode(result), - }, - }); + if (result && typeof result === 'object' && result.__httpResponse === true) { + const response = { + response: { + result: Parse._encode(result.result), + }, + }; + if (typeof result.status === 'number') { + response.status = result.status; + } + if (result.headers && typeof result.headers === 'object') { + response.headers = result.headers; + } + resolve(response); + } else { + resolve({ + response: { + result: Parse._encode(result), + }, + }); + } }, error: function (message) { const error = triggers.resolveError(message); From 40040fb3701f4c7eea6017f819cfb310b30d2800 Mon Sep 17 00:00:00 2001 From: Joseph Hajjar Date: Sat, 13 Dec 2025 22:19:39 +0100 Subject: [PATCH 2/4] feat: add response argument to cloud functions for HTTP status and headers --- spec/CloudCode.spec.js | 36 +++++------------ src/Routers/FunctionsRouter.js | 73 ++++++++++++++++++++++++---------- 2 files changed, 64 insertions(+), 45 deletions(-) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 471eca0b9e..2fa3c56167 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -94,12 +94,9 @@ describe('Cloud Code', () => { }); it('can return custom HTTP status code', async () => { - Parse.Cloud.define('customStatus', () => { - return { - __httpResponse: true, - status: 201, - result: { message: 'Created' }, - }; + Parse.Cloud.define('customStatus', (req, res) => { + res.status(201); + return { message: 'Created' }; }); const response = await request({ @@ -118,15 +115,10 @@ describe('Cloud Code', () => { }); it('can return custom HTTP headers', async () => { - Parse.Cloud.define('customHeaders', () => { - return { - __httpResponse: true, - headers: { - 'X-Custom-Header': 'custom-value', - 'X-Another-Header': 'another-value', - }, - result: { success: true }, - }; + Parse.Cloud.define('customHeaders', (req, res) => { + res.set('X-Custom-Header', 'custom-value'); + res.set('X-Another-Header', 'another-value'); + return { success: true }; }); const response = await request({ @@ -147,15 +139,9 @@ describe('Cloud Code', () => { }); it('can return custom HTTP status code and headers together', async () => { - Parse.Cloud.define('customStatusAndHeaders', () => { - return { - __httpResponse: true, - status: 401, - headers: { - 'WWW-Authenticate': 'Bearer realm="api"', - }, - result: { error: 'Authentication required' }, - }; + Parse.Cloud.define('customStatusAndHeaders', (req, res) => { + res.status(401).set('WWW-Authenticate', 'Bearer realm="api"'); + return { error: 'Authentication required' }; }); try { @@ -177,7 +163,7 @@ describe('Cloud Code', () => { } }); - it('returns normal response when __httpResponse is not set', async () => { + it('returns normal response when response object is not used', async () => { Parse.Cloud.define('normalResponse', () => { return { status: 201, result: 'this should be the result' }; }); diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index 4558b0fa32..8f67f4cf14 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -9,6 +9,41 @@ import { jobStatusHandler } from '../StatusHandler'; import _ from 'lodash'; import { logger } from '../logger'; +class CloudResponse { + constructor() { + this._status = null; + this._headers = {}; + } + + status(code) { + if (typeof code !== 'number') { + throw new Error('Status code must be a number'); + } + this._status = code; + return this; + } + + set(name, value) { + if (typeof name !== 'string') { + throw new Error('Header name must be a string'); + } + this._headers[name] = value; + return this; + } + + hasCustomResponse() { + return this._status !== null || Object.keys(this._headers).length > 0; + } + + getStatus() { + return this._status; + } + + getHeaders() { + return this._headers; + } +} + function parseObject(obj, config) { if (Array.isArray(obj)) { return obj.map(item => { @@ -103,29 +138,25 @@ export class FunctionsRouter extends PromiseRouter { }); } - static createResponseObject(resolve, reject) { + static createResponseObject(resolve, reject, cloudResponse) { return { success: function (result) { - if (result && typeof result === 'object' && result.__httpResponse === true) { - const response = { - response: { - result: Parse._encode(result.result), - }, - }; - if (typeof result.status === 'number') { - response.status = result.status; + const response = { + response: { + result: Parse._encode(result), + }, + }; + if (cloudResponse && cloudResponse.hasCustomResponse()) { + const status = cloudResponse.getStatus(); + const headers = cloudResponse.getHeaders(); + if (status !== null) { + response.status = status; } - if (result.headers && typeof result.headers === 'object') { - response.headers = result.headers; + if (Object.keys(headers).length > 0) { + response.headers = headers; } - resolve(response); - } else { - resolve({ - response: { - result: Parse._encode(result), - }, - }); } + resolve(response); }, error: function (message) { const error = triggers.resolveError(message); @@ -143,6 +174,7 @@ export class FunctionsRouter extends PromiseRouter { } let params = Object.assign({}, req.body, req.query); params = parseParams(params, req.config); + const cloudResponse = new CloudResponse(); const request = { params: params, config: req.config, @@ -197,14 +229,15 @@ export class FunctionsRouter extends PromiseRouter { } catch (e) { reject(e); } - } + }, + cloudResponse ); return Promise.resolve() .then(() => { return triggers.maybeRunValidator(request, functionName, req.auth); }) .then(() => { - return theFunction(request); + return theFunction(request, cloudResponse); }) .then(success, error); }); From 4cdda82f76acd3be7bd90fbac0b189c9c8c7b848 Mon Sep 17 00:00:00 2001 From: Joseph Hajjar Date: Sat, 13 Dec 2025 23:07:00 +0100 Subject: [PATCH 3/4] feat: add response argument to cloud functions for HTTP status and headers --- README.md | 49 ++++++++++++ spec/CloudCode.spec.js | 136 +++++++++++++++++++++++++++++++++ src/Routers/FunctionsRouter.js | 8 +- 3 files changed, 192 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index de0b21d442..a4510f5dcb 100644 --- a/README.md +++ b/README.md @@ -771,6 +771,55 @@ Logs are also viewable in Parse Dashboard. **Want new line delimited JSON error logs (for consumption by CloudWatch, Google Cloud Logging, etc)?** Pass the `JSON_LOGS` environment variable when starting `parse-server`. Usage :- `JSON_LOGS='1' parse-server --appId APPLICATION_ID --masterKey MASTER_KEY` +## Cloud Functions HTTP Response + +Cloud functions support an Express-like `(req, res)` pattern to customize HTTP response status codes and headers. + +### Basic Usage + +```js +// Set custom status code +Parse.Cloud.define('createItem', (req, res) => { + res.status(201); + return { id: 'abc123', message: 'Created' }; +}); + +// Set custom headers +Parse.Cloud.define('apiEndpoint', (req, res) => { + res.set('X-Request-Id', 'req-123'); + res.set('Cache-Control', 'no-cache'); + return { success: true }; +}); + +// Chain methods +Parse.Cloud.define('authenticate', (req, res) => { + if (!isValid(req.params.token)) { + res.status(401).set('WWW-Authenticate', 'Bearer'); + return { error: 'Unauthorized' }; + } + return { user: 'john' }; +}); +``` + +### Response Methods + +| Method | Description | +|--------|-------------| +| `res.status(code)` | Set HTTP status code (e.g., 201, 400, 404). Returns `res` for chaining. | +| `res.set(name, value)` | Set HTTP header. Returns `res` for chaining. | + +### Backwards Compatibility + +The `res` argument is optional. Existing cloud functions using only `(req) => {}` continue to work unchanged. + +### Security Considerations + +The `set()` method allows setting arbitrary HTTP headers. Be cautious when setting security-sensitive headers such as: +- CORS headers (`Access-Control-Allow-Origin`, `Access-Control-Allow-Credentials`) +- `Set-Cookie` +- `Location` (redirects) +- Authentication headers (`WWW-Authenticate`) + # Deprecations See the [Deprecation Plan](https://github.com/parse-community/parse-server/blob/master/DEPRECATIONS.md) for an overview of deprecations and planned breaking changes. diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 2fa3c56167..16197d2b61 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -184,6 +184,142 @@ describe('Cloud Code', () => { expect(response.data.result.result).toEqual('this should be the result'); }); + it('res.status() called multiple times uses last value', async () => { + Parse.Cloud.define('multipleStatus', (req, res) => { + res.status(201); + res.status(202); + res.status(203); + return { message: 'ok' }; + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/multipleStatus', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + + expect(response.status).toEqual(203); + }); + + it('res.set() called multiple times for same header uses last value', async () => { + Parse.Cloud.define('multipleHeaders', (req, res) => { + res.set('X-Custom-Header', 'first'); + res.set('X-Custom-Header', 'second'); + res.set('X-Custom-Header', 'third'); + return { message: 'ok' }; + }); + + const response = await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/multipleHeaders', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + + expect(response.headers['x-custom-header']).toEqual('third'); + }); + + it('res.status() throws error for non-number status code', async () => { + Parse.Cloud.define('invalidStatusType', (req, res) => { + res.status('200'); + return { message: 'ok' }; + }); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/invalidStatusType', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + fail('Expected request to fail'); + } catch (response) { + expect(response.status).toEqual(400); + } + }); + + it('res.set() throws error for non-string header name', async () => { + Parse.Cloud.define('invalidHeaderName', (req, res) => { + res.set(123, 'value'); + return { message: 'ok' }; + }); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/invalidHeaderName', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + fail('Expected request to fail'); + } catch (response) { + expect(response.status).toEqual(400); + } + }); + + it('res.status() throws error for out of range status code', async () => { + Parse.Cloud.define('outOfRangeStatus', (req, res) => { + res.status(50); + return { message: 'ok' }; + }); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/outOfRangeStatus', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + fail('Expected request to fail'); + } catch (response) { + expect(response.status).toEqual(400); + } + }); + + it('res.set() throws error for undefined header value', async () => { + Parse.Cloud.define('undefinedHeaderValue', (req, res) => { + res.set('X-Custom-Header', undefined); + return { message: 'ok' }; + }); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/undefinedHeaderValue', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + fail('Expected request to fail'); + } catch (response) { + expect(response.status).toEqual(400); + } + }); + it('can get config', () => { const config = Parse.Server; let currentConfig = Config.get('test'); diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index 8f67f4cf14..2d3c27b0ff 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -19,6 +19,9 @@ class CloudResponse { if (typeof code !== 'number') { throw new Error('Status code must be a number'); } + if (code < 100 || code > 599) { + throw new Error('Status code must be between 100 and 599'); + } this._status = code; return this; } @@ -27,6 +30,9 @@ class CloudResponse { if (typeof name !== 'string') { throw new Error('Header name must be a string'); } + if (value === undefined || value === null) { + throw new Error('Header value must be defined'); + } this._headers[name] = value; return this; } @@ -153,7 +159,7 @@ export class FunctionsRouter extends PromiseRouter { response.status = status; } if (Object.keys(headers).length > 0) { - response.headers = headers; + response.headers = { ...headers }; } } resolve(response); From 71fba5fd440c5195016fd3e24332194dc3b76f4e Mon Sep 17 00:00:00 2001 From: Joseph Hajjar Date: Sat, 13 Dec 2025 23:17:37 +0100 Subject: [PATCH 4/4] feat: add response argument to cloud functions for HTTP status and headers --- README.md | 1 + spec/CloudCode.spec.js | 94 +++++++++++++++++++++++++++++++++- src/Routers/FunctionsRouter.js | 20 ++++++-- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index a4510f5dcb..6fa681748c 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ A big _thank you_ 🙏 to our [sponsors](#sponsors) and [backers](#backers) who - [Reserved Keys](#reserved-keys) - [Parameters](#parameters-1) - [Logging](#logging) + - [Cloud Function Custom HTTP Response](#cloud-functions-http-response) - [Deprecations](#deprecations) - [Live Query](#live-query) - [GraphQL](#graphql) diff --git a/spec/CloudCode.spec.js b/spec/CloudCode.spec.js index 16197d2b61..d9c4794b64 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -228,7 +228,7 @@ describe('Cloud Code', () => { expect(response.headers['x-custom-header']).toEqual('third'); }); - it('res.status() throws error for non-number status code', async () => { + it('res.status() throws error for non-integer status code', async () => { Parse.Cloud.define('invalidStatusType', (req, res) => { res.status('200'); return { message: 'ok' }; @@ -251,6 +251,29 @@ describe('Cloud Code', () => { } }); + it('res.status() throws error for NaN status code', async () => { + Parse.Cloud.define('nanStatus', (req, res) => { + res.status(NaN); + return { message: 'ok' }; + }); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/nanStatus', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + fail('Expected request to fail'); + } catch (response) { + expect(response.status).toEqual(400); + } + }); + it('res.set() throws error for non-string header name', async () => { Parse.Cloud.define('invalidHeaderName', (req, res) => { res.set(123, 'value'); @@ -320,6 +343,75 @@ describe('Cloud Code', () => { } }); + it('res.set() throws error for empty header name', async () => { + Parse.Cloud.define('emptyHeaderName', (req, res) => { + res.set(' ', 'value'); + return { message: 'ok' }; + }); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/emptyHeaderName', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + fail('Expected request to fail'); + } catch (response) { + expect(response.status).toEqual(400); + } + }); + + it('res.set() throws error for prototype pollution header names', async () => { + Parse.Cloud.define('protoHeaderName', (req, res) => { + res.set('__proto__', 'value'); + return { message: 'ok' }; + }); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/protoHeaderName', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + fail('Expected request to fail'); + } catch (response) { + expect(response.status).toEqual(400); + } + }); + + it('res.set() throws error for CRLF in header value', async () => { + Parse.Cloud.define('crlfHeaderValue', (req, res) => { + res.set('X-Custom-Header', 'value\r\nX-Injected: bad'); + return { message: 'ok' }; + }); + + try { + await request({ + method: 'POST', + url: 'http://localhost:8378/1/functions/crlfHeaderValue', + headers: { + 'X-Parse-Application-Id': 'test', + 'X-Parse-REST-API-Key': 'rest', + 'Content-Type': 'application/json', + }, + body: {}, + }); + fail('Expected request to fail'); + } catch (response) { + expect(response.status).toEqual(400); + } + }); + it('can get config', () => { const config = Parse.Server; let currentConfig = Config.get('test'); diff --git a/src/Routers/FunctionsRouter.js b/src/Routers/FunctionsRouter.js index 2d3c27b0ff..8365906060 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -12,12 +12,12 @@ import { logger } from '../logger'; class CloudResponse { constructor() { this._status = null; - this._headers = {}; + this._headers = Object.create(null); } status(code) { - if (typeof code !== 'number') { - throw new Error('Status code must be a number'); + if (!Number.isInteger(code)) { + throw new Error('Status code must be an integer'); } if (code < 100 || code > 599) { throw new Error('Status code must be between 100 and 599'); @@ -30,10 +30,22 @@ class CloudResponse { if (typeof name !== 'string') { throw new Error('Header name must be a string'); } + const headerName = name.trim(); + if (!headerName) { + throw new Error('Header name must not be empty'); + } + if (headerName === '__proto__' || headerName === 'constructor' || headerName === 'prototype') { + throw new Error('Invalid header name'); + } if (value === undefined || value === null) { throw new Error('Header value must be defined'); } - this._headers[name] = value; + const headerValue = Array.isArray(value) ? value.map(v => String(v)) : String(value); + const values = Array.isArray(headerValue) ? headerValue : [headerValue]; + if (values.some(v => /[\r\n]/.test(v))) { + throw new Error('Header value must not contain CRLF'); + } + this._headers[headerName] = headerValue; return this; }