Skip to content

Commit 28f88e5

Browse files
committed
[edit] release v5.5.0 with security fixes, streaming static files, and binding improvements
- HTML-encode exception text in Http500ErrorPageBuilder to close XSS vector - Normalize multiple leading slashes in request path to prevent path traversal - Filter empty/whitespace roles in AuthorizeAttribute to prevent auth bypass - Use StartsWith for Content-Type check in JsonModelBinder - Add CopyToAsync to StaticFile; NewFileHandler now streams without buffering - InMemoryFilesCacheHandler: use TryAdd to avoid concurrent-write race - Preserve original exception when OnException handler itself throws - Add CHANGELOG entries and bump version to 5.5.0
1 parent 872dbe7 commit 28f88e5

20 files changed

Lines changed: 163 additions & 41 deletions

File tree

src/Simplify.Web.Tests/Responses/FileTests.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ public async Task Process_Stream_StreamSentAndDisposed()
102102
Assert.That(_headerDictionary["Content-Disposition"], Is.EqualTo("inline"));
103103

104104
_responseWriter.Verify(x => x.WriteAsync(It.IsAny<HttpResponse>(), It.Is<Stream>(s => s == stream.Object)));
105-
stream.Verify(x => x.Close());
105+
#if NETFRAMEWORK
106+
stream.Verify(x => x.Dispose());
107+
#else
108+
stream.Verify(x => x.DisposeAsync());
109+
#endif
106110
}
107111
}

src/Simplify.Web.Tests/StaticFiles/Handlers/NewFileHandlerTests.cs

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
using System;
2+
using System.IO;
23
using System.Threading.Tasks;
34
using Microsoft.AspNetCore.Http;
45
using Moq;
56
using NUnit.Framework;
67
using Simplify.System;
7-
using Simplify.Web.Http.ResponseWriting;
88
using Simplify.Web.StaticFiles.Context;
99
using Simplify.Web.StaticFiles.Handlers;
1010
using Simplify.Web.StaticFiles.IO;
@@ -17,15 +17,13 @@ public class NewFileHandlerTests
1717
{
1818
private NewFileHandler _handler = null!;
1919

20-
private Mock<IResponseWriter> _responseWriter = null!;
2120
private Mock<IStaticFile> _staticFile = null!;
2221

2322
[SetUp]
2423
public void Initialize()
2524
{
26-
_responseWriter = new Mock<IResponseWriter>();
2725
_staticFile = new Mock<IStaticFile>();
28-
_handler = new NewFileHandler(_responseWriter.Object, _staticFile.Object);
26+
_handler = new NewFileHandler(_staticFile.Object);
2927
}
3028

3129
[Test]
@@ -45,7 +43,6 @@ public void CanHandle_CanBeCached_False()
4543
public void CanHandle_CantBeCached_True()
4644
{
4745
// Arrange
48-
4946
var context = Mock.Of<IStaticFileProcessingContext>(x => !x.CanBeCached);
5047

5148
// Act
@@ -56,23 +53,27 @@ public void CanHandle_CantBeCached_True()
5653
}
5754

5855
[Test]
59-
public async Task ExecuteAsync_NewFile_FileSendToClientAndRespectiveResponsePropertiesAreSet()
56+
public async Task ExecuteAsync_NewFile_FileStreamedToResponseBody()
6057
{
6158
// Arrange
6259

6360
var filePath = "Foo.txt";
6461
var lastModificationTime = new DateTime(2013, 4, 5, 0, 0, 0, DateTimeKind.Utc);
65-
var data = new byte[1] { 255 };
6662

6763
var context = Mock.Of<IStaticFileProcessingContext>(x =>
6864
x.RelativeFilePath == filePath &&
6965
x.LastModificationTime == lastModificationTime);
7066

7167
TimeProvider.Current = Mock.Of<ITimeProvider>(x => x.Now == new DateTime(2013, 1, 1, 0, 0, 0, DateTimeKind.Utc));
7268

73-
_staticFile.Setup(x => x.GetDataAsync(It.Is<string>(s => s == filePath))).Returns(Task.FromResult(data));
69+
var bodyStream = new MemoryStream();
70+
var response = Mock.Of<HttpResponse>(x =>
71+
x.Headers == new HeaderDictionary() &&
72+
x.Body == bodyStream);
7473

75-
var response = Mock.Of<HttpResponse>(x => x.Headers == new HeaderDictionary());
74+
_staticFile.Setup(x => x.CopyToAsync(It.Is<Stream>(s => s == bodyStream), It.Is<string>(s => s == filePath)))
75+
.Returns(Task.CompletedTask)
76+
.Verifiable();
7677

7778
// Act
7879
await _handler.ExecuteAsync(context, response);
@@ -83,6 +84,6 @@ public async Task ExecuteAsync_NewFile_FileSendToClientAndRespectiveResponseProp
8384
Assert.That(response.Headers["Last-Modified"], Is.EqualTo(lastModificationTime.ToString("r")));
8485
Assert.That(response.Headers["Expires"], Is.EqualTo(new DateTimeOffset(new DateTime(2014, 1, 1, 0, 0, 0, DateTimeKind.Utc)).ToString("R")));
8586

86-
_responseWriter.Verify(x => x.WriteAsync(It.Is<HttpResponse>(r => r == response), It.Is<byte[]>(b => b == data)));
87+
_staticFile.Verify();
8788
}
8889
}

