diff --git a/README.md b/README.md index de0b21d442..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) @@ -771,6 +772,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 400efbc380..d9c4794b64 100644 --- a/spec/CloudCode.spec.js +++ b/spec/CloudCode.spec.js @@ -93,6 +93,325 @@ describe('Cloud Code', () => { }); }); + it('can return custom HTTP status code', async () => { + Parse.Cloud.define('customStatus', (req, res) => { + res.status(201); + return { 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', (req, res) => { + res.set('X-Custom-Header', 'custom-value'); + res.set('X-Another-Header', 'another-value'); + return { 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', (req, res) => { + res.status(401).set('WWW-Authenticate', 'Bearer realm="api"'); + return { 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 response object is not used', 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('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-integer 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.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'); + 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('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 9720e4679c..8365906060 100644 --- a/src/Routers/FunctionsRouter.js +++ b/src/Routers/FunctionsRouter.js @@ -9,6 +9,59 @@ import { jobStatusHandler } from '../StatusHandler'; import _ from 'lodash'; import { logger } from '../logger'; +class CloudResponse { + constructor() { + this._status = null; + this._headers = Object.create(null); + } + + status(code) { + 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'); + } + this._status = code; + return this; + } + + set(name, value) { + 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'); + } + 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; + } + + 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,14 +156,25 @@ export class FunctionsRouter extends PromiseRouter { }); } - static createResponseObject(resolve, reject) { + static createResponseObject(resolve, reject, cloudResponse) { return { success: function (result) { - resolve({ + 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 (Object.keys(headers).length > 0) { + response.headers = { ...headers }; + } + } + resolve(response); }, error: function (message) { const error = triggers.resolveError(message); @@ -128,6 +192,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, @@ -182,14 +247,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); });