diff --git a/README.md b/README.md index b0d623a0c5..94b4389411 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,58 @@ In the example above, the validation rules applied to `example` won't be run unl Note that when the condition is false all validation decorators are ignored, including `isDefined`. +## Whitelisting + +Even if your object is an instance of a validation class it can contain additional properties that are not defined. +If you do not want to have such properties on your object, pass special flag to `validate` method: + +```typescript +import {validate} from "class-validator"; +// ... +validate(post, { whitelist: true }); +``` + +This will strip all properties that don't have any decorators. If no other decorator is suitable for your property, +you can use @Allow decorator: + +```typescript +import {validate, Allow, Min} from "class-validator"; + +export class Post { + + @Allow() + title: string; + + @Min(0) + views: number; + + nonWhitelistedProperty: number; +} + +let post = new Post(); +post.title = 'Hello world!'; +post.views = 420; + +post.nonWhitelistedProperty = 69; +(post as any).anotherNonWhitelistedProperty = "something"; + +validate(post).then(errors => { + // post.nonWhitelistedProperty is not defined + // (post as any).anotherNonWhitelistedProperty is not defined + ... +}); +```` + +If you would rather to have an error thrown when any non-whitelisted properties are present, pass another flag to +`validate` method: + +```typescript +import {validate} from "class-validator"; +// ... +validate(post, { whitelist: true, forbidNonWhitelisted: true }); +``` + + ## Skipping missing properties Sometimes you may want to skip validation of the properties that does not exist in the validating object. This is diff --git a/src/decorator/decorators.ts b/src/decorator/decorators.ts index 4cbdd1f846..a1a95e7e0b 100644 --- a/src/decorator/decorators.ts +++ b/src/decorator/decorators.ts @@ -63,6 +63,22 @@ export function ValidateNested(validationOptions?: ValidationOptions) { }; } +/** + * If object has both allowed and not allowed properties a validation error will be thrown. + */ +export function Allow(validationOptions?: ValidationOptions) { + return function (object: Object, propertyName: string) { + const args: ValidationMetadataArgs = { + type: ValidationTypes.WHITELIST, + target: object.constructor, + propertyName: propertyName, + validationOptions: validationOptions + }; + getFromContainer(MetadataStorage).addValidationMetadata(new ValidationMetadata(args)); + }; +} + + /** * Objects / object arrays marked with this decorator will also be validated. */ diff --git a/src/validation/ValidationExecutor.ts b/src/validation/ValidationExecutor.ts index e5b5ce3bbc..499df15f2d 100644 --- a/src/validation/ValidationExecutor.ts +++ b/src/validation/ValidationExecutor.ts @@ -44,10 +44,15 @@ export class ValidationExecutor { const targetMetadatas = this.metadataStorage.getTargetValidationMetadatas(object.constructor, targetSchema, groups); const groupedMetadatas = this.metadataStorage.groupByPropertyName(targetMetadatas); + if (this.validatorOptions && this.validatorOptions.whitelist) + this.whitelist(object, groupedMetadatas, validationErrors); + + // General validation Object.keys(groupedMetadatas).forEach(propertyName => { const value = (object as any)[propertyName]; const definedMetadatas = groupedMetadatas[propertyName].filter(metadata => metadata.type === ValidationTypes.IS_DEFINED); - const metadatas = groupedMetadatas[propertyName].filter(metadata => metadata.type !== ValidationTypes.IS_DEFINED); + const metadatas = groupedMetadatas[propertyName].filter( + metadata => metadata.type !== ValidationTypes.IS_DEFINED && metadata.type !== ValidationTypes.WHITELIST); const customValidationMetadatas = metadatas.filter(metadata => metadata.type === ValidationTypes.CUSTOM_VALIDATION); const nestedValidationMetadatas = metadatas.filter(metadata => metadata.type === ValidationTypes.NESTED_VALIDATION); const conditionalValidationMetadatas = metadatas.filter(metadata => metadata.type === ValidationTypes.CONDITIONAL_VALIDATION); @@ -73,6 +78,38 @@ export class ValidationExecutor { }); } + whitelist(object: any, + groupedMetadatas: { [propertyName: string]: ValidationMetadata[] }, + validationErrors: ValidationError[]) { + let notAllowedProperties: string[] = []; + + Object.keys(object).forEach(propertyName => { + // does this property have no metadata? + if (!groupedMetadatas[propertyName] || groupedMetadatas[propertyName].length === 0) + notAllowedProperties.push(propertyName); + }); + + if (notAllowedProperties.length > 0) { + + if (this.validatorOptions && this.validatorOptions.forbidNonWhitelisted) { + + // throw errors + notAllowedProperties.forEach(property => { + validationErrors.push({ + target: object, property, value: (object as any)[property], children: undefined, + constraints: { [ValidationTypes.WHITELIST]: `property ${property} should not exist` } + }); + }); + + } else { + + // strip non allowed properties + notAllowedProperties.forEach(property => delete (object as any)[property]); + + } + } + } + stripEmptyErrors(errors: ValidationError[]) { return errors.filter(error => { if (error.children) { diff --git a/src/validation/ValidationTypes.ts b/src/validation/ValidationTypes.ts index b719098818..ac5f0654dd 100644 --- a/src/validation/ValidationTypes.ts +++ b/src/validation/ValidationTypes.ts @@ -9,6 +9,7 @@ export class ValidationTypes { static CUSTOM_VALIDATION = "customValidation"; static NESTED_VALIDATION = "nestedValidation"; static CONDITIONAL_VALIDATION = "conditionalValidation"; + static WHITELIST = "whitelistValidation"; /* common checkers */ static IS_DEFINED = "isDefined"; diff --git a/src/validation/ValidatorOptions.ts b/src/validation/ValidatorOptions.ts index 5d3798006a..0cfb902c28 100644 --- a/src/validation/ValidatorOptions.ts +++ b/src/validation/ValidatorOptions.ts @@ -8,6 +8,18 @@ export interface ValidatorOptions { */ skipMissingProperties?: boolean; + /** + * If set to true validator will strip validated object of any properties that do not have any decorators. + * + * Tip: if no other decorator is suitable for your property use @Allow decorator. + */ + whitelist?: false; + + /** + * If set to true, instead of stripping non-whitelisted properties validator will throw an error + */ + forbidNonWhitelisted?: false; + /** * Groups to be used during validation of the object. */ diff --git a/test/functional/whitelist-validation.spec.ts b/test/functional/whitelist-validation.spec.ts new file mode 100644 index 0000000000..61268401d1 --- /dev/null +++ b/test/functional/whitelist-validation.spec.ts @@ -0,0 +1,78 @@ +import "es6-shim"; +import {Allow, IsDefined, Min, ValidateNested} from "../../src/decorator/decorators"; +import {Validator} from "../../src/validation/Validator"; +import {expect} from "chai"; +import {ValidationTypes} from "../../src/validation/ValidationTypes"; + +// ------------------------------------------------------------------------- +// Setup +// ------------------------------------------------------------------------- + +const validator = new Validator(); + +// ------------------------------------------------------------------------- +// Specifications: allowed validation +// ------------------------------------------------------------------------- + +describe("whitelist validation", function () { + + it("should strip non whitelisted properties, but leave whitelisted untouched", function () { + + class MyClass { + @IsDefined() + title: string; + + @Min(0) + views: number; + } + + const model: any = new MyClass(); + + model.title = "hello"; + model.views = 56; + model.unallowedProperty = 42; + return validator.validate(model, { whitelist: true }).then(errors => { + expect(errors.length).to.be.equal(0); + expect(model.unallowedProperty).be.undefined; + expect(model.title).to.equal("hello"); + expect(model.views).to.be.equal(56); + }); + }); + + it("should be able to whitelist with @Allow", function () { + + class MyClass { + @Allow() + views: number; + } + + const model: any = new MyClass(); + + model.views = 420; + model.unallowedProperty = "non-whitelisted"; + + return validator.validate(model, { whitelist: true }).then(errors => { + expect(errors.length).to.be.equal(0); + expect(model.unallowedProperty).be.undefined; + expect(model.views).to.be.equal(420); + }); + }); + + it("should throw an error when forbidNonWhitelisted flag is set", function () { + + class MyClass { + } + + const model: any = new MyClass(); + + model.unallowedProperty = "non-whitelisted"; + + return validator.validate(model, { whitelist: true, forbidNonWhitelisted: true }).then(errors => { + expect(errors.length).to.be.equal(1); + errors[0].target.should.be.equal(model); + errors[0].property.should.be.equal("unallowedProperty"); + errors[0].constraints.should.haveOwnProperty(ValidationTypes.WHITELIST); + }); + }); + +});