Skip to content

Commit d72412b

Browse files
linkdotnetegil
andauthored
fix: Escape text inside html tag and attributes
* fix: Added tests for escpable characters * fix: Escape text inside html tag * Added changelog entry * chore: code cleanup * fix: optimize char replacement --------- Co-authored-by: Egil Hansen <[email protected]>
1 parent 2cab01b commit d72412b

File tree

4 files changed

+162
-53
lines changed

4 files changed

+162
-53
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ All notable changes to **bUnit** will be documented in this file. The project ad
66

77
## [Unreleased]
88

9+
### Changed
10+
11+
- Some characters where not properly escaped. Reported by [@pwhe23](https://github.com/pwhe23). Fixed by [@linkdotnet](https://github.com/linkdotnet).
12+
913
## [1.17.2] - 2023-02-22
1014

1115
- Submit buttons and input fields now no longer cause a form submit when they have the `@onclick:preventDefault` attribute. By [@JelleHissink](https://github.com/JelleHissink).

src/bunit.web/Rendering/Internal/Htmlizer.cs

Lines changed: 151 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
using System.Diagnostics;
66
using System.Globalization;
77
using System.Text;
8-
using System.Text.Encodings.Web;
9-
using System.Text.Unicode;
108
using Bunit.Rendering;
119

1210
namespace Bunit;
@@ -22,30 +20,34 @@ internal static class Htmlizer
2220
internal const string BlazorAttrPrefix = "blazor:";
2321
internal const string ElementReferenceAttrName = BlazorAttrPrefix + "elementReference";
2422

25-
private static readonly HashSet<string> SelfClosingElements = new(StringComparer.OrdinalIgnoreCase)
26-
{
27-
"area",
28-
"base",
29-
"br",
30-
"col",
31-
"embed",
32-
"hr",
33-
"img",
34-
"input",
35-
"link",
36-
"meta",
37-
"param",
38-
"source",
39-
"track",
40-
"wbr",
41-
};
23+
private static readonly HashSet<string> SelfClosingElements =
24+
new(StringComparer.OrdinalIgnoreCase)
25+
{
26+
"area",
27+
"base",
28+
"br",
29+
"col",
30+
"embed",
31+
"hr",
32+
"img",
33+
"input",
34+
"link",
35+
"meta",
36+
"param",
37+
"source",
38+
"track",
39+
"wbr",
40+
};
4241

4342
public static bool IsBlazorAttribute(string attributeName)
4443
{
4544
if (attributeName is null)
45+
{
4646
throw new ArgumentNullException(nameof(attributeName));
47-
return attributeName.StartsWith(BlazorAttrPrefix, StringComparison.Ordinal) ||
48-
attributeName.StartsWith(BlazorCssScopeAttrPrefix, StringComparison.Ordinal);
47+
}
48+
49+
return attributeName.StartsWith(BlazorAttrPrefix, StringComparison.Ordinal)
50+
|| attributeName.StartsWith(BlazorCssScopeAttrPrefix, StringComparison.Ordinal);
4951
}
5052

5153
public static string ToBlazorAttribute(string attributeName)
@@ -58,11 +60,19 @@ public static string GetHtml(int componentId, RenderTreeFrameDictionary framesCo
5860
var context = new HtmlRenderingContext(framesCollection);
5961
var frames = context.GetRenderTreeFrames(componentId);
6062
var newPosition = RenderFrames(context, frames, 0, frames.Length);
61-
Debug.Assert(newPosition == frames.Length, $"frames.Length = {frames.Length}. newPosition = {newPosition}");
63+
Debug.Assert(
64+
newPosition == frames.Length,
65+
$"frames.Length = {frames.Length}. newPosition = {newPosition}"
66+
);
6267
return context.Result.ToString();
6368
}
6469

65-
private static int RenderFrames(HtmlRenderingContext context, ReadOnlySpan<RenderTreeFrame> frames, int position, int maxElements)
70+
private static int RenderFrames(
71+
HtmlRenderingContext context,
72+
ReadOnlySpan<RenderTreeFrame> frames,
73+
int position,
74+
int maxElements
75+
)
6676
{
6777
var nextPosition = position;
6878
var endPosition = position + maxElements;
@@ -84,17 +94,20 @@ private static int RenderFrames(HtmlRenderingContext context, ReadOnlySpan<Rende
8494
private static int RenderCore(
8595
HtmlRenderingContext context,
8696
ReadOnlySpan<RenderTreeFrame> frames,
87-
int position)
97+
int position
98+
)
8899
{
89100
var frame = frames[position];
90101
switch (frame.FrameType)
91102
{
92103
case RenderTreeFrameType.Element:
93104
return RenderElement(context, frames, position);
94105
case RenderTreeFrameType.Attribute:
95-
throw new InvalidOperationException($"Attributes should only be encountered within {nameof(RenderElement)}");
106+
throw new InvalidOperationException(
107+
$"Attributes should only be encountered within {nameof(RenderElement)}"
108+
);
96109
case RenderTreeFrameType.Text:
97-
context.Result.Append(frame.TextContent);
110+
AppendEscapeText(context, frame.TextContent);
98111
return position + 1;
99112
case RenderTreeFrameType.Markup:
100113
context.Result.Append(frame.MarkupContent);
@@ -107,14 +120,17 @@ private static int RenderCore(
107120
case RenderTreeFrameType.ComponentReferenceCapture:
108121
return position + 1;
109122
default:
110-
throw new InvalidOperationException($"Invalid element frame type '{frame.FrameType}'.");
123+
throw new InvalidOperationException(
124+
$"Invalid element frame type '{frame.FrameType}'."
125+
);
111126
}
112127
}
113128

114129
private static int RenderChildComponent(
115130
HtmlRenderingContext context,
116131
ReadOnlySpan<RenderTreeFrame> frames,
117-
int position)
132+
int position
133+
)
118134
{
119135
var frame = frames[position];
120136
var childFrames = context.GetRenderTreeFrames(frame.ComponentId);
@@ -125,20 +141,33 @@ private static int RenderChildComponent(
125141
private static int RenderElement(
126142
HtmlRenderingContext context,
127143
ReadOnlySpan<RenderTreeFrame> frames,
128-
int position)
144+
int position
145+
)
129146
{
130147
var frame = frames[position];
131148
var result = context.Result;
132149
result.Append('<');
133150
result.Append(frame.ElementName);
134-
var afterAttributes = RenderAttributes(context, frames, position + 1, frame.ElementSubtreeLength - 1, out var capturedValueAttribute);
151+
var afterAttributes = RenderAttributes(
152+
context,
153+
frames,
154+
position + 1,
155+
frame.ElementSubtreeLength - 1,
156+
out var capturedValueAttribute
157+
);
135158

136159
// When we see an <option> as a descendant of a <select>, and the option's "value" attribute matches the
137160
// "value" attribute on the <select>, then we auto-add the "selected" attribute to that option. This is
138161
// a way of converting Blazor's select binding feature to regular static HTML.
139-
if (context.ClosestSelectValueAsString != null
162+
if (
163+
context.ClosestSelectValueAsString != null
140164
&& string.Equals(frame.ElementName, "option", StringComparison.OrdinalIgnoreCase)
141-
&& string.Equals(capturedValueAttribute, context.ClosestSelectValueAsString, StringComparison.Ordinal))
165+
&& string.Equals(
166+
capturedValueAttribute,
167+
context.ClosestSelectValueAsString,
168+
StringComparison.Ordinal
169+
)
170+
)
142171
{
143172
result.Append(" selected");
144173
}
@@ -148,7 +177,11 @@ private static int RenderElement(
148177
{
149178
result.Append('>');
150179

151-
var isSelect = string.Equals(frame.ElementName, "select", StringComparison.OrdinalIgnoreCase);
180+
var isSelect = string.Equals(
181+
frame.ElementName,
182+
"select",
183+
StringComparison.OrdinalIgnoreCase
184+
);
152185
if (isSelect)
153186
{
154187
context.ClosestSelectValueAsString = capturedValueAttribute;
@@ -181,11 +214,19 @@ private static int RenderElement(
181214
result.Append('>');
182215
}
183216

184-
Debug.Assert(afterAttributes == position + frame.ElementSubtreeLength, $"afterAttributes = {afterAttributes}. position = {position}. frame.ElementSubtreeLength = {frame.ElementSubtreeLength}");
217+
Debug.Assert(
218+
afterAttributes == position + frame.ElementSubtreeLength,
219+
$"afterAttributes = {afterAttributes}. position = {position}. frame.ElementSubtreeLength = {frame.ElementSubtreeLength}"
220+
);
185221
return afterAttributes;
186222
}
187223

188-
private static int RenderChildren(HtmlRenderingContext context, ReadOnlySpan<RenderTreeFrame> frames, int position, int maxElements)
224+
private static int RenderChildren(
225+
HtmlRenderingContext context,
226+
ReadOnlySpan<RenderTreeFrame> frames,
227+
int position,
228+
int maxElements
229+
)
189230
{
190231
if (maxElements == 0)
191232
{
@@ -195,13 +236,13 @@ private static int RenderChildren(HtmlRenderingContext context, ReadOnlySpan<Ren
195236
return RenderFrames(context, frames, position, maxElements);
196237
}
197238

198-
[SuppressMessage("Design", "MA0051:Method is too long", Justification = "TODO: Refactor")]
199239
private static int RenderAttributes(
200240
HtmlRenderingContext context,
201241
ReadOnlySpan<RenderTreeFrame> frames,
202242
int position,
203243
int maxElements,
204-
out string? capturedValueAttribute)
244+
out string? capturedValueAttribute
245+
)
205246
{
206247
capturedValueAttribute = null;
207248

@@ -251,11 +292,19 @@ private static int RenderAttributes(
251292

252293
switch (frame.AttributeValue)
253294
{
254-
case bool flag when flag && frame.AttributeName.StartsWith(BlazorInternalAttrPrefix, StringComparison.Ordinal):
295+
case bool flag
296+
when flag
297+
&& frame.AttributeName.StartsWith(
298+
BlazorInternalAttrPrefix,
299+
StringComparison.Ordinal
300+
):
255301
// NOTE: This was added to make it more obvious
256302
// that this is a generated/special blazor attribute
257303
// for internal usage
258-
var nameParts = frame.AttributeName.Split('_', StringSplitOptions.RemoveEmptyEntries);
304+
var nameParts = frame.AttributeName.Split(
305+
'_',
306+
StringSplitOptions.RemoveEmptyEntries
307+
);
259308
result.Append(' ');
260309
result.Append(BlazorAttrPrefix);
261310
result.Append(nameParts[2]);
@@ -271,7 +320,7 @@ private static int RenderAttributes(
271320
result.Append(frame.AttributeName);
272321
result.Append('=');
273322
result.Append('"');
274-
result.Append(Escape(value));
323+
AppendEscapeAttributeValue(context, value);
275324
result.Append('"');
276325
break;
277326
default:
@@ -282,10 +331,66 @@ private static int RenderAttributes(
282331
return position + maxElements;
283332
}
284333

285-
private static string Escape(string value) =>
286-
value
287-
.Replace("&", "&amp;", StringComparison.OrdinalIgnoreCase)
288-
.Replace("\"", "&quot;", StringComparison.OrdinalIgnoreCase);
334+
private static void AppendEscapeText(HtmlRenderingContext context, string value)
335+
{
336+
var valueSpan = value.AsSpan();
337+
var copyFromIndex = 0;
338+
for (var index = 0; index < valueSpan.Length; index++)
339+
{
340+
var c = valueSpan[index];
341+
if (c is '<' or '>' or '&')
342+
{
343+
context.Result.Append(valueSpan.Slice(copyFromIndex, index - copyFromIndex));
344+
switch (c)
345+
{
346+
case '<':
347+
context.Result.Append("&lt;");
348+
break;
349+
case '>':
350+
context.Result.Append("&gt;");
351+
break;
352+
case '&':
353+
context.Result.Append("&amp;");
354+
break;
355+
}
356+
copyFromIndex = index + 1;
357+
}
358+
}
359+
360+
if (copyFromIndex < valueSpan.Length)
361+
{
362+
context.Result.Append(valueSpan.Slice(copyFromIndex));
363+
}
364+
}
365+
366+
private static void AppendEscapeAttributeValue(HtmlRenderingContext context, string value)
367+
{
368+
var valueSpan = value.AsSpan();
369+
var copyFromIndex = 0;
370+
for (var index = 0; index < valueSpan.Length; index++)
371+
{
372+
var c = valueSpan[index];
373+
if (c is '"' or '&')
374+
{
375+
context.Result.Append(valueSpan.Slice(copyFromIndex, index - copyFromIndex));
376+
switch (c)
377+
{
378+
case '"':
379+
context.Result.Append("&quot;");
380+
break;
381+
case '&':
382+
context.Result.Append("&amp;");
383+
break;
384+
}
385+
copyFromIndex = index + 1;
386+
}
387+
}
388+
389+
if (copyFromIndex < valueSpan.Length)
390+
{
391+
context.Result.Append(valueSpan.Slice(copyFromIndex));
392+
}
393+
}
289394

290395
private sealed class HtmlRenderingContext
291396
{
@@ -296,8 +401,8 @@ public HtmlRenderingContext(RenderTreeFrameDictionary frames)
296401
this.frames = frames;
297402
}
298403

299-
public ReadOnlySpan<RenderTreeFrame> GetRenderTreeFrames(int componentId)
300-
=> new(frames[componentId].Array, 0, frames[componentId].Count);
404+
public ReadOnlySpan<RenderTreeFrame> GetRenderTreeFrames(int componentId) =>
405+
new(frames[componentId].Array, 0, frames[componentId].Count);
301406

302407
public StringBuilder Result { get; } = new();
303408

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
<p style="@Escaped">@Escaped</p>
1+
<p some-attribute="@Escaped">@Escaped</p>
22

33
@code {
4-
54
[Parameter]
65
public string Escaped { get; set; }
7-
86
}

tests/bunit.web.tests/BlazorE2E/ComponentRenderingTest.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -690,14 +690,16 @@ public async Task CanHandleRemovedParentObjectsAsync()
690690
cut.FindAll("div").Count.ShouldBe(0);
691691
}
692692

693-
[Fact]
694-
public void SomeEscapableCharactersDontGetEncoded()
693+
[Theory]
694+
[InlineData("<b>Message</b>", "<p some-attribute=\"<b>Message</b>\">&lt;b&gt;Message&lt;/b&gt;</p>")]
695+
[InlineData("url('&*')", "<p some-attribute=\"url('&amp;*')\">url('&amp;*')</p>")]
696+
[InlineData("<\"*?_'", "<p some-attribute=\"<&quot;*?_'\">&lt;\"*?_'</p>")]
697+
public void SomeEscapableCharactersDontGetEncoded(string input, string expected)
695698
{
696-
const string input = "url('\"&')";
697699
var cut = RenderComponent<ComponentWithEscapableCharacters>(
698700
p => p.Add(s => s.Escaped, input));
699701

700-
cut.Markup.ShouldBe("<p style=\"url('&quot;&amp;')\">url('\"&')</p>");
702+
cut.Markup.ShouldBe(expected);
701703
}
702704

703705
[Fact]

0 commit comments

Comments
 (0)