Skip to content

Commit

Permalink
Add content type validation header (#27)
Browse files Browse the repository at this point in the history
- Add content type validation header (optional check)
   - Support for multiple types (by swagger spec)
   - Support check only if body exists.
- Refactor Error
- Fix NSP badge
  • Loading branch information
idanto authored Mar 10, 2018
1 parent b211f62 commit b32b010
Show file tree
Hide file tree
Showing 13 changed files with 331 additions and 107 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
[![NPM Downloads][downloads-image]][downloads-url]
[![Build Status][travis-image]][travis-url]
[![Test Coverage][coveralls-image]][coveralls-url]
[![NSP Status](https://nodesecurity.io/orgs/zooz/projects/91986a79-6151-44df-a6f4-b12982a8858a/badge)](https://nodesecurity.io/orgs/zooz/projects/91986a79-6151-44df-a6f4-b12982a8858a)
[![NSP Status](https://nodesecurity.io/orgs/zooz/projects/3244db73-7215-4526-8cb0-b5b1e640fc6e/badge)](https://nodesecurity.io/orgs/zooz/projects/3244db73-7215-4526-8cb0-b5b1e640fc6e)
[![Apache 2.0 License][license-image]][license-url]

This package is used to perform input validation to express app using a [Swagger (Open API)](https://swagger.io/specification/) definition and [ajv](https://www.npmjs.com/package/ajv)
Expand Down Expand Up @@ -74,9 +74,9 @@ Options currently supports:

- `firstError` - Boolean that indicates if to return only the first error.
- `makeOptionalAttributesNullable` - Boolean that forces preprocessing of Swagger schema to include 'null' as possible type for all non-required properties. Main use-case for this is to ensure correct handling of null values when Ajv type coercion is enabled

- `ajvConfigBody` - Object that will be passed as config to new Ajv instance which will be used for validating request body. Can be useful to e. g. enable type coercion (to automatically convert strings to numbers etc). See Ajv documentation for supported values.
- `ajvConfigParams` - Object that will be passed as config to new Ajv instance which will be used for validating request body. See Ajv documentation for supported values.
- `contentTypeValidation` - Boolean that indicates if to perform content type validation in case `consume` field is specified and the request body is not empty.

```js
formats: [
Expand Down
38 changes: 24 additions & 14 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 31 additions & 0 deletions src/customKeywords/contentTypeValidation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const Ajv = require('ajv');

module.exports = {
compile: function contentTypeValidation(schema) {
const regex = buildContentTypeRegex(schema.types);
return function contentValidation(data) {
contentValidation.errors = [];
if (Number(data['content-length']) > 0) {
if (!regex.test(data['content-type'])) {
contentValidation.errors.push(new Ajv.ValidationError({
keyword: 'content-type',
message: 'content-type must be one of ' + schema.types,
params: { pattern: schema.pattern, types: schema.types, 'content-type': data['content-type'], 'content-length': data['content-length'] }
}));
return false;
}
}
return true;
};
},
errors: true
};

function buildContentTypeRegex(contentTypes) {
let pattern = '';
contentTypes.forEach(type => {
pattern += `(${type.replace(/\//g, '\\/')}.*\\s*\\S*)|`;
});

return new RegExp(pattern.substring(0, pattern.length - 1));
}
4 changes: 2 additions & 2 deletions src/customKeywords/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ module.exports = {
if (missingFiles.length > 0) {
filesValidation.errors.push(new Ajv.ValidationError({
keyword: 'files',
message: 'Missing required files: ' + missingFiles.toString(),
message: `Missing required files: ${missingFiles.toString()}`,
params: { requiredFiles: schema.required, missingFiles: missingFiles }
}));
return false;
Expand All @@ -33,7 +33,7 @@ module.exports = {
if (extraFiles.length > 0) {
filesValidation.errors.push(new Ajv.ValidationError({
keyword: 'files',
message: 'Extra files are not allowed. Not allowed files: ' + extraFiles,
message: `Extra files are not allowed. Not allowed files: ${extraFiles}`,
params: { allowedFiles: allFiles, extraFiles: extraFiles }
}));
return false;
Expand Down
74 changes: 74 additions & 0 deletions src/inputValidationError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use strict';

/**
* Represent an input validation error
* errors field will include the `ajv` error
* @class InputValidationError
* @extends {Error}
*/
class InputValidationError extends Error {
constructor(errors, path, method, options) {
super('Input validation error');

if (options.beautifyErrors && options.firstError) {
this.errors = this.parseAjvError(errors[0], path, method);
} else if (options.beautifyErrors) {
this.errors = this.parseAjvErrors(errors, path, method);
} else {
this.errors = errors;
}
}

parseAjvErrors(errors, path, method) {
var parsedError = [];
errors.forEach(function (error) {
parsedError.push(this.parseAjvError(error, path, method));
}, this);

return parsedError;
}

parseAjvError(error, path, method) {
if (error.dataPath.startsWith('.header')) {
error.dataPath = error.dataPath.replace('.', '');
error.dataPath = error.dataPath.replace('[', '/');
error.dataPath = error.dataPath.replace(']', '');
error.dataPath = error.dataPath.replace('\'', '');
error.dataPath = error.dataPath.replace('\'', '');
}

if (error.dataPath.startsWith('.path')) {
error.dataPath = error.dataPath.replace('.', '');
error.dataPath = error.dataPath.replace('.', '/');
}

if (error.dataPath.startsWith('.query')) {
error.dataPath = error.dataPath.replace('.', '');
error.dataPath = error.dataPath.replace('.', '/');
}

if (error.dataPath.startsWith('.')) {
error.dataPath = error.dataPath.replace('.', 'body/');
}

if (error.dataPath.startsWith('[')) {
error.dataPath = `body/${error.dataPath}`;
}

if (error.dataPath === '') {
error.dataPath = 'body';
}

if (error.keyword === 'enum') {
error.message += ` [${error.params.allowedValues.toString()}]`;
}

if (error.validation) {
error.message = error.errors.message;
}

return `${error.dataPath} ${error.message}`;
}
}

module.exports = InputValidationError;
103 changes: 28 additions & 75 deletions src/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

var SwaggerParser = require('swagger-parser'),
Ajv = require('ajv'),
Validators = require('./validators'),
Validators = require('./utils/validators'),
filesKeyword = require('./customKeywords/files'),
contentKeyword = require('./customKeywords/contentTypeValidation'),
InputValidationError = require('./inputValidationError'),
schemaPreprocessor = require('./utils/schema-preprocessor');

var schemas = {};
Expand Down Expand Up @@ -47,8 +49,14 @@ function init(swaggerPath, options) {
let localParameters = parameters.filter(function (parameter) {
return parameter.in !== 'body';
}).concat(pathParameters);
if (localParameters.length > 0) {
schemas[parsedPath][currentMethod].parameters = buildParametersValidation(localParameters);

if (bodySchema.length > 0) {
schemas[parsedPath][currentMethod].body = buildBodyValidation(bodySchema[0].schema, dereferenced.definitions, swaggers[1], currentPath, currentMethod, parsedPath);
}

if (localParameters.length > 0 || middlewareOptions.contentTypeValidation) {
schemas[parsedPath][currentMethod].parameters = buildParametersValidation(localParameters,
dereferenced.paths[currentPath][currentMethod].consumes || dereferenced.paths[currentPath].consumes || dereferenced.consumes);
}
});
});
Expand All @@ -67,6 +75,7 @@ function init(swaggerPath, options) {
*/
function validate(req, res, next) {
let path = extractPath(req);

return Promise.all([
_validateParams(req.headers, req.params, req.query, req.files, path, req.method.toLowerCase()).catch(e => e),
_validateBody(req.body, path, req.method.toLowerCase()).catch(e => e)
Expand All @@ -76,13 +85,10 @@ function validate(req, res, next) {
}
return next();
}).catch(function (errors) {
if (middlewareOptions.beautifyErrors && middlewareOptions.firstError) {
errors = parseAjvError(errors[0], path, req.method.toLowerCase());
} else if (middlewareOptions.beautifyErrors) {
errors = parseAjvErrors(errors, path, req.method.toLowerCase());
}

return next(new InputValidationError(errors));
const error = new InputValidationError(errors, path, req.method.toLowerCase(),
{ beautifyErrors: middlewareOptions.beautifyErrors,
firstError: middlewareOptions.firstError });
return next(error);
});
};

Expand All @@ -105,57 +111,6 @@ function _validateParams(headers, pathParams, query, files, path, method) {
});
}

function parseAjvErrors(errors, path, method) {
var parsedError = [];
errors.forEach(function (error) {
parsedError.push(parseAjvError(error, path, method));
}, this);

return parsedError;
}

function parseAjvError(error, path, method) {
if (error.dataPath.startsWith('.header')) {
error.dataPath = error.dataPath.replace('.', '');
error.dataPath = error.dataPath.replace('[', '/');
error.dataPath = error.dataPath.replace(']', '');
error.dataPath = error.dataPath.replace('\'', '');
error.dataPath = error.dataPath.replace('\'', '');
}

if (error.dataPath.startsWith('.path')) {
error.dataPath = error.dataPath.replace('.', '');
error.dataPath = error.dataPath.replace('.', '/');
}

if (error.dataPath.startsWith('.query')) {
error.dataPath = error.dataPath.replace('.', '');
error.dataPath = error.dataPath.replace('.', '/');
}

if (error.dataPath.startsWith('.')) {
error.dataPath = error.dataPath.replace('.', 'body/');
}

if (error.dataPath.startsWith('[')) {
error.dataPath = 'body/' + error.dataPath;
}

if (error.dataPath === '') {
error.dataPath = 'body';
}

if (error.keyword === 'enum') {
error.message += ' [' + error.params.allowedValues.toString() + ']';
}

if (error.validation) {
error.message = error.errors.message;
}

return error.dataPath + ' ' + error.message;
}

function addCustomKeyword(ajv, formats) {
if (formats) {
formats.forEach(function (format) {
Expand All @@ -164,6 +119,7 @@ function addCustomKeyword(ajv, formats) {
}

ajv.addKeyword('files', filesKeyword);
ajv.addKeyword('content', contentKeyword);
}

function extractPath(req) {
Expand Down Expand Up @@ -209,7 +165,15 @@ function buildInheritance(discriminator, dereferencedDefinitions, swagger, curre
return new Validators.OneOfValidator(inheritsObject);
}

function buildParametersValidation(parameters) {
function createContentTypeHeaders(validate, contentTypes) {
if (!validate || !contentTypes) return;

return {
types: contentTypes
};
}

function buildParametersValidation(parameters, contentTypes) {
const defaultAjvOptions = {
allErrors: true,
coerceTypes: 'array'
Expand Down Expand Up @@ -278,20 +242,9 @@ function buildParametersValidation(parameters) {
}
}, this);

return ajv.compile(ajvParametersSchema);
}
ajvParametersSchema.properties.headers.content = createContentTypeHeaders(middlewareOptions.contentTypeValidation, contentTypes);

/**
* Represent an input validation error
* errors field will include the `ajv` error
* @class InputValidationError
* @extends {Error}
*/
class InputValidationError extends Error {
constructor(errors, place, message) {
super(message);
this.errors = errors;
}
return ajv.compile(ajvParametersSchema);
}

module.exports = {
Expand Down
File renamed without changes.
Loading

0 comments on commit b32b010

Please sign in to comment.