src/Simplify.Web/Attributes/AuthorizeAttribute.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System;
22
using System.Collections.Generic;
3+
using System.Linq;
34
using Simplify.Web.System;
45

56
namespace Simplify.Web.Attributes;
@@ -16,15 +17,16 @@ public class AuthorizeAttribute : Attribute
1617
/// </summary>
1718
/// <param name="requiredUserRoles">Required user roles.</param>
1819
public AuthorizeAttribute(string? requiredUserRoles = null) =>
19-
RequiredUserRoles = requiredUserRoles != null
20-
? requiredUserRoles.ParseCommaSeparatedList()
20+
RequiredUserRoles = !string.IsNullOrEmpty(requiredUserRoles)
21+
? requiredUserRoles!.ParseCommaSeparatedList()
2122
: [];
2223

2324
/// <summary>
2425
/// Initializes a new instance of the <see cref="AuthorizeAttribute" /> class.
2526
/// </summary>
2627
/// <param name="requiredUserRoles">The required user roles.</param>
27-
public AuthorizeAttribute(params string[] requiredUserRoles) => RequiredUserRoles = requiredUserRoles;
28+
public AuthorizeAttribute(params string[] requiredUserRoles) =>
29+
RequiredUserRoles = requiredUserRoles.Where(r => !string.IsNullOrEmpty(r));
2830

2931
/// <summary>
3032
/// Gets the required user roles.

src/Simplify.Web/Bootstrapper/Setup/BaseBootstrapperStaticFiles.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ public virtual void RegisterStaticFileRequestHandlingPipelineHandlers()
7474
if (r.Resolve<ISimplifyWebSettings>().StaticFilesMemoryCache)
7575
handlers.Add(new InMemoryFilesCacheHandler(r.Resolve<IResponseWriter>(), r.Resolve<IStaticFile>()));
7676
else
77-
handlers.Add(new NewFileHandler(r.Resolve<IResponseWriter>(), r.Resolve<IStaticFile>()));
77+
handlers.Add(new NewFileHandler(r.Resolve<IStaticFile>()));
7878

7979
return (IReadOnlyList<IStaticFileRequestHandler>)handlers;
8080
}, LifetimeType.Singleton);

src/Simplify.Web/CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,42 @@
11
# Changelog
22

3+
## [5.5.0] - 2026-07-06
4+
5+
### Added
6+
7+
- MaxRequestBodySize setting (default: 100 MB) to cap request body reads during
8+
model binding, preventing memory-exhaustion DoS. Configurable via
9+
`SimplifyWebSettings:MaxRequestBodySize` in appsettings.json.
10+
11+
### Security
12+
13+
- Http500ErrorPageBuilder: HTML-encode exception text before rendering it in the
14+
error page, closing an XSS vector when HideExceptionDetails is false.
15+
16+
### Fixed
17+
18+
- StringToSpecifiedObjectParser: wrap Enum.Parse in a try/catch with a clear
19+
ModelBindingException message and pass ignoreCase: true so route parameter casing
20+
does not cause spurious binding failures.
21+
- File response: use `await using` for the data stream so it is disposed correctly
22+
under all target frameworks.
23+
- SimplifyWebRequestMiddleware: preserve the original exception when the OnException
24+
handler itself throws, instead of replacing it with the handler's exception.
25+
- RequestPathExtensions: normalize multiple leading slashes in the request path
26+
to prevent path-traversal probes via "//" prefixed URLs.
27+
- AuthorizeAttribute: treat empty or whitespace-only role strings as absent so that
28+
`[Authorize("")]` does not bypass authorization entirely.
29+
- JsonModelBinder: use StartsWith (ordinal, ignore-case) instead of Contains for the
30+
Content-Type check to avoid matching unrelated media types.
31+
32+
### Changed
33+
34+
- StaticFile: add CopyToAsync(Stream, string) that streams file content directly to
35+
the target stream without loading it into memory first. NewFileHandler now uses
36+
this streaming path, reducing memory pressure for large static files.
37+
- InMemoryFilesCacheHandler: use TryAdd instead of the indexer to avoid a race
38+
condition when multiple threads cache the same file concurrently.
39+
340
## [5.4.0] - 2026-07-04
441

