Skip to content

Commit d044a86

Browse files
feat: Implement TagHtml sanitization and validation for tag creation and updates
1 parent 1728fdc commit d044a86

6 files changed

Lines changed: 285 additions & 0 deletions

File tree

src/XtremeIdiots.Portal.Repository.Abstractions.V1/Constants/V1/ApiErrorCodes.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public static class ApiErrorCodes
1717
public const string EntityConflict = "ENTITY_CONFLICT";
1818
public const string EntityIdMismatch = "ENTITY_ID_MISMATCH";
1919
public const string MissingEntityId = "MISSING_ENTITY_ID";
20+
public const string InvalidTagHtml = "INVALID_TAG_HTML";
2021

2122
// Deserialization Errors
2223
public const string DeserializationError = "DESERIALIZATION_ERROR";

src/XtremeIdiots.Portal.Repository.Abstractions.V1/Constants/V1/ApiErrorMessages.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ public static class ApiErrorMessages
2424

2525
// Validation Error Messages
2626
public const string TagIdRequiredMessage = "TagId is required";
27+
public const string InvalidTagHtmlMessage = "TagHtml contains unsupported markup";
2728
public const string PlayerIdMismatchMessage = "PlayerId in the URL must match PlayerId in the request body";
2829
public const string UserProfileIdMismatchMessage = "UserProfileId in the URL must match UserProfileId in the request body";
2930
public const string InvalidCutoffDateMessage = "Cutoff date was not provided or was invalid";

src/XtremeIdiots.Portal.Repository.Api.Tests.V1/Controllers/V1/TagsControllerTests.cs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using System.Net;
22
using Microsoft.Extensions.Caching.Memory;
33
using Xunit;
4+
using XtremeIdiots.Portal.Repository.Abstractions.Constants.V1;
45
using XtremeIdiots.Portal.Repository.Abstractions.Interfaces.V1;
56
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Tags;
67
using XtremeIdiots.Portal.Repository.Api.Tests.V1.TestHelpers;
@@ -72,6 +73,48 @@ public async Task CreateTag_CreatesEntity()
7273
Assert.Single(context.Tags);
7374
}
7475

76+
[Fact]
77+
public async Task CreateTag_WithUnsafeTagHtml_ReturnsBadRequest()
78+
{
79+
using var context = DbContextHelper.CreateInMemoryContext();
80+
var controller = CreateController(context);
81+
var api = (ITagsApi)controller;
82+
83+
var tagDto = new TagDto
84+
{
85+
TagId = Guid.NewGuid(),
86+
Name = "UnsafeTag",
87+
TagHtml = "<img src=x onerror=alert(1)>"
88+
};
89+
90+
var result = await api.CreateTag(tagDto);
91+
92+
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
93+
Assert.Empty(context.Tags);
94+
Assert.Equal(ApiErrorCodes.InvalidTagHtml, result.Result?.Errors?.FirstOrDefault()?.Code);
95+
}
96+
97+
[Fact]
98+
public async Task CreateTag_WithValidTagHtml_SanitizesAndCreatesEntity()
99+
{
100+
using var context = DbContextHelper.CreateInMemoryContext();
101+
var controller = CreateController(context);
102+
var api = (ITagsApi)controller;
103+
104+
var tagDto = new TagDto
105+
{
106+
TagId = Guid.NewGuid(),
107+
Name = "ValidTag",
108+
TagHtml = "<span class=\"badge bg-warning\"><i class=\"fa-solid fa-shield\"></i> Moderate Chat</span>"
109+
};
110+
111+
var result = await api.CreateTag(tagDto);
112+
113+
Assert.Equal(HttpStatusCode.Created, result.StatusCode);
114+
var created = Assert.Single(context.Tags);
115+
Assert.Equal("<span class=\"badge bg-warning\"><i class=\"fa-solid fa-shield\"></i> Moderate Chat</span>", created.TagHtml);
116+
}
117+
75118
[Fact]
76119
public async Task UpdateTag_WithValidId_ReturnsOk()
77120
{
@@ -89,6 +132,71 @@ public async Task UpdateTag_WithValidId_ReturnsOk()
89132
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
90133
}
91134

