Skip to content

Commit

Permalink
[release/8.0] [Blazor] Fix type name hashing when the type has multib…
Browse files Browse the repository at this point in the history
…yte characters (#52316)

Backport of #52232 to release/8.0

/cc @MackinnonBuck

# [Blazor] Fix type name hashing when the type has multibyte characters

Fixes an issue where an exception gets thrown if a render mode boundary component has a type whose full name contains multibyte characters.

## Description

The bug results in an exception getting thrown if the type of a component with a render mode has a full name containing multibyte characters. It especially affects cases where a component (or the namespace it's defined in) contains non-Latin characters.

This PR fixes the issue by allocating a constant-sized stack buffer and falling back to a heap-allocated buffer when the type name is too long.

Fixes #50879
Fixes #52109

## Customer Impact

Blazor Apps with non-Latin code might not be able to use interactivity. The workaround is to ensure that the full name of any component serving as a render mode boundary does not contain multibyte characters.

## Regression?

- [ ] Yes
- [X] No

Render modes are a new feature in .NET 8, so this bug is not a regression.

## Risk

- [ ] High
- [ ] Medium
- [X] Low

The fix is straightforward and we have new automated tests for this scenario.

## Verification

- [X] Manual (required)
- [X] Automated

## Packaging changes reviewed?

- [ ] Yes
- [ ] No
- [X] N/A

---------

Co-authored-by: Mackinnon Buck <[email protected]>
  • Loading branch information
github-actions[bot] and MackinnonBuck authored Nov 27, 2023
1 parent 6a745a4 commit 4373734
Show file tree
Hide file tree
Showing 6 changed files with 217 additions and 23 deletions.
24 changes: 1 addition & 23 deletions src/Components/Endpoints/src/Rendering/SSRRenderModeBoundary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.Web;
Expand Down Expand Up @@ -192,7 +190,7 @@ public ComponentMarker ToMarker(HttpContext httpContext, int sequence, object? c

private ComponentMarkerKey GenerateMarkerKey(int sequence, object? componentKey)
{
var componentTypeNameHash = _componentTypeNameHashCache.GetOrAdd(_componentType, ComputeComponentTypeNameHash);
var componentTypeNameHash = _componentTypeNameHashCache.GetOrAdd(_componentType, TypeNameHash.Compute);
var sequenceString = sequence.ToString(CultureInfo.InvariantCulture);

var locationHash = $"{componentTypeNameHash}:{sequenceString}";
Expand All @@ -204,24 +202,4 @@ private ComponentMarkerKey GenerateMarkerKey(int sequence, object? componentKey)
FormattedComponentKey = formattedComponentKey,
};
}

private static string ComputeComponentTypeNameHash(Type componentType)
{
if (componentType.FullName is not { } typeName)
{
throw new InvalidOperationException($"An invalid component type was used in {nameof(SSRRenderModeBoundary)}.");
}

var typeNameLength = typeName.Length;
var typeNameBytes = typeNameLength < 1024
? stackalloc byte[typeNameLength]
: new byte[typeNameLength];

Encoding.UTF8.GetBytes(typeName, typeNameBytes);

Span<byte> typeNameHashBytes = stackalloc byte[SHA1.HashSizeInBytes];
SHA1.HashData(typeNameBytes, typeNameHashBytes);

return Convert.ToHexString(typeNameHashBytes);
}
}
34 changes: 34 additions & 0 deletions src/Components/Endpoints/src/Rendering/TypeNameHash.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Security.Cryptography;
using System.Text;

namespace Microsoft.AspNetCore.Components.Endpoints;

// Internal for testing.
internal class TypeNameHash
{
public const int MaxStackBufferSize = 1024;

public static string Compute(Type type)
{
if (type.FullName is not { } typeName)
{
throw new InvalidOperationException($"Cannot compute a hash for a type without a {nameof(Type.FullName)}.");
}

Span<byte> typeNameBytes = stackalloc byte[MaxStackBufferSize];

if (!Encoding.UTF8.TryGetBytes(typeName, typeNameBytes, out var written))
{
typeNameBytes = Encoding.UTF8.GetBytes(typeName);
written = typeNameBytes.Length;
}

Span<byte> typeNameHashBytes = stackalloc byte[SHA256.HashSizeInBytes];
SHA256.HashData(typeNameBytes[..written], typeNameHashBytes);

return Convert.ToHexString(typeNameHashBytes);
}
}
81 changes: 81 additions & 0 deletions src/Components/Endpoints/test/TypeNameHashTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Security.Cryptography;

namespace Microsoft.AspNetCore.Components.Endpoints;

public class TypeNameHashTest
{
// In these tests, we're mostly interested in checking that the hash function succeeds
// for any type with a valid name. We'll also do some basic sanity checking by ensuring
// that the string representation of the hash has the expected length.

// We currently use a hex-encoded SHA256 hash, so there should be two characters per byte
// of encoded data.
private const int ExpectedHashLength = SHA256.HashSizeInBytes * 2;

[Fact]
public void CanComputeHashForTypeWithBasicName()
{
// Act
var hash = TypeNameHash.Compute(typeof(ClassWithBasicName));

// Assert
Assert.Equal(ExpectedHashLength, hash.Length);
}

[Fact]
public void CanComputeHashForTypeWithMultibyteCharacters()
{
// Act
var hash = TypeNameHash.Compute(typeof(ClássWïthMûltibyteÇharacters));

// Assert
Assert.Equal(ExpectedHashLength, hash.Length);
}

[Fact]
public void CanComputeHashForAnonymousType()
{
// Arrange
var type = new { Foo = "bar" }.GetType();

// Act
var hash = TypeNameHash.Compute(type);

// Assert
Assert.Equal(ExpectedHashLength, hash.Length);
}

[Fact]
public void CanComputeHashForTypeWithNameLongerThanMaxStackBufferSize()
{
// Arrange
// We need to use a type with a long name, so we'll use a large tuple.
// We have an assert later in this test to sanity check that the type
// name is indeed longer than the max stack buffer size.
var type = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12).GetType();

// Act
var hash = TypeNameHash.Compute(type);

// Assert
Assert.True(type.FullName.Length > TypeNameHash.MaxStackBufferSize);
Assert.Equal(ExpectedHashLength, hash.Length);
}

[Fact]
public void ThrowsIfTypeHasNoName()
{
// Arrange
var type = typeof(Nullable<>).GetGenericArguments()[0];

// Act/Assert
var ex = Assert.Throws<InvalidOperationException>(() => TypeNameHash.Compute(type));
Assert.Equal($"Cannot compute a hash for a type without a {nameof(Type.FullName)}.", ex.Message);
}

class ClassWithBasicName;
class ClássWïthMûltibyteÇharacters;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Components.TestServer.RazorComponents;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using TestServer;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;

public class MultibyteComponentTypeNameTest : ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>
{
public MultibyteComponentTypeNameTest(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}

[Theory]
[InlineData("server")]
[InlineData("webassembly")]
public void CanRenderInteractiveComponentsWithMultibyteName(string renderMode)
{
Navigate($"{ServerPathBase}/multibyte-character-component/{renderMode}");

Browser.Equal("True", () => Browser.FindElement(By.ClassName("is-interactive")).Text);
Browser.Equal("0", () => Browser.FindElement(By.ClassName("count")).Text);

Browser.FindElement(By.ClassName("increment")).Click();

Browser.Equal("1", () => Browser.FindElement(By.ClassName("count")).Text);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
@page "/multibyte-character-component/{renderModeString?}"
@using TestContentPackage

<h1>Page rendering component with multibyte type name</h1>

@if (_renderMode is null)
{
<p>
<b>Warning:</b> Render mode should be specified as a route parameter and have the value 'server' or 'webassembly'.
</p>

<p>
Defaulting to a null render mode.
</p>
}

<MûltibyteÇharacterCompoñent @rendermode="_renderMode" />

@code {
private IComponentRenderMode? _renderMode;

[Parameter]
public string? RenderModeString { get; set; }

protected override void OnInitialized()
{
if (string.Equals("server", RenderModeString, StringComparison.OrdinalIgnoreCase))
{
_renderMode = RenderMode.InteractiveServer;
}
else if (string.Equals("webassembly", RenderModeString, StringComparison.OrdinalIgnoreCase))
{
_renderMode = RenderMode.InteractiveWebAssembly;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<p>
<button class="increment" type="button" @onclick="IncrementCount">Click me</button>
Count: <span class="count">@_count</span>
</p>

<p>
Is interactive: <span class="is-interactive">@_isInteractive</span>
</p>

@code {
private int _count = 0;
private bool _isInteractive;

private void IncrementCount()
{
_count++;
}

protected override void OnAfterRender(bool firstRender)
{
if (firstRender)
{
_isInteractive = true;
StateHasChanged();
}
}
}

0 comments on commit 4373734

Please sign in to comment.