Ensure SystemTextJsonHelper always HTML encodes output. (#12808)

* Ensure JsonSerializer always HTML encodes output.

* Update JsonOptions.JsonSerializerOptions to use encoder scheme that does not encode non-ASCII
  characters by default. This makes the encoding comparable to Json.NET's defaults
* In SystemTextJsonHelper, ensure that the content is always HTML-encoded
* Unskip skipped test

Fixes https://github.com/aspnet/AspNetCore/issues/9946
Fixes https://github.com/aspnet/AspNetCore/issues/11459
This commit is contained in:
Pranav K 2019-08-05 13:34:32 -07:00 committed by GitHub
parent 709b390157
commit 397f924e8d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 261 additions and 88 deletions

View File

@ -2210,7 +2210,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
}
public partial class SystemTextJsonOutputFormatter : Microsoft.AspNetCore.Mvc.Formatters.TextOutputFormatter
{
public SystemTextJsonOutputFormatter(Microsoft.AspNetCore.Mvc.JsonOptions options) { }
public SystemTextJsonOutputFormatter(System.Text.Json.JsonSerializerOptions jsonSerializerOptions) { }
public System.Text.Json.JsonSerializerOptions SerializerOptions { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } }
[System.Diagnostics.DebuggerStepThroughAttribute]
public sealed override System.Threading.Tasks.Task WriteResponseBodyAsync(Microsoft.AspNetCore.Mvc.Formatters.OutputFormatterWriteContext context, System.Text.Encoding selectedEncoding) { throw null; }

View File

@ -4,6 +4,7 @@
using System;
using System.IO;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@ -17,14 +18,13 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
/// </summary>
public class SystemTextJsonOutputFormatter : TextOutputFormatter
{
/// <summary>
/// Initializes a new <see cref="SystemTextJsonOutputFormatter"/> instance.
/// </summary>
/// <param name="options">The <see cref="JsonOptions"/>.</param>
public SystemTextJsonOutputFormatter(JsonOptions options)
/// <param name="jsonSerializerOptions">The <see cref="JsonSerializerOptions"/>.</param>
public SystemTextJsonOutputFormatter(JsonSerializerOptions jsonSerializerOptions)
{
SerializerOptions = options.JsonSerializerOptions;
SerializerOptions = jsonSerializerOptions;
SupportedEncodings.Add(Encoding.UTF8);
SupportedEncodings.Add(Encoding.Unicode);
@ -33,6 +33,19 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
SupportedMediaTypes.Add(MediaTypeHeaderValues.ApplicationAnyJsonSyntax);
}
internal static SystemTextJsonOutputFormatter CreateFormatter(JsonOptions jsonOptions)
{
var jsonSerializerOptions = jsonOptions.JsonSerializerOptions;
if (jsonSerializerOptions.Encoder is null)
{
// If the user hasn't explicitly configured the encoder, use the less strict encoder that does not encode all non-ASCII characters.
jsonSerializerOptions = jsonSerializerOptions.Copy(JavaScriptEncoder.UnsafeRelaxedJsonEscaping);
}
return new SystemTextJsonOutputFormatter(jsonSerializerOptions);
}
/// <summary>
/// Gets the <see cref="JsonSerializerOptions"/> used to configure the <see cref="JsonSerializer"/>.
/// </summary>

View File

@ -0,0 +1,36 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Text.Encodings.Web;
namespace System.Text.Json
{
internal static class JsonSerializerOptionsCopyConstructor
{
public static JsonSerializerOptions Copy(this JsonSerializerOptions serializerOptions, JavaScriptEncoder encoder)
{
var copiedOptions = new JsonSerializerOptions
{
AllowTrailingCommas = serializerOptions.AllowTrailingCommas,
DefaultBufferSize = serializerOptions.DefaultBufferSize,
DictionaryKeyPolicy = serializerOptions.DictionaryKeyPolicy,
IgnoreNullValues = serializerOptions.IgnoreNullValues,
IgnoreReadOnlyProperties = serializerOptions.IgnoreReadOnlyProperties,
MaxDepth = serializerOptions.MaxDepth,
PropertyNameCaseInsensitive = serializerOptions.PropertyNameCaseInsensitive,
PropertyNamingPolicy = serializerOptions.PropertyNamingPolicy,
ReadCommentHandling = serializerOptions.ReadCommentHandling,
WriteIndented = serializerOptions.WriteIndented
};
for (var i = 0; i < serializerOptions.Converters.Count; i++)
{
copiedOptions.Converters.Add(serializerOptions.Converters[i]);
}
copiedOptions.Encoder = encoder;
return copiedOptions;
}
}
}

View File

@ -87,7 +87,9 @@ namespace Microsoft.AspNetCore.Mvc
options.OutputFormatters.Add(new HttpNoContentOutputFormatter());
options.OutputFormatters.Add(new StringOutputFormatter());
options.OutputFormatters.Add(new StreamOutputFormatter());
options.OutputFormatters.Add(new SystemTextJsonOutputFormatter(_jsonOptions.Value));
var jsonOutputFormatter = SystemTextJsonOutputFormatter.CreateFormatter(_jsonOptions.Value);
options.OutputFormatters.Add(jsonOutputFormatter);
// Set up ValueProviders
options.ValueProviderFactories.Add(new FormValueProviderFactory());

View File

@ -91,7 +91,7 @@ namespace Microsoft.AspNetCore.Mvc
{
var options = Options.Create(new MvcOptions());
options.Value.OutputFormatters.Add(new StringOutputFormatter());
options.Value.OutputFormatters.Add(new SystemTextJsonOutputFormatter(new JsonOptions()));
options.Value.OutputFormatters.Add(SystemTextJsonOutputFormatter.CreateFormatter(new JsonOptions()));
var services = new ServiceCollection();
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(

View File

@ -104,7 +104,7 @@ namespace Microsoft.AspNetCore.Mvc
{
var options = Options.Create(new MvcOptions());
options.Value.OutputFormatters.Add(new StringOutputFormatter());
options.Value.OutputFormatters.Add(new SystemTextJsonOutputFormatter(new JsonOptions()));
options.Value.OutputFormatters.Add(SystemTextJsonOutputFormatter.CreateFormatter(new JsonOptions()));
var services = new ServiceCollection();
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(

View File

@ -92,7 +92,7 @@ namespace Microsoft.AspNetCore.Mvc
{
var options = Options.Create(new MvcOptions());
options.Value.OutputFormatters.Add(new StringOutputFormatter());
options.Value.OutputFormatters.Add(new SystemTextJsonOutputFormatter(new JsonOptions()));
options.Value.OutputFormatters.Add(SystemTextJsonOutputFormatter.CreateFormatter(new JsonOptions()));
var services = new ServiceCollection();
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(

View File

@ -465,7 +465,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
// Set up default output formatters.
MvcOptions.OutputFormatters.Add(new HttpNoContentOutputFormatter());
MvcOptions.OutputFormatters.Add(new StringOutputFormatter());
MvcOptions.OutputFormatters.Add(new SystemTextJsonOutputFormatter(new JsonOptions()));
MvcOptions.OutputFormatters.Add(SystemTextJsonOutputFormatter.CreateFormatter(new JsonOptions()));
// Set up default mapping for json extensions to content type
MvcOptions.FormatterMappings.SetMediaTypeMappingForFormat(

View File

@ -76,6 +76,71 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
}
}
[Theory]
[MemberData(nameof(WriteCorrectCharacterEncoding))]
public async Task WriteToStreamAsync_UsesCorrectCharacterEncoding(
string content,
string encodingAsString,
bool isDefaultEncoding)
{
// Arrange
var formatter = GetOutputFormatter();
var expectedContent = "\"" + content + "\"";
var mediaType = MediaTypeHeaderValue.Parse(string.Format("application/json; charset={0}", encodingAsString));
var encoding = CreateOrGetSupportedEncoding(formatter, encodingAsString, isDefaultEncoding);
var body = new MemoryStream();
var actionContext = GetActionContext(mediaType, body);
var outputFormatterContext = new OutputFormatterWriteContext(
actionContext.HttpContext,
new TestHttpResponseStreamWriterFactory().CreateWriter,
typeof(string),
content)
{
ContentType = new StringSegment(mediaType.ToString()),
};
// Act
await formatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.GetEncoding(encodingAsString));
// Assert
var actualContent = encoding.GetString(body.ToArray());
Assert.Equal(expectedContent, actualContent, StringComparer.OrdinalIgnoreCase);
}
[Fact]
public async Task WriteResponseBodyAsync_Encodes()
{
// Arrange
var formatter = GetOutputFormatter();
var expectedContent = "{\"key\":\"Hello \\n <b>Wörld</b>\"}";
var content = new { key = "Hello \n <b>Wörld</b>" };
var mediaType = MediaTypeHeaderValue.Parse("application/json; charset=utf-8");
var encoding = CreateOrGetSupportedEncoding(formatter, "utf-8", isDefaultEncoding: true);
var body = new MemoryStream();
var actionContext = GetActionContext(mediaType, body);
var outputFormatterContext = new OutputFormatterWriteContext(
actionContext.HttpContext,
new TestHttpResponseStreamWriterFactory().CreateWriter,
typeof(string),
content)
{
ContentType = new StringSegment(mediaType.ToString()),
};
// Act
await formatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.GetEncoding("utf-8"));
// Assert
var actualContent = encoding.GetString(body.ToArray());
Assert.Equal(expectedContent, actualContent);
}
[Fact]
public async Task ErrorDuringSerialization_DoesNotCloseTheBrackets()
{

View File

@ -1,56 +1,13 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.IO;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.Formatters
{
public class SystemTextJsonOutputFormatterTest : JsonOutputFormatterTestBase
{
protected override TextOutputFormatter GetOutputFormatter()
{
return new SystemTextJsonOutputFormatter(new JsonOptions());
}
[Theory]
[MemberData(nameof(WriteCorrectCharacterEncoding))]
public async Task WriteToStreamAsync_UsesCorrectCharacterEncoding(
string content,
string encodingAsString,
bool isDefaultEncoding)
{
// Arrange
var formatter = GetOutputFormatter();
var expectedContent = "\"" + JavaScriptEncoder.Default.Encode(content) + "\"";
var mediaType = MediaTypeHeaderValue.Parse(string.Format("application/json; charset={0}", encodingAsString));
var encoding = CreateOrGetSupportedEncoding(formatter, encodingAsString, isDefaultEncoding);
var body = new MemoryStream();
var actionContext = GetActionContext(mediaType, body);
var outputFormatterContext = new OutputFormatterWriteContext(
actionContext.HttpContext,
new TestHttpResponseStreamWriterFactory().CreateWriter,
typeof(string),
content)
{
ContentType = new StringSegment(mediaType.ToString()),
};
// Act
await formatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.GetEncoding(encodingAsString));
// Assert
var actualContent = encoding.GetString(body.ToArray());
Assert.Equal(expectedContent, actualContent, StringComparer.OrdinalIgnoreCase);
return SystemTextJsonOutputFormatter.CreateFormatter(new JsonOptions());
}
}
}

View File

@ -69,7 +69,7 @@ namespace Microsoft.AspNetCore.Mvc
{
var options = Options.Create(new MvcOptions());
options.Value.OutputFormatters.Add(new StringOutputFormatter());
options.Value.OutputFormatters.Add(new SystemTextJsonOutputFormatter(new JsonOptions()));
options.Value.OutputFormatters.Add(SystemTextJsonOutputFormatter.CreateFormatter(new JsonOptions()));
var services = new ServiceCollection();
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(

View File

@ -70,7 +70,7 @@ namespace Microsoft.AspNetCore.Mvc
{
var options = Options.Create(new MvcOptions());
options.Value.OutputFormatters.Add(new StringOutputFormatter());
options.Value.OutputFormatters.Add(new SystemTextJsonOutputFormatter(new JsonOptions()));
options.Value.OutputFormatters.Add(SystemTextJsonOutputFormatter.CreateFormatter(new JsonOptions()));
var services = new ServiceCollection();
services.AddSingleton<IActionResultExecutor<ObjectResult>>(new ObjectResultExecutor(

View File

@ -2,6 +2,7 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.AspNetCore.Html;
using Microsoft.Extensions.Options;
@ -10,11 +11,11 @@ namespace Microsoft.AspNetCore.Mvc.Rendering
{
internal class SystemTextJsonHelper : IJsonHelper
{
private readonly JsonOptions _options;
private readonly JsonSerializerOptions _htmlSafeJsonSerializerOptions;
public SystemTextJsonHelper(IOptions<JsonOptions> options)
{
_options = options.Value;
_htmlSafeJsonSerializerOptions = GetHtmlSafeSerializerOptions(options.Value.JsonSerializerOptions);
}
/// <inheritdoc />
@ -22,8 +23,18 @@ namespace Microsoft.AspNetCore.Mvc.Rendering
{
// JsonSerializer always encodes non-ASCII chars, so we do not need
// to do anything special with the SerializerOptions
var json = JsonSerializer.Serialize(value, _options.JsonSerializerOptions);
var json = JsonSerializer.Serialize(value, _htmlSafeJsonSerializerOptions);
return new HtmlString(json);
}
private static JsonSerializerOptions GetHtmlSafeSerializerOptions(JsonSerializerOptions serializerOptions)
{
if (serializerOptions.Encoder is null || serializerOptions.Encoder == JavaScriptEncoder.Default)
{
return serializerOptions;
}
return serializerOptions.Copy(JavaScriptEncoder.Default);
}
}
}

View File

@ -25,7 +25,7 @@ namespace Microsoft.AspNetCore.Mvc.Rendering
// Assert
var htmlString = Assert.IsType<HtmlString>(result);
Assert.Equal(expectedOutput, htmlString.ToString());
Assert.Equal(expectedOutput, htmlString.ToString(), ignoreCase: true);
}
[Fact]
@ -71,14 +71,14 @@ namespace Microsoft.AspNetCore.Mvc.Rendering
{
HTML = $"Hello pingüino"
};
var expectedOutput = "{\"html\":\"Hello ping\\u00fcino\"}";
var expectedOutput = "{\"html\":\"Hello pingüino\"}";
// Act
var result = helper.Serialize(obj);
// Assert
var htmlString = Assert.IsType<HtmlString>(result);
Assert.Equal(expectedOutput, htmlString.ToString());
Assert.Equal(expectedOutput, htmlString.ToString(), ignoreCase: true);
}
[Fact]
@ -99,5 +99,25 @@ namespace Microsoft.AspNetCore.Mvc.Rendering
var htmlString = Assert.IsType<HtmlString>(result);
Assert.Equal(expectedOutput, htmlString.ToString());
}
[Fact]
public virtual void Serialize_WithHTMLNonAsciiAndControlChars()
{
// Arrange
var helper = GetJsonHelper();
var obj = new
{
HTML = "<b>Hello \n pingüino</b>"
};
var expectedOutput = "{\"html\":\"\\u003cb\\u003eHello \\n pingüino\\u003c/b\\u003e\"}";
// Act
var result = helper.Serialize(obj);
// Assert
var htmlString = Assert.IsType<HtmlString>(result);
Assert.Equal(expectedOutput, htmlString.ToString());
}
}
}

View File

@ -1,7 +1,7 @@
// Copyright (c) .NET Foundation. All rights reserved
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Text.Json;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Html;
using Microsoft.Extensions.Options;
using Xunit;
@ -10,31 +10,13 @@ namespace Microsoft.AspNetCore.Mvc.Rendering
{
public class SystemTextJsonHelperTest : JsonHelperTestBase
{
protected override IJsonHelper GetJsonHelper()
protected override IJsonHelper GetJsonHelper() => GetJsonHelper(new JsonOptions());
private static IJsonHelper GetJsonHelper(JsonOptions options)
{
var options = new JsonOptions() { JsonSerializerOptions = { PropertyNamingPolicy = JsonNamingPolicy.CamelCase } };
return new SystemTextJsonHelper(Options.Create(options));
}
[Fact]
public override void Serialize_EscapesHtmlByDefault()
{
// Arrange
var helper = GetJsonHelper();
var obj = new
{
HTML = "<b>John Doe</b>"
};
var expectedOutput = "{\"html\":\"\\u003Cb\\u003EJohn Doe\\u003C/b\\u003E\"}";
// Act
var result = helper.Serialize(obj);
// Assert
var htmlString = Assert.IsType<HtmlString>(result);
Assert.Equal(expectedOutput, htmlString.ToString());
}
[Fact]
public override void Serialize_WithNonAsciiChars()
{
@ -53,5 +35,56 @@ namespace Microsoft.AspNetCore.Mvc.Rendering
var htmlString = Assert.IsType<HtmlString>(result);
Assert.Equal(expectedOutput, htmlString.ToString());
}
[Fact]
public override void Serialize_WithHTMLNonAsciiAndControlChars()
{
// Arrange
var helper = GetJsonHelper();
var obj = new
{
HTML = "<b>Hello \n pingüino</b>"
};
var expectedOutput = "{\"html\":\"\\u003Cb\\u003EHello \\n ping\\u00FCino\\u003C/b\\u003E\"}";
// Act
var result = helper.Serialize(obj);
// Assert
var htmlString = Assert.IsType<HtmlString>(result);
Assert.Equal(expectedOutput, htmlString.ToString());
}
[Fact]
public void Serialize_UsesOptionsConfiguredInTheProvider()
{
// Arrange
// This should use property-casing and indentation, but the result should be HTML-safe
var options = new JsonOptions
{
JsonSerializerOptions =
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
PropertyNamingPolicy = null,
WriteIndented = true,
}
};
var helper = GetJsonHelper(options);
var obj = new
{
HTML = "<b>John</b>"
};
var expectedOutput =
@"{
""HTML"": ""\u003Cb\u003EJohn\u003C/b\u003E""
}";
// Act
var result = helper.Serialize(obj);
// Assert
var htmlString = Assert.IsType<HtmlString>(result);
Assert.Equal(expectedOutput, htmlString.ToString());
}
}
}

View File

@ -2,7 +2,6 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
@ -11,6 +10,7 @@ using System.Text;
using System.Threading.Tasks;
using FormatterWebSite.Controllers;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Xunit;
@ -21,13 +21,14 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
protected JsonOutputFormatterTestBase(MvcTestFixture<TStartup> fixture)
{
var factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
Client = factory.CreateDefaultClient();
Factory = fixture.Factories.FirstOrDefault() ?? fixture.WithWebHostBuilder(ConfigureWebHostBuilder);
Client = Factory.CreateDefaultClient();
}
private static void ConfigureWebHostBuilder(IWebHostBuilder builder) =>
builder.UseStartup<TStartup>();
public WebApplicationFactory<TStartup> Factory { get; }
public HttpClient Client { get; }
[Fact]
@ -100,6 +101,17 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Equal("\"Hello Mr. 🦊\"", await response.Content.ReadAsStringAsync());
}
[Fact]
public virtual async Task Formatting_StringValueWithNonAsciiCharacters()
{
// Act
var response = await Client.GetAsync($"/JsonOutputFormatter/{nameof(JsonOutputFormatterController.StringWithNonAsciiContent)}");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
Assert.Equal("\"Une bête de cirque\"", await response.Content.ReadAsStringAsync());
}
[Fact]
public virtual async Task Formatting_SimpleModel()
{

View File

@ -2,8 +2,10 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Net;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using FormatterWebSite.Controllers;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.FunctionalTests
@ -15,7 +17,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
{
}
[Fact(Skip = "https://github.com/aspnet/AspNetCore/issues/11459")]
[Fact]
public override Task SerializableErrorIsReturnedInExpectedFormat() => base.SerializableErrorIsReturnedInExpectedFormat();
[Fact]
@ -29,6 +31,25 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests
Assert.Equal("\"Hello Mr. \\uD83E\\uDD8A\"", await response.Content.ReadAsStringAsync());
}
[Fact]
public async Task Formatting_WithCustomEncoder()
{
// Arrange
static void ConfigureServices(IServiceCollection serviceCollection)
{
serviceCollection.AddControllers()
.AddJsonOptions(o => o.JsonSerializerOptions.Encoder = JavaScriptEncoder.Default);
}
var client = Factory.WithWebHostBuilder(c => c.ConfigureServices(ConfigureServices)).CreateClient();
// Act
var response = await client.GetAsync($"/JsonOutputFormatter/{nameof(JsonOutputFormatterController.StringWithNonAsciiContent)}");
// Assert
await response.AssertStatusCodeAsync(HttpStatusCode.OK);
Assert.Equal("\"Une b\\u00EAte de cirque\"", await response.Content.ReadAsStringAsync());
}
[Fact]
public override Task Formatting_DictionaryType() => base.Formatting_DictionaryType();

View File

@ -20,6 +20,9 @@ namespace FormatterWebSite.Controllers
[HttpGet]
public ActionResult<string> StringWithUnicodeResult() => "Hello Mr. 🦊";
[HttpGet]
public ActionResult<string> StringWithNonAsciiContent() => "Une bête de cirque";
[HttpGet]
public ActionResult<SimpleModel> SimpleModelResult() =>
new SimpleModel { Id = 10, Name = "Test", StreetName = "Some street" };