135+
[Fact]
136+
public async Task UpdateTag_WithUnsafeTagHtml_ReturnsBadRequestAndDoesNotChangeStoredTagHtml()
137+
{
138+
using var context = DbContextHelper.CreateInMemoryContext();
139+
var tagId = Guid.NewGuid();
140+
context.Tags.Add(new Tag
141+
{
142+
TagId = tagId,
143+
Name = "Original",
144+
TagHtml = "<span class=\"badge bg-primary\">Original</span>"
145+
});
146+
await context.SaveChangesAsync();
147+
148+
var controller = CreateController(context);
149+
var api = (ITagsApi)controller;
150+
151+
var tagDto = new TagDto
152+
{
153+
TagId = tagId,
154+
Name = "Updated",
155+
TagHtml = "<script>alert('xss')</script>"
156+
};
157+
158+
var result = await api.UpdateTag(tagDto);
159+
160+
Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode);
161+
Assert.Equal(ApiErrorCodes.InvalidTagHtml, result.Result?.Errors?.FirstOrDefault()?.Code);
162+
163+
var unchanged = await context.Tags.FindAsync(tagId);
164+
Assert.NotNull(unchanged);
165+
Assert.Equal("<span class=\"badge bg-primary\">Original</span>", unchanged.TagHtml);
166+
}
167+
168+
[Fact]
169+
public async Task UpdateTag_WithEmptyTagHtml_ClearsStoredTagHtml()
170+
{
171+
using var context = DbContextHelper.CreateInMemoryContext();
172+
var tagId = Guid.NewGuid();
173+
context.Tags.Add(new Tag
174+
{
175+
TagId = tagId,
176+
Name = "Original",
177+
TagHtml = "<span class=\"badge bg-primary\">Original</span>"
178+
});
179+
await context.SaveChangesAsync();
180+
181+
var controller = CreateController(context);
182+
var api = (ITagsApi)controller;
183+
184+
var tagDto = new TagDto
185+
{
186+
TagId = tagId,
187+
Name = "Updated",
188+
TagHtml = " "
189+
};
190+
191+
var result = await api.UpdateTag(tagDto);
192+
193+
Assert.Equal(HttpStatusCode.OK, result.StatusCode);
194+
195+
var updated = await context.Tags.FindAsync(tagId);
196+
Assert.NotNull(updated);
197+
Assert.Null(updated.TagHtml);
198+
}
199+
92200
[Fact]
93201
public async Task UpdateTag_WithInvalidId_ReturnsNotFound()
94202
{
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
using XtremeIdiots.Portal.Repository.Api.V1.Validation;
2+
using Xunit;
3+
4+
namespace XtremeIdiots.Portal.Repository.Api.Tests.V1.Validation;
5+
6+
public class TagHtmlSanitizerTests
7+
{
8+
[Fact]
9+
public void TrySanitize_WithValidSpanAndIcon_ReturnsSanitizedHtml()
10+
{
11+
const string input = "<span class=\"badge bg-warning\"><i class=\"fa-solid fa-shield\"></i> Moderate Chat</span>";
12+
13+
var isValid = TagHtmlSanitizer.TrySanitize(input, out var sanitized, out var error);
14+
15+
Assert.True(isValid);
16+
Assert.Null(error);
17+
Assert.Equal("<span class=\"badge bg-warning\"><i class=\"fa-solid fa-shield\"></i> Moderate Chat</span>", sanitized);
18+
}
19+
20+
[Fact]
21+
public void TrySanitize_WithScriptMarkup_ReturnsFalse()
22+
{
23+
const string input = "<script>alert('xss')</script>";
24+
25+
var isValid = TagHtmlSanitizer.TrySanitize(input, out var sanitized, out var error);
26+
27+
Assert.False(isValid);
28+
Assert.NotNull(error);
29+
Assert.Equal(input, sanitized);
30+
}
31+
32+
[Fact]
33+
public void TrySanitize_WithInvalidClassToken_ReturnsFalse()
34+
{
35+
const string input = "<span class=\"badge bg-warning()\">Test</span>";
36+
37+
var isValid = TagHtmlSanitizer.TrySanitize(input, out _, out var error);
38+
39+
Assert.False(isValid);
40+
Assert.NotNull(error);
41+
}
42+
43+
[Fact]
44+
public void TrySanitize_WithPlainLabelSpecialCharacters_EncodesLabel()
45+
{
46+
const string input = "<span class=\"badge bg-warning\">A & B</span>";
47+
48+
var isValid = TagHtmlSanitizer.TrySanitize(input, out var sanitized, out var error);
49+
50+
Assert.True(isValid);
51+
Assert.Null(error);
52+
Assert.Equal("<span class=\"badge bg-warning\">A &amp; B</span>", sanitized);
53+
}
54+
}

src/XtremeIdiots.Portal.Repository.Api.V1/Controllers/V1/TagsController.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using XtremeIdiots.Portal.Repository.Abstractions.Interfaces.V1;
1414
using XtremeIdiots.Portal.Repository.Abstractions.Models.V1.Tags;
1515
using XtremeIdiots.Portal.Repository.Api.V1.Mapping;
16+
using XtremeIdiots.Portal.Repository.Api.V1.Validation;
1617
using MX.Api.Abstractions;
1718
using MX.Api.Web.Extensions;
1819
using XtremeIdiots.Portal.Repository.Abstractions.Constants.V1;
@@ -181,6 +182,17 @@ public async Task<IActionResult> CreateTag([FromBody] TagDto tagDto, Cancellatio
181182
/// <returns>An API result indicating the tag was created.</returns>
182183
async Task<ApiResult> ITagsApi.CreateTag(TagDto tagDto, CancellationToken cancellationToken)
183184
{
185+
if (!TagHtmlSanitizer.TrySanitize(tagDto.TagHtml, out var sanitizedTagHtml, out var tagHtmlError))
186+
{
187+
var error = string.IsNullOrWhiteSpace(tagHtmlError)
188+
? ApiErrorMessages.InvalidTagHtmlMessage
189+
: $"{ApiErrorMessages.InvalidTagHtmlMessage}. {tagHtmlError}";
190+
191+
return new ApiResponse(new ApiError(ApiErrorCodes.InvalidTagHtml, error)).ToBadRequestResult();
192+
}
193+
194+
tagDto.TagHtml = sanitizedTagHtml;
195+
184196
var tag = tagDto.ToEntity();
185197
context.Tags.Add(tag);
186198
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
@@ -225,7 +237,25 @@ async Task<ApiResult> ITagsApi.UpdateTag(TagDto tagDto, CancellationToken cancel
225237
return new ApiResult(HttpStatusCode.NotFound);
226238
}
227239

240+
var hadTagHtmlInRequest = tagDto.TagHtml is not null;
241+
242+
if (!TagHtmlSanitizer.TrySanitize(tagDto.TagHtml, out var sanitizedTagHtml, out var tagHtmlError))
243+
{
244+
var error = string.IsNullOrWhiteSpace(tagHtmlError)
245+
? ApiErrorMessages.InvalidTagHtmlMessage
246+
: $"{ApiErrorMessages.InvalidTagHtmlMessage}. {tagHtmlError}";
247+
248+
return new ApiResponse(new ApiError(ApiErrorCodes.InvalidTagHtml, error)).ToBadRequestResult();
249+
}
250+
251+
tagDto.TagHtml = sanitizedTagHtml;
252+
228253
tagDto.ApplyTo(tag);
254+
if (hadTagHtmlInRequest && sanitizedTagHtml is null)
255+
{
256+
tag.TagHtml = null;
257+
}
258+
229259
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
230260
return new ApiResponse().ToApiResult();
231261
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using System.Net;
2+
using System.Text.RegularExpressions;
3+
4+
namespace XtremeIdiots.Portal.Repository.Api.V1.Validation;
5+
6+
internal static partial class TagHtmlSanitizer
7+
{
8+
private static readonly Regex SpanWithOptionalIconPattern = SpanWithOptionalIconRegex();
9+
private static readonly Regex ClassTokenPattern = ClassTokenRegex();
10+
11+
public static bool TrySanitize(string? input, out string? sanitized, out string? error)
12+
{
13+
sanitized = input;
14+
error = null;
15+
16+
if (input is null)
17+
{
18+
return true;
19+
}
20+
21+
var trimmed = input.Trim();
22+
if (trimmed.Length == 0)
23+
{
24+
sanitized = null;
25+
return true;
26+
}
27+
28+
var match = SpanWithOptionalIconPattern.Match(trimmed);
29+
if (!match.Success)
30+
{
31+
error = "TagHtml must be a <span class=\"...\"> label, with an optional <i class=\"...\"></i> icon.";
32+
return false;
33+
}
34+
35+
var spanClasses = NormalizeClasses(match.Groups[1].Value);
36+
if (spanClasses is null)
37+
{
38+
error = "TagHtml contains invalid CSS class names on the span element.";
39+
return false;
40+
}
41+
42+
var iconClassesRaw = match.Groups[2].Success ? match.Groups[3].Value : null;
43+
var iconClasses = NormalizeClasses(iconClassesRaw);
44+
if (iconClassesRaw is not null && iconClasses is null)
45+
{
46+
error = "TagHtml contains invalid CSS class names on the icon element.";
47+
return false;
48+
}
49+
50+
var label = WebUtility.HtmlEncode(match.Groups[4].Value.Trim());
51+
if (string.IsNullOrWhiteSpace(label))
52+
{
53+
error = "TagHtml must include non-empty label text.";
54+
return false;
55+
}
56+
57+
sanitized = iconClasses is null
58+
? $"<span class=\"{spanClasses}\">{label}</span>"
59+
: $"<span class=\"{spanClasses}\"><i class=\"{iconClasses}\"></i> {label}</span>";
60+
61+
return true;
62+
}
63+
64+
private static string? NormalizeClasses(string? raw)
65+
{
66+
if (string.IsNullOrWhiteSpace(raw))
67+
{
68+
return null;
69+
}
70+
71+
var tokens = raw
72+
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
73+
.Distinct(StringComparer.Ordinal)
74+
.ToList();
75+
76+
if (tokens.Count == 0 || tokens.Any(t => !ClassTokenPattern.IsMatch(t)))
77+
{
78+
return null;
79+
}
80+
81+
return string.Join(' ', tokens);
82+
}
83+
84+
[GeneratedRegex(
85+
"^<span\\s+class=\"([^\"]+)\"\\s*>\\s*(<i\\s+class=\"([^\"]+)\"\\s*><\\/i>\\s*)?([^<>]+?)\\s*<\\/span>$",
86+
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
87+
private static partial Regex SpanWithOptionalIconRegex();
88+
89+
[GeneratedRegex("^[a-zA-Z0-9_-]+$", RegexOptions.CultureInvariant)]
90+
private static partial Regex ClassTokenRegex();
91+
}

0 commit comments

Comments
 (0)