Часть 16 – Предварительный контроль запроса

Давайте избавимся, несколько это возможно, от появления недружественного к пользователю сообщения об ошибочном запросе.

Проектируем решение

Разделим пользователей на группы с точки зрения причины возникновения запрещенных последовательностей в запросе к серверу.

Сразу можно выделить небольшую группу, которая будет отправлять их сознательно. И не важно, делают ли это они пытаясь взломать веб-приложение или просто из любопытства. В любом случае они не ждут продолжения нормальной его работы. Для них можно показывать сообщение с минимумом подробностей и, например, записывать данные такой попытке в журнал для дальнейшего анализа.

Другая группа это обычные пользователи, которых гораздо больше. Они случайно или по ошибке ввели данные, приведшие к некорректному запросу. Прерывать их работу крайне нежелательно, т.к. человек, например, может передумать регистрироваться на сайте, потеряв введенные данные "по ошибке сервера".

В этом случае решение напрашивается само собой – контролировать данные еще на этапе их ввода в браузере. Разумеется, это не отменяет проверки на сервере. Таким образом, необходимо разработать атрибут. Может возникнуть вопрос: а почему просто не создать отдельное правило для jQuery Validation? В этом случае будет утеряна возможность указывать поля, для которых необходима проверка. А нужна она далеко не всем. Нет смысла на стороне клиента проверять значения, тип которых отличается от строкового или свойства которых были отмечены атрибутом [AllowHtml].

Чтобы определить наличие последовательностей "<!", "</", "<[символ латинского алфавита]" или "&#" в введённой строке, воспользуемся регулярным выражением. Здесь появляется еще один вопрос: а почему не использовать существующий [RegularExpression] или хотя бы взять его как базовый? Причин для отказа от такого решения четыре:

  • необходимо будет указывать само выражение каждый при указании атрибута, а это не удобно;
  • как следствие, его код будет прописан в каждом HTML теге;
  • атрибут [RegularExpression] или его наследника можно использовать только один раз для каждого свойства, а значит уже нельзя будет задать другое выражение;
  • будет выполняться проверка выражения на сервере, которая по сути дублирует проверку запроса.

В результате, создадим новый атрибут, блокирующий ввод запрещенных последовательностей. Так же предусмотрим опциональную проверку полученных данных на сервере. Это может пригодиться в случае, когда в запросе необходимо контролировать только одно свойство. При этом можно будет не отмечать их атрибутом [AllowHtml]. Достаточно отключить проверку для всего запроса, задействовав новый атрибут для контроля нужного поля и на стороне клиента и на сервере.

Создаем реализацию атрибута

Действие создаваемого атрибута противоположено уже знакомому [AllowHtml]. Поэтому назовем его [BlockHtml]. В папке Attributes\Validation создадим новый класс BlockHtmlAttribute. Посмотрим исходный код реализации, а потом разберем некоторые подробности:

namespace MVCDemo.Attributes.Validation
{
    using System;
    using System.Collections.Generic;
    using System.ComponentModel.DataAnnotations;
    using System.Text.RegularExpressions;
    using System.Web.Mvc;

    [AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
    public class BlockHtmlAttribute : ValidationAttribute, IClientValidatable
    {
        private const string _hasTags =
            @"^(&\u0023)|^(<[/!a-zA-Z])|^((.(?!(?:(&\u0023)|(<[/!a-zA-Z]))))*)$";

        public bool ValidateOnServer { get; set; }

        public BlockHtmlAttribute() :
            base("{0} has invalid symbols in it.")
        {
            this.ValidateOnServer = false;
        }

        #region ValidationAttribute overrides

        protected override ValidationResult IsValid(
            object value, ValidationContext validationContext)
        {
            if (!this.ValidateOnServer) {
                return ValidationResult.Success;
            }

            string stringValue = value as string;
            if (stringValue == null) {
                return ValidationResult.Success;
            }

            var regex = new Regex(BlockHtmlAttribute._hasTags);
            Match match = regex.Match(stringValue);

            if ((match.Success && (match.Index == 0)) &&
                (match.Length == stringValue.Length)) {
                return ValidationResult.Success;
            }

            return new ValidationResult(
                    this.FormatErrorMessage(validationContext.DisplayName));
        }

        #endregion

        #region IClientValidatable Members

        public IEnumerable<ModelClientValidationRule> GetClientValidationRules(
            ModelMetadata metadata, ControllerContext context)
        {
            var rule = new ModelClientValidationRule() {
                ValidationType = "blockhtml",
                ErrorMessage = this.FormatErrorMessage(metadata.DisplayName)
            };

            yield return rule;
        }

        #endregion
    }
}

Как хорошо видно, атрибут предназначен только для свойств Модели. Кроме того, он будет унаследован вместе классом, но не может быть добавлен к одному объекту более раза.

Регулярное выражение представлено константой _hasTags. В таком виде оно может быть использовано как в коде на C#, так и на JavaScript. Стоит отметить, что в последнем есть ограничения. В частности не поддерживается повторный анализ уже пройденных символов (Look-behind).

Кратко рассмотрим основные части выражения:

  • строка не должна начинаться с последовательности "&#": ^(&\u0023)
  • а так же содержать в начале "<[! или символ латинского алфавита]": |^(<[/!a-zA-Z])
  • и не должно быть символов, после которых есть указанные выше последовательности:
    ^((.(?!(?:(&\u0023)|(<[/!a-zA-Z]))))*)$

При не соблюдении любого их этих условий проверка не будет пройдена.

Контроль на стороне сервера осуществляется только, если свойство ValidateOnServer установлено в значение true. По умолчанию не будем повторять работу механизма проверки запроса. Поэтому все полученные значения считаются корректными. Также отсутствие значения будет обозначать отмену проверки, т.е. как и раньше не будем перекладывать на себя работу атрибута [Require]. Для проверки полученной строки на соответствие регулярному выражению используем класс Regex.

В реализации IClientValidatable для клиентской части определим правило "blockhtml". Стандартное правило ModelClientValidationRegexRule использовать не будем, т.к. это запретит задавать другие регулярные выражения для отмеченного свойства. Кроме того, оно бы потребовало каждый раз передавать само выражение в клиентскую часть. Поэтому используется обобщенное ModelClientValidationRule.

Перейдем к разработке части на JavaScript. Для него в папке Scripts\Validation создадим файл BlockHtmlAttribute.js. Здесь все повторяет алгоритм серверной части:

/// <reference path="../jquery-1.4.4-vsdoc.js" />
/// <reference path="../jquery.validate-vsdoc.js" />
/// <reference path="../jquery.validate.unobtrusive.js" />

jQuery.validator.addMethod("blockhtml", function (value, element, params) {
    if (value === null) { return true; }

    var regexp = new RegExp("^(&\u0023)|^(<[/!a-zA-Z])|^((.(?!(?:(&\u0023)|(<[/!a-zA-Z]))))*)$");
    var match = regexp.exec(value);
    return (match && (match.index === 0) && (match[0].length === value.length));
});

jQuery.validator.unobtrusive.adapters.addBool("blockhtml");

Поскольку нет необходимости передавать в функцию проверки параметры, то используется addBool() для добавления адаптера нового правила.

Реализация атрибута завершена.

Использование [BlockHtml] в проекте

Начнем с создания сообщения об ошибке. Оно будет показано пользователю в случае ввода запрещенной комбинации символов. Текст разместим в файле общих ресурсов:

Файл Resources\Shared\ErrorsRes.resx
HtmlTagsNotAllowed HTML tags are not allowed. HTML теги запрещены

Перейдем к модификации свойств Модели. Свойства Email и Homepage не получат значений с HTML тегами благодаря установленным для них форматам. Поле Description уже отмечено атрибутом [AllowHtml]. Кроме того, AgreementAccepted имеет тип bool, а значит также не требует проверки. Поэтому назначим [BlockHtml] остальным текстовым полям:

namespace MVCDemo.Models
{
    using System.ComponentModel.DataAnnotations;
    using System.Web.Mvc;
    using MVCDemo.Attributes.Validation;
    using MVCDemo.Resources.Models;
    using MVCDemo.Resources.Shared;

    public class UserProfileModel
    {
        [Display(Name = "Login", ResourceType = typeof(UserProfileRes))]
        [DataType(DataType.Text)]
        [Required(ErrorMessageResourceName = "FieldIsRequired",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        [StringLengthRange(3, 64,
            ErrorMessageResourceName = "InvalidStringLength",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        [BlockHtml(ErrorMessageResourceName = "HtmlTagsNotAllowed",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        public virtual string Login { get; set; }

        [Display(Name = "DisplayName", ResourceType = typeof(UserProfileRes))]
        [DataType(DataType.Text)]
        [Required(ErrorMessageResourceName = "FieldIsRequired",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        [StringLengthRange(3, 32,
            ErrorMessageResourceName = "InvalidStringLength",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        [BlockHtml(ErrorMessageResourceName = "HtmlTagsNotAllowed",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        public string DisplayName { get; set; }

        [Display(Name = "Email", ResourceType = typeof(UserProfileRes))]
        [DataType(DataType.EmailAddress)]
        [Required(ErrorMessageResourceName = "FieldIsRequired",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        [RegularExpression(
            @"[\w\.-]*[a-zA-Z0-9_]@[\w\.-]*[a-zA-Z0-9]\.[a-zA-Z][a-zA-Z\.]*[a-zA-Z]",
            ErrorMessageResourceName = "InvalidEmailAddress",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        [StringLength(64,
             ErrorMessageResourceName = "StringTooLong",
             ErrorMessageResourceType = typeof(ErrorsRes))]
        public string Email { get; set; }

        [Display(Name = "Homepage", ResourceType = typeof(UserProfileRes))]
        [DataType(DataType.Url)]
        [RegularExpression(@"(http(s)?://)?([\w-]+\.)+[\w-]+(/[\w- ;,./?%&=]*)?",
            ErrorMessageResourceName = "InvalidUrl",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        [StringLength(96,
            ErrorMessageResourceName = "StringTooLong",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        public string Homepage { get; set; }

        [Display(Name = "Description", ResourceType = typeof(UserProfileRes))]
        [DataType(DataType.MultilineText)]
        [StringLength(255,
            ErrorMessageResourceName = "StringTooLong",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        [AllowHtml]
        public string Description { get; set; }
    }
}
namespace MVCDemo.Models
{
    using System.Collections.Generic;
    using System.ComponentModel.DataAnnotations;
    using System.Web.Mvc;
    using MVCDemo.Attributes.Validation;
    using MVCDemo.Resources.Models;
    using MVCDemo.Resources.Shared;

    public class NewUserProfileModel : UserProfileModel, IValidatableObject
    {
        [Remote("IsLoginExists", "UserProfiles", HttpMethod = "POST",
            AdditionalFields = "Password,ConfirmPassword",
            ErrorMessageResourceName = "LoginAlreadyExists",
            ErrorMessageResourceType = typeof(NewUserProfileRes))]
        public override string Login { get; set; }

        [Display(Name = "Password", ResourceType = typeof(NewUserProfileRes))]
        [DataType(DataType.Password)]
        [Required(ErrorMessageResourceName = "FieldIsRequired",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        [StringLengthRange(6, 64,
            ErrorMessageResourceName = "InvalidStringLength",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        [BlockHtml(ErrorMessageResourceName = "HtmlTagsNotAllowed",
            ErrorMessageResourceType = typeof(ErrorsRes))]
        public string Password { get; set; }

        [Display(Name = "ConfirmPassword", ResourceType = typeof(NewUserProfileRes))]
        [DataType(DataType.Password)]
        [Compare("Password",
            ErrorMessageResourceName = "PasswordsDontMatch",
            ErrorMessageResourceType = typeof(NewUserProfileRes))]
        public string ConfirmPassword { get; set; }

        [Display(Name = "AgreementAccepted", ResourceType = typeof(NewUserProfileRes))]
        [Equal(true,
            ErrorMessageResourceName = "MustAgreeWithEULA",
            ErrorMessageResourceType = typeof(NewUserProfileRes))]
        public bool AgreementAccepted { get; set; }

        #region IValidatableObject Members

        public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
        {
            var userRepository = new UserRepository();
            if (userRepository.IsLoginExists(this.Login)) {
                yield return new ValidationResult(
                    Resources.Models.NewUserProfileRes.LoginAlreadyExists,
                    new string[] { "Login" });
            }

            yield break;
        }

        #endregion
    }
}

Добавим ссылку на созданный скрипт в Представление View\Create:

@model MVCDemo.Models.NewUserProfileModel
@using MVCDemo.Resources.Views.UserProfiles;
@{
    ViewBag.Title = CreateRes.PageTitle;
}

<h2>@ViewBag.Title</h2>

<script src="@Url.Content("~/Scripts/jquery.validate.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/jquery.validate.unobtrusive.min.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/Validation/EqualAttribute.js")" type="text/javascript"></script>
<script src="@Url.Content("~/Scripts/Validation/BlockHtmlAttribute.js")" type="text/javascript"></script>

@using (Html.BeginForm()) {
.........

Веб-приложение готово к проверке. Теперь ввод HTML тегов в поля формы создания профиля приводит к появлению сообщения об ошибке еще при заполнении формы.

Остается разобраться с ситуацией, если у пользователя отключен JavaScript или некорректные данные были отправлены намеренно. Для обработки такой ситуации можно воспользоваться стандартным для ASP.NET способом перехвата исключений.Однако он не очень хорошо подходит для использования в рамках шаблона MVC. Поэтому чуть позже рассмотрим как данная задача решается средствами ASP.NET MVC 3. А пока что продолжим рассмотрение работы с Моделью.


Исходный код проекта (C#, Visual Studio 2010): MVCDemo-Part16.zip (478 Kb)

Комментарии (11) -

Виктор 28.03.2011 23:34:14

Кульная статья, но не логичнее было бы от regexp attr наследоваться ?

Виктор 28.03.2011 23:37:05

и как преодолеть проблему синхронизации серверного и клиентского regexp'a ? Если что-то менятся, то в будущем проблемы с поддержкой могут возникнуть... У самого похожий механизм с проверкой полей по умолчанию: на клиенте и на сервере - изменили надпись, приходиться менять в двух местах. Пока не победил...

@ Виктор:

В начале было: "атрибут [RegularExpression] или его наследника можно использовать только один раз для каждого свойства, а значит уже нельзя будет задать другое выражение".

А вот красивого решения с передачей от клиента к серверу в данном случае у меня пока нет. Мысли были, но получается очень много кода и лишней работы серверу ради такой фишки.

Виктор 29.03.2011 14:17:08

@ Andrey:

да, сорри, невнимательно читал

Андрей, большое Вам спасибо за статьи, учу по ним MVC3.
А вот с полем Login проблема, оно все равно пропускает html теги, хотя password и display name работают как надо.

Сорри, html все же не пускает, просто не выводит сообщение HTML tags are not allowed.

@ manst:

Там есть такая проблема из-за отправки строки с HTML на сторону сервера. Видимо, поскольку это исключительная ситуация, то обработка прекращается и сообщение не выводится. Я сообщил о ситуации разработчикам, но пока ни от них ни своего решения нет.

"Свойства Email и Homepage не получат значений с HTML тегами благодаря установленным для них форматам"

ах да и big thx Andrey
=)

По поводу регулярки. А почему фразы "строка не должна начинаться с.." и "не должно быть символов, перед которыми..." не объединить просто в "не должна содержать таких последовательностей"? Только ради того, чтобы такую последовательность можно было ввести в самом конце строки?

@ Alexandr: Собственно задача была повторить на клиенте правила проверки на сервере. Поэтому именно так.

Pingbacks and trackbacks (3)+

Добавить комментарий