Sanitize "id" attributes for HTML 4.0.1

- #704 part 2 of 2
- change `@Html.Id()` to sanitize return value; was identical to `@Html.Name()`

Copied `TagBuilder.CreateSanitizedId()` and `TagBuilder.Html401IdUtil` from MVC 5.2
- except this `CreateSanitizedId()` returns a valid identifier if first `char` is not a letter
 - e.g. "[0].Name"

nits:
- expand variable names, use lots of `var`, put `public` members first
- add doc comments for `CreateSanitizedId()`

Note users will be able to apply different sanitization once we fix #1188.
This commit is contained in:
Doug Bunting 2014-10-17 09:12:26 -07:00
parent f9e44ff7f9
commit dd5da33a62
7 changed files with 199 additions and 106 deletions

View File

@ -679,7 +679,9 @@ namespace Microsoft.AspNet.Mvc.Rendering
protected virtual string GenerateId(string expression)
{
var fullName = DefaultHtmlGenerator.GetFullHtmlFieldName(ViewContext, name: expression);
return fullName;
var id = TagBuilder.CreateSanitizedId(fullName, IdAttributeDotReplacement);
return id;
}
protected virtual HtmlString GenerateLabel([NotNull] ModelMetadata metadata,

View File

@ -49,32 +49,48 @@ namespace Microsoft.AspNet.Mvc.Rendering
}
}
public static string CreateSanitizedId(string originalId, [NotNull] string invalidCharReplacement)
/// <summary>
/// Return valid HTML 4.01 "id" attribute for an element with the given <paramref name="name"/>.
/// </summary>
/// <param name="name">The original element name.</param>
/// <param name="invalidCharReplacement">
/// The <see cref="string"/> (normally a single <see cref="char"/>) to substitute for invalid characters in
/// <paramref name="name"/>.
/// </param>
/// <returns>
/// Valid HTML 4.01 "id" attribute for an element with the given <paramref name="name"/>.
/// </returns>
/// <remarks>Valid "id" attributes are defined in http://www.w3.org/TR/html401/types.html#type-id</remarks>
public static string CreateSanitizedId(string name, [NotNull] string invalidCharReplacement)
{
if (string.IsNullOrEmpty(originalId))
if (string.IsNullOrEmpty(name))
{
return string.Empty;
}
var firstChar = originalId[0];
var sb = new StringBuilder(originalId.Length);
sb.Append(firstChar);
for (var i = 1; i < originalId.Length; i++)
var firstChar = name[0];
if (!Html401IdUtil.IsAsciiLetter(firstChar))
{
var thisChar = originalId[i];
if (!char.IsWhiteSpace(thisChar))
// The first character must be a letter according to the HTML 4.01 specification.
firstChar = 'z';
}
var stringBuffer = new StringBuilder(name.Length);
stringBuffer.Append(firstChar);
for (var index = 1; index < name.Length; index++)
{
var thisChar = name[index];
if (Html401IdUtil.IsValidIdCharacter(thisChar))
{
sb.Append(thisChar);
stringBuffer.Append(thisChar);
}
else
{
sb.Append(invalidCharReplacement);
stringBuffer.Append(invalidCharReplacement);
}
}
return sb.ToString();
return stringBuffer.ToString();
}
public void GenerateId(string name, [NotNull] string idAttributeDotReplacement)
@ -195,5 +211,39 @@ namespace Microsoft.AspNet.Mvc.Rendering
return sb.ToString();
}
private static class Html401IdUtil
{
public static bool IsAsciiLetter(char testChar)
{
return (('A' <= testChar && testChar <= 'Z') || ('a' <= testChar && testChar <= 'z'));
}
public static bool IsValidIdCharacter(char testChar)
{
return (IsAsciiLetter(testChar) || IsAsciiDigit(testChar) || IsAllowableSpecialCharacter(testChar));
}
private static bool IsAsciiDigit(char testChar)
{
return ('0' <= testChar && testChar <= '9');
}
private static bool IsAllowableSpecialCharacter(char testChar)
{
switch (testChar)
{
case '-':
case '_':
case ':':
// Note '.' is valid according to the HTML 4.01 specification. Disallowed here to avoid confusion
// with CSS class selectors or when using jQuery.
return true;
default:
return false;
}
}
}
}
}