542
### Added

src/Simplify.Web/Diagnostics/Templates/Http500ErrorPageBuilder.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Reflection;
1+
using System.Net;
2+
using System.Reflection;
23
using Simplify.System;
34
using Simplify.Templates;
45

@@ -27,7 +28,7 @@ private static ITemplate SetExceptionInfo(this ITemplate tpl, string? exceptionT
2728
? ""
2829
: TemplateBuilder.FromCurrentAssembly("Diagnostics.Templates.Http500ErrorPageExceptionInfo.html")
2930
.Build()
30-
.Set("ExceptionText", exceptionText)
31+
.Set("ExceptionText", WebUtility.HtmlEncode(exceptionText))
3132
.Get());
3233

3334
private static ITemplate SetStyle(this ITemplate tpl, bool darkStyle) =>

src/Simplify.Web/Http/RequestPath/RequestPathExtensions.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,22 @@ public static string GetRelativeFilePath(this HttpRequest request)
1818
if (string.IsNullOrEmpty(request.Path.Value))
1919
return "";
2020

21+
var path = request.Path.Value;
22+
23+
// Normalize multiple leading slashes (e.g. "//etc/passwd" -> "/etc/passwd")
24+
// to prevent path traversal attempts through URL encoding tricks.
25+
var startIndex = 0;
26+
27+
while (startIndex < path.Length && path[startIndex] == '/')
28+
startIndex++;
29+
30+
if (startIndex >= path.Length)
31+
return "";
32+
2133
#if NETSTANDARD2_0
22-
return request.Path.Value.Substring(1);
34+
return path.Substring(startIndex);
2335
#else
24-
return request.Path.Value[1..];
36+
return path[startIndex..];
2537
#endif
2638
}
2739

src/Simplify.Web/Middleware/SimplifyWebRequestMiddleware.cs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ public static async Task InvokeAsync(HttpContext context)
4747
{
4848
using var scope = BootstrapperFactory.ContainerProvider.BeginLifetimeScope();
4949

50+
Exception? caughtException = null;
51+
5052
try
5153
{
5254
await scope.StartMeasurements()
@@ -56,13 +58,15 @@ await scope.StartMeasurements()
5658
}
5759
catch (Exception e)
5860
{
61+
caughtException = e;
62+
5963
try
6064
{
6165
ProcessOnException(e);
6266
}
63-
catch (Exception exception)
67+
catch
6468
{
65-
e = exception;
69+
// OnException handler failure is non-fatal; keep the original exception.
6670
}
6771

6872
// Once the response has started the headers are already sent — we can neither set the
@@ -74,7 +78,7 @@ await scope.StartMeasurements()
7478

7579
context.Response.StatusCode = 500;
7680

77-
await context.WriteErrorResponseAsync(scope, e);
81+
await context.WriteErrorResponseAsync(scope, caughtException);
7882
}
7983
}
8084

src/Simplify.Web/Model/Binding/Binders/JsonModelBinder.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ public class JsonModelBinder : IModelBinder
3434
/// <exception cref="InvalidOperationException">Deserialized model is null.</exception>
3535
public async Task BindAsync<T>(ModelBinderEventArgs<T> args)
3636
{
37-
if (args.Context.Request.ContentType == null || !args.Context.Request.ContentType.Contains("application/json"))
37+
if (args.Context.Request.ContentType == null || !args.Context.Request.ContentType.StartsWith("application/json", StringComparison.OrdinalIgnoreCase))
3838
return;
3939

4040
await args.Context.ReadRequestBodyAsync();

src/Simplify.Web/Model/Binding/Parsers/StringToSpecifiedObjectParser.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,17 @@ public static DateTime ParseDateTime(string value, string? format = null)
266266
/// </summary>
267267
/// <param name="value">The value.</param>
268268
/// <param name="enumType">Type of the enum.</param>
269-
public static object ParseEnum(string value, Type enumType) => Enum.Parse(enumType, value);
269+
public static object ParseEnum(string value, Type enumType)
270+
{
271+
try
272+
{
273+
return Enum.Parse(enumType, value, ignoreCase: true);
274+
}
275+
catch (ArgumentException ex)
276+
{
277+
throw new ModelBindingException($"String to enum parsing failed, value: '{value}', type: '{enumType.Name}'", ex);
278+
}
279+
}
270280

271281
/// <summary>
272282
/// Determines whether specified type is valid for parsing.

0 commit comments

Comments
 (0)