diff --git a/package-lock.json b/package-lock.json index 45a397a4b..81aa8a655 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8616,6 +8616,11 @@ "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-3.0.0.tgz", "integrity": "sha512-WQwgWEBgn+SJU1tlDa/GiY5/ngRpa9yrSj8n4BYPHcwoxTDaMEaYCHMOn42hIHHDd3CrUoRr3+HpsK0hCKoxzA==" }, + "jsonschema": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/jsonschema/-/jsonschema-1.2.6.tgz", + "integrity": "sha512-SqhURKZG07JyKKeo/ir24QnS4/BV7a6gQy93bUSe4lUdNp0QNpIz2c9elWJQ9dpc5cQYY6cvCzgRwy0MQCLyqA==" + }, "jsonwebtoken": { "version": "8.5.1", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz", diff --git a/package.json b/package.json index 7731bd105..f993dfccf 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,7 @@ "java-invoke-local": "0.0.6", "js-string-escape": "^1.0.1", "jsonpath-plus": "^3.0.0", + "jsonschema": "^1.2.6", "jsonwebtoken": "^8.5.1", "jszip": "^3.2.2", "luxon": "^1.22.0", diff --git a/src/events/http/HttpServer.js b/src/events/http/HttpServer.js index 5084b4a87..654e03a9f 100644 --- a/src/events/http/HttpServer.js +++ b/src/events/http/HttpServer.js @@ -15,6 +15,7 @@ import { VelocityContext, } from './lambda-events/index.js' import parseResources from './parseResources.js' +import payloadSchemaValidator from './payloadSchemaValidator.js' import debugLog from '../../debugLog.js' import serverlessLog, { logRoutes } from '../../serverlessLog.js' import { @@ -466,6 +467,12 @@ export default class HttpServer { ? requestTemplates[contentType] : '' + const schema = + typeof endpoint?.request?.schema !== 'undefined' && + integration === 'AWS' + ? endpoint.request.schema[contentType] + : '' + // https://hapijs.com/api#route-configuration doesn't seem to support selectively parsing // so we have to do it ourselves const contentTypesThatRequirePayloadParsing = [ @@ -493,6 +500,16 @@ export default class HttpServer { debugLog('requestTemplate:', requestTemplate) debugLog('payload:', request.payload) + /* REQUEST PAYLOAD SCHEMA VALIDATION */ + if (schema) { + debugLog('schema:', request.schema) + try { + payloadSchemaValidator.validate(schema, request.payload) + } catch (err) { + return this._reply400(response, err.message, err) + } + } + /* REQUEST TEMPLATE PROCESSING (event population) */ let event = {} @@ -509,7 +526,7 @@ export default class HttpServer { requestPath, ).create() } catch (err) { - return this._reply500( + return this._reply502( response, `Error while parsing template "${contentType}" for ${functionKey}`, err, @@ -566,7 +583,7 @@ export default class HttpServer { // it here and reply in the same way that we would have above when // we lazy-load the non-IPC handler function. if (this.#options.useChildProcesses && err.ipcException) { - return this._reply500( + return this._reply502( response, `Error while loading ${functionKey}`, err, @@ -763,7 +780,7 @@ export default class HttpServer { } else if (typeof result === 'string') { response.source = JSON.stringify(result) } else if (result && result.body && typeof result.body !== 'string') { - return this._reply500( + return this._reply502( response, 'According to the API Gateway specs, the body content must be stringified. Check your Lambda response and make sure you are invoking JSON.stringify(YOUR_CONTENT) on your body object', {}, @@ -835,7 +852,7 @@ export default class HttpServer { response.variety = 'buffer' } else { if (result && result.body && typeof result.body !== 'string') { - return this._reply500( + return this._reply502( response, 'According to the API Gateway specs, the body content must be stringified. Check your Lambda response and make sure you are invoking JSON.stringify(YOUR_CONTENT) on your body object', {}, @@ -872,15 +889,14 @@ export default class HttpServer { }) } - // Bad news - _reply500(response, message, error) { + _replyError(statusCode, response, message, error) { serverlessLog(message) console.error(error) response.header('Content-Type', 'application/json') - response.statusCode = 502 // APIG replies 502 by default on failures; + response.statusCode = statusCode response.source = { errorMessage: message, errorType: error.constructor.name, @@ -892,6 +908,16 @@ export default class HttpServer { return response } + // Bad news + _reply502(response, message, error) { + // APIG replies 502 by default on failures; + return this._replyError(502, response, message, error) + } + + _reply400(response, message, error) { + return this._replyError(400, response, message, error) + } + createResourceRoutes() { const resourceRoutesOptions = this.#options.resourceRoutes diff --git a/src/events/http/payloadSchemaValidator.js b/src/events/http/payloadSchemaValidator.js new file mode 100644 index 000000000..9697cc015 --- /dev/null +++ b/src/events/http/payloadSchemaValidator.js @@ -0,0 +1,15 @@ +'use strict' + +const { validate: validateJsonSchema } = require('jsonschema') + +exports.validate = function validate(model, body) { + const result = validateJsonSchema(body, model) + + if (result.errors.length > 0) { + throw new Error( + `Request body validation failed: ${result.errors + .map((e) => e.message) + .join(', ')}`, + ) + } +} diff --git a/tests/integration/handler/handler.js b/tests/integration/handler/handler.js index 38073567c..217bddf0f 100644 --- a/tests/integration/handler/handler.js +++ b/tests/integration/handler/handler.js @@ -209,3 +209,10 @@ exports.TestResourceVariable = (event, context, callback) => { body: stringify(event.resource), }) } + +exports.TestPayloadSchemaValidation = (event, context, callback) => { + callback(null, { + statusCode: 200, + body: stringify(event.body), + }) +} diff --git a/tests/integration/handler/handlerPayload.test.js b/tests/integration/handler/handlerPayload.test.js index c9c7f666a..b04949205 100644 --- a/tests/integration/handler/handlerPayload.test.js +++ b/tests/integration/handler/handlerPayload.test.js @@ -322,3 +322,51 @@ describe('handler payload tests with prepend off', () => { }) }) }) + +describe('handler payload scehma validation tests', () => { + // init + beforeAll(() => + setup({ + servicePath: resolve(__dirname), + args: ['--noPrependStageInUrl'], + }), + ) + + // cleanup + afterAll(() => teardown()) + ;[ + { + description: 'test with valid payload', + expectedBody: `'{"foo":"bar"}'`, + path: '/test-payload-schema-validator', + body: { + foo: 'bar', + }, + status: 200, + }, + + { + description: 'test with invalid payload', + path: '/test-payload-schema-validator', + body: {}, + status: 400, + }, + ].forEach(({ description, expectedBody, path, body, status }) => { + test(description, async () => { + const url = joinUrl(TEST_BASE_URL, path) + + const response = await fetch(url, { + method: 'post', + body: JSON.stringify(body), + headers: { 'Content-Type': 'application/json' }, + }) + + expect(response.status).toEqual(status) + + if (expectedBody) { + const json = await response.json() + expect(json.body).toEqual(expectedBody) + } + }) + }) +}) diff --git a/tests/integration/handler/serverless.yml b/tests/integration/handler/serverless.yml index 36aa123b5..028bcd8d4 100644 --- a/tests/integration/handler/serverless.yml +++ b/tests/integration/handler/serverless.yml @@ -162,3 +162,21 @@ functions: method: get path: /{id}/test-resource-variable-handler handler: handler.TestResourceVariable + + TestPayloadSchemaValidation: + events: + - http: + method: post + path: /test-payload-schema-validator + integration: lambda + request: + schema: + application/json: + $schema: http://json-schema.org/draft-07/schema + type: object + required: + - foo + properties: + foo: + type: string + handler: handler.TestPayloadSchemaValidation