View File

@ -60,8 +60,8 @@ namespace Microsoft.AspNet.Mvc.Core
var labelForResult = helper.LabelFor(m => m.Inner.Id);
// Assert
Assert.Equal("<label for=\"Inner.Id\">Id</label>", labelResult.ToString());
Assert.Equal("<label for=\"Inner.Id\">Id</label>", labelForResult.ToString());
Assert.Equal("<label for=\"Inner_Id\">Id</label>", labelResult.ToString());
Assert.Equal("<label for=\"Inner_Id\">Id</label>", labelForResult.ToString());
}
[Fact]
@ -229,13 +229,14 @@ namespace Microsoft.AspNet.Mvc.Core
}
[Theory]
[InlineData("A", "A")]
[InlineData("A[23]", "A[23]")]
[InlineData("A[0].B", "B")]
[InlineData("A.B.C.D", "D")]
[InlineData("A", "A", "A")]
[InlineData("A[23]", "A[23]", "A_23_")]
[InlineData("A[0].B", "B", "A_0__B")]
[InlineData("A.B.C.D", "D", "A_B_C_D")]
public void Label_DisplaysRightmostExpressionSegment_IfPropertiesNotFound(
string expression,
string expectedResult)
string expectedText,
string expectedId)
{
// Arrange
var metadataHelper = new MetadataHelper();
@ -246,7 +247,7 @@ namespace Microsoft.AspNet.Mvc.Core
// Assert
// Label() falls back to expression name when DisplayName and PropertyName are null.
Assert.Equal("<label for=\"" + expression + "\">" + expectedResult + "</label>", result.ToString());
Assert.Equal("<label for=\"" + expectedId + "\">" + expectedText + "</label>", result.ToString());
}
[Fact]

View File

@ -46,12 +46,12 @@ namespace Microsoft.AspNet.Mvc.Core
}
[Theory]
[InlineData("")]
[InlineData("A")]
[InlineData("A[23]")]
[InlineData("A[0].B")]
[InlineData("A.B.C.D")]
public void IdAndNameHelpers_ReturnPrefixForModel(string prefix)
[InlineData("", "")]
[InlineData("A", "A")]
[InlineData("A[23]", "A_23_")]
[InlineData("A[0].B", "A_0__B")]
[InlineData("A.B.C.D", "A_B_C_D")]
public void IdAndNameHelpers_ReturnPrefixForModel(string prefix, string expectedId)
{
// Arrange
var helper = DefaultTemplatesUtilities.GetHtmlHelper();
@ -66,9 +66,9 @@ namespace Microsoft.AspNet.Mvc.Core
var nameForModelResult = helper.NameForModel();
// Assert
Assert.Equal(prefix, idResult);
Assert.Equal(prefix, idForResult);
Assert.Equal(prefix, idForModelResult);
Assert.Equal(expectedId, idResult);
Assert.Equal(expectedId, idForResult);
Assert.Equal(expectedId, idForModelResult);
Assert.Equal(prefix, nameResult);
Assert.Equal(prefix, nameForResult);
Assert.Equal(prefix, nameForModelResult);
@ -94,16 +94,17 @@ namespace Microsoft.AspNet.Mvc.Core
}
[Theory]
[InlineData(null, "Property1")]
[InlineData("", "Property1")]
[InlineData("A", "A.Property1")]
[InlineData("A[23]", "A[23].Property1")]
[InlineData("A[0].B", "A[0].B.Property1")]
[InlineData("A.B.C.D", "A.B.C.D.Property1")]
public void IdAndNameHelpers_ReturnPrefixAndPropertyName(string prefix, string expectedResult)
[InlineData(null, "Property1", "Property1")]
[InlineData("", "Property1", "Property1")]
[InlineData("A", "A.Property1", "A_Property1")]
[InlineData("A[23]", "A[23].Property1", "A_23__Property1")]
[InlineData("A[0].B", "A[0].B.Property1", "A_0__B_Property1")]
[InlineData("A.B.C.D", "A.B.C.D.Property1", "A_B_C_D_Property1")]
public void IdAndNameHelpers_ReturnPrefixAndPropertyName(string prefix, string expectedName, string expectedId)
{
// Arrange
var helper = DefaultTemplatesUtilities.GetHtmlHelper();
helper.ViewData.TemplateInfo.HtmlFieldPrefix = prefix;
// Act
var idResult = helper.Id("Property1");
@ -112,10 +113,10 @@ namespace Microsoft.AspNet.Mvc.Core
var nameForResult = helper.NameFor(m => m.Property1);
// Assert
Assert.Equal("Property1", idResult);
Assert.Equal("Property1", idForResult);
Assert.Equal("Property1", nameResult);
Assert.Equal("Property1", nameForResult);
Assert.Equal(expectedId, idResult);
Assert.Equal(expectedId, idForResult);
Assert.Equal(expectedName, nameResult);
Assert.Equal(expectedName, nameForResult);
}
[Fact]
@ -131,8 +132,8 @@ namespace Microsoft.AspNet.Mvc.Core
var nameForResult = helper.NameFor(m => m.Inner.Id);
// Assert
Assert.Equal("Inner.Id", idResult);
Assert.Equal("Inner.Id", idForResult);
Assert.Equal("Inner_Id", idResult);
Assert.Equal("Inner_Id", idForResult);
Assert.Equal("Inner.Id", nameResult);
Assert.Equal("Inner.Id", nameForResult);
}
@ -194,10 +195,10 @@ namespace Microsoft.AspNet.Mvc.Core
}
[Theory]
[InlineData("A")]
[InlineData("A[0].B")]
[InlineData("A.B.C.D")]
public void IdAndName_ReturnExpression_EvenIfExpressionNotFound(string expression)
[InlineData("A", "A")]
[InlineData("A[0].B", "A_0__B")]
[InlineData("A.B.C.D", "A_B_C_D")]
public void IdAndName_ReturnExpression_EvenIfExpressionNotFound(string expression, string expectedId)
{
// Arrange
var helper = DefaultTemplatesUtilities.GetHtmlHelper();
@ -207,7 +208,7 @@ namespace Microsoft.AspNet.Mvc.Core
var nameResult = helper.Name(expression);
// Assert
Assert.Equal(expression, idResult);
Assert.Equal(expectedId, idResult);
Assert.Equal(expression, nameResult);
}

View File

@ -14,8 +14,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
public class InputTagHelperTest
{
// Model (List<Model> or Model instance), container type (Model or NestModel), model accessor,
// property path, expected value.
public static TheoryData<object, Type, Func<object>, string, string> TestDataSet
// property path / id, expected value.
public static TheoryData<object, Type, Func<object>, NameAndId, string> TestDataSet
{
get
{
@ -41,32 +41,32 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
modelWithText,
};
return new TheoryData<object, Type, Func<object>, string, string>
return new TheoryData<object, Type, Func<object>, NameAndId, string>
{
{ null, typeof(Model), () => null, "Text",
{ null, typeof(Model), () => null, new NameAndId("Text", "Text"),
string.Empty },
{ modelWithNull, typeof(Model), () => modelWithNull.Text, "Text",
{ modelWithNull, typeof(Model), () => modelWithNull.Text, new NameAndId("Text", "Text"),
string.Empty },
{ modelWithText, typeof(Model), () => modelWithText.Text, "Text",
{ modelWithText, typeof(Model), () => modelWithText.Text, new NameAndId("Text", "Text"),
"outer text" },
{ modelWithNull, typeof(NestedModel), () => modelWithNull.NestedModel.Text, "NestedModel.Text",
string.Empty },
{ modelWithText, typeof(NestedModel), () => modelWithText.NestedModel.Text, "NestedModel.Text",
"inner text" },
{ modelWithNull, typeof(NestedModel), () => modelWithNull.NestedModel.Text,
new NameAndId("NestedModel.Text", "NestedModel_Text"), string.Empty },
{ modelWithText, typeof(NestedModel), () => modelWithText.NestedModel.Text,
new NameAndId("NestedModel.Text", "NestedModel_Text"), "inner text" },
// Top-level indexing does not work end-to-end due to code generation issue #1345.
// TODO: Remove above comment when #1345 is fixed.
{ models, typeof(Model), () => models[0].Text, "[0].Text",
string.Empty },
{ models, typeof(Model), () => models[1].Text, "[1].Text",
"outer text" },
{ models, typeof(Model), () => models[0].Text,
new NameAndId("[0].Text", "z0__Text"), string.Empty },
{ models, typeof(Model), () => models[1].Text,
new NameAndId("[1].Text", "z1__Text"), "outer text" },
{ models, typeof(NestedModel), () => models[0].NestedModel.Text, "[0].NestedModel.Text",
string.Empty },
{ models, typeof(NestedModel), () => models[1].NestedModel.Text, "[1].NestedModel.Text",
"inner text" },
{ models, typeof(NestedModel), () => models[0].NestedModel.Text,
new NameAndId("[0].NestedModel.Text", "z0__NestedModel_Text"), string.Empty },
{ models, typeof(NestedModel), () => models[1].NestedModel.Text,
new NameAndId("[1].NestedModel.Text", "z1__NestedModel_Text"), "inner text" },
};
}
}
@ -77,7 +77,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
object model,
Type containerType,
Func<object> modelAccessor,
string propertyPath,
NameAndId nameAndId,
string expectedValue)
{
// Arrange
@ -85,8 +85,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
{
{ "class", "form-control" },
{ "type", "text" },
{ "id", propertyPath },
{ "name", propertyPath },
{ "id", nameAndId.Id },
{ "name", nameAndId.Name },
{ "valid", "from validation attributes" },
{ "value", expectedValue },
};
@ -97,7 +97,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
// Property name is either nameof(Model.Text) or nameof(NestedModel.Text).
var metadata = metadataProvider.GetMetadataForProperty(modelAccessor, containerType, propertyName: "Text");
var modelExpression = new ModelExpression(propertyPath, metadata);
var modelExpression = new ModelExpression(nameAndId.Name, metadata);
var tagHelperContext = new TagHelperContext(new Dictionary<string, object>());
var htmlAttributes = new Dictionary<string, string>
@ -185,6 +185,19 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
Assert.Equal(expectedTagName, output.TagName);
}
public class NameAndId
{
public NameAndId(string name, string id)
{
Name = name;
Id = id;
}
public string Name { get; private set; }
public string Id { get; private set; }
}
private class Model
{
public string Text { get; set; }

View File

@ -44,45 +44,45 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
return new TheoryData<object, Type, Func<object>, string, TagHelperOutputContent>
{
{ null, typeof(Model), () => null, "Text",
new TagHelperOutputContent(Environment.NewLine, "Text") },
new TagHelperOutputContent(Environment.NewLine, "Text", "Text") },
{ modelWithNull, typeof(Model), () => modelWithNull.Text, "Text",
new TagHelperOutputContent(Environment.NewLine, "Text") },
new TagHelperOutputContent(Environment.NewLine, "Text", "Text") },
{ modelWithText, typeof(Model), () => modelWithText.Text, "Text",
new TagHelperOutputContent(Environment.NewLine, "Text") },
new TagHelperOutputContent(Environment.NewLine, "Text", "Text") },
{ modelWithText, typeof(Model), () => modelWithNull.Text, "Text",
new TagHelperOutputContent("Hello World", "Hello World") },
new TagHelperOutputContent("Hello World", "Hello World", "Text") },
{ modelWithText, typeof(Model), () => modelWithText.Text, "Text",
new TagHelperOutputContent("Hello World", "Hello World") },
new TagHelperOutputContent("Hello World", "Hello World", "Text") },
{ modelWithNull, typeof(NestedModel), () => modelWithNull.NestedModel.Text, "NestedModel.Text",
new TagHelperOutputContent(Environment.NewLine, "Text") },
new TagHelperOutputContent(Environment.NewLine, "Text", "NestedModel_Text") },
{ modelWithText, typeof(NestedModel), () => modelWithText.NestedModel.Text, "NestedModel.Text",
new TagHelperOutputContent(Environment.NewLine, "Text") },
new TagHelperOutputContent(Environment.NewLine, "Text", "NestedModel_Text") },
{ modelWithNull, typeof(NestedModel), () => modelWithNull.NestedModel.Text, "NestedModel.Text",
new TagHelperOutputContent("Hello World", "Hello World") },
new TagHelperOutputContent("Hello World", "Hello World", "NestedModel_Text") },
{ modelWithText, typeof(NestedModel), () => modelWithText.NestedModel.Text, "NestedModel.Text",
new TagHelperOutputContent("Hello World", "Hello World") },
new TagHelperOutputContent("Hello World", "Hello World", "NestedModel_Text") },
// Note: Tests cases below here will not work in practice due to current limitations on indexing
// into ModelExpressions. Will be fixed in https://github.com/aspnet/Mvc/issues/1345.
{ models, typeof(Model), () => models[0].Text, "[0].Text",
new TagHelperOutputContent(Environment.NewLine, "Text") },
new TagHelperOutputContent(Environment.NewLine, "Text", "z0__Text") },
{ models, typeof(Model), () => models[1].Text, "[1].Text",
new TagHelperOutputContent(Environment.NewLine, "Text") },
new TagHelperOutputContent(Environment.NewLine, "Text", "z1__Text") },
{ models, typeof(Model), () => models[0].Text, "[0].Text",
new TagHelperOutputContent("Hello World", "Hello World") },
new TagHelperOutputContent("Hello World", "Hello World", "z0__Text") },
{ models, typeof(Model), () => models[1].Text, "[1].Text",
new TagHelperOutputContent("Hello World", "Hello World") },
new TagHelperOutputContent("Hello World", "Hello World", "z1__Text") },
{ models, typeof(NestedModel), () => models[0].NestedModel.Text, "[0].NestedModel.Text",
new TagHelperOutputContent(Environment.NewLine, "Text") },
new TagHelperOutputContent(Environment.NewLine, "Text", "z0__NestedModel_Text") },
{ models, typeof(NestedModel), () => models[1].NestedModel.Text, "[1].NestedModel.Text",
new TagHelperOutputContent(Environment.NewLine, "Text") },
new TagHelperOutputContent(Environment.NewLine, "Text", "z1__NestedModel_Text") },
{ models, typeof(NestedModel), () => models[0].NestedModel.Text, "[0].NestedModel.Text",
new TagHelperOutputContent("Hello World", "Hello World") },
new TagHelperOutputContent("Hello World", "Hello World", "z0__NestedModel_Text") },
{ models, typeof(NestedModel), () => models[1].NestedModel.Text, "[1].NestedModel.Text",
new TagHelperOutputContent("Hello World", "Hello World") },
new TagHelperOutputContent("Hello World", "Hello World", "z1__NestedModel_Text") },
};
}
}
@ -100,7 +100,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
var expectedAttributes = new Dictionary<string, string>
{
{ "class", "form-control" },
{ "for", propertyPath }
{ "for", tagHelperOutputContent.ExpectedId }
};
var metadataProvider = new DataAnnotationsModelMetadataProvider();
@ -173,14 +173,18 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
public class TagHelperOutputContent
{
public TagHelperOutputContent(string outputContent, string expectedContent)
public TagHelperOutputContent(string outputContent, string expectedContent, string expectedId)
{
OriginalContent = outputContent;
ExpectedContent = expectedContent;
ExpectedId = expectedId;
}
public string OriginalContent { get; set; }
public string ExpectedContent { get; set; }
public string ExpectedId { get; set; }
}
private class Model

View File

@ -14,8 +14,8 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
public class TextAreaTagHelperTest
{
// Model (List<Model> or Model instance), container type (Model or NestModel), model accessor,
// property path, expected content.
public static TheoryData<object, Type, Func<object>, string, string> TestDataSet
// property path / id, expected content.
public static TheoryData<object, Type, Func<object>, NameAndId, string> TestDataSet
{
get
{
@ -41,31 +41,40 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
modelWithText,
};
return new TheoryData<object, Type, Func<object>, string, string>
return new TheoryData<object, Type, Func<object>, NameAndId, string>
{
{ null, typeof(Model), () => null, "Text",
{ null, typeof(Model), () => null,
new NameAndId("Text", "Text"),
Environment.NewLine },
{ modelWithNull, typeof(Model), () => modelWithNull.Text, "Text",
{ modelWithNull, typeof(Model), () => modelWithNull.Text,
new NameAndId("Text", "Text"),
Environment.NewLine },
{ modelWithText, typeof(Model), () => modelWithText.Text, "Text",
{ modelWithText, typeof(Model), () => modelWithText.Text,
new NameAndId("Text", "Text"),
Environment.NewLine + "outer text" },
{ modelWithNull, typeof(NestedModel), () => modelWithNull.NestedModel.Text, "NestedModel.Text",
{ modelWithNull, typeof(NestedModel), () => modelWithNull.NestedModel.Text,
new NameAndId("NestedModel.Text", "NestedModel_Text"),
Environment.NewLine },
{ modelWithText, typeof(NestedModel), () => modelWithText.NestedModel.Text, "NestedModel.Text",
{ modelWithText, typeof(NestedModel), () => modelWithText.NestedModel.Text,
new NameAndId("NestedModel.Text", "NestedModel_Text"),
Environment.NewLine + "inner text" },
// Top-level indexing does not work end-to-end due to code generation issue #1345.
// TODO: Remove above comment when #1345 is fixed.
{ models, typeof(Model), () => models[0].Text, "[0].Text",
{ models, typeof(Model), () => models[0].Text,
new NameAndId("[0].Text", "z0__Text"),
Environment.NewLine },
{ models, typeof(Model), () => models[1].Text, "[1].Text",
{ models, typeof(Model), () => models[1].Text,
new NameAndId("[1].Text", "z1__Text"),
Environment.NewLine + "outer text" },
{ models, typeof(NestedModel), () => models[0].NestedModel.Text, "[0].NestedModel.Text",
{ models, typeof(NestedModel), () => models[0].NestedModel.Text,
new NameAndId("[0].NestedModel.Text", "z0__NestedModel_Text"),
Environment.NewLine },
{ models, typeof(NestedModel), () => models[1].NestedModel.Text, "[1].NestedModel.Text",
{ models, typeof(NestedModel), () => models[1].NestedModel.Text,
new NameAndId("[1].NestedModel.Text", "z1__NestedModel_Text"),
Environment.NewLine + "inner text" },
};
}
@ -77,15 +86,15 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
object model,
Type containerType,
Func<object> modelAccessor,
string propertyPath,
NameAndId nameAndId,
string expectedContent)
{
// Arrange
var expectedAttributes = new Dictionary<string, string>
{
{ "class", "form-control" },
{ "id", propertyPath },
{ "name", propertyPath },
{ "id", nameAndId.Id },
{ "name", nameAndId.Name },
{ "valid", "from validation attributes" },
};
var expectedTagName = "textarea";
@ -94,7 +103,7 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
// Property name is either nameof(Model.Text) or nameof(NestedModel.Text).
var metadata = metadataProvider.GetMetadataForProperty(modelAccessor, containerType, propertyName: "Text");
var modelExpression = new ModelExpression(propertyPath, metadata);
var modelExpression = new ModelExpression(nameAndId.Name, metadata);
var tagHelper = new TextAreaTagHelper
{
For = modelExpression,
@ -178,6 +187,19 @@ namespace Microsoft.AspNet.Mvc.TagHelpers
Assert.Equal(expectedTagName, output.TagName);
}
public class NameAndId
{
public NameAndId(string name, string id)
{
Name = name;
Id = id;
}
public string Name { get; private set; }
public string Id { get; private set; }
}
private class Model
{
public string Text { get; set; }