Skip to content

Commit 4c664a0

Browse files
author
Richard Edwards
committed
Add BrowserLinkNonceMiddleware for CSP nonce injection
Introduced BrowserLinkNonceMiddleware to inject CSP nonces into BrowserLink and ASP.NET Core hot-reload script tags during development. Added BrowserLinkNonceStartupFilter to ensure the middleware runs outermost in the pipeline. Provided AddUmbrellaBrowserLinkNonce extension for easy DI registration. Updated usings as needed.
1 parent f236331 commit 4c664a0

3 files changed

Lines changed: 130 additions & 0 deletions

File tree

AspNetCore/src/Umbrella.AspNetCore.WebUtilities/IServiceCollectionExtensions.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11

22
using CommunityToolkit.Diagnostics;
3+
using Microsoft.AspNetCore.Hosting;
34
using Microsoft.AspNetCore.Identity;
5+
using Umbrella.AspNetCore.WebUtilities.Middleware;
46
using Umbrella.AspNetCore.WebUtilities.Components;
57
using Umbrella.AspNetCore.WebUtilities.Components.Abstractions;
68
using Umbrella.AspNetCore.WebUtilities.Components.Options;
@@ -140,4 +142,24 @@ public static IServiceCollection AddUmbrellaAspNetCoreWebUtilitiesAnonymousPhone
140142

141143
return services;
142144
}
145+
146+
/// <summary>
147+
/// Adds the <see cref="Umbrella.AspNetCore.WebUtilities.Middleware.BrowserLinkNonceMiddleware"/> to the pipeline via an <see cref="IStartupFilter"/>,
148+
/// registering it as the outermost middleware so it can inject CSP nonces onto BrowserLink and
149+
/// ASP.NET Core hot-reload script tags injected by development tooling.
150+
/// This should only be called in Development environments.
151+
/// </summary>
152+
/// <param name="services">The services dependency injection container builder.</param>
153+
/// <returns>The <see cref="IServiceCollection"/> dependency injection container builder.</returns>
154+
/// <exception cref="ArgumentNullException">Thrown if <paramref name="services"/> is null.</exception>
155+
public static IServiceCollection AddUmbrellaBrowserLinkNonce(this IServiceCollection services)
156+
{
157+
Guard.IsNotNull(services, nameof(services));
158+
159+
// Insert at 0 so our startup filter is resolved first and therefore runs outermost in the pipeline.
160+
// ASP.NET Core applies startup filters in reverse DI registration order, so index 0 = outermost wrapper.
161+
services.Insert(0, ServiceDescriptor.Transient<IStartupFilter, BrowserLinkNonceStartupFilter>());
162+
163+
return services;
164+
}
143165
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
using System.Text;
2+
using System.Text.RegularExpressions;
3+
using CommunityToolkit.Diagnostics;
4+
using Microsoft.AspNetCore.Http;
5+
using Umbrella.WebUtilities.Security;
6+
7+
namespace Umbrella.AspNetCore.WebUtilities.Middleware;
8+
9+
/// <summary>
10+
/// Middleware that injects a CSP nonce attribute onto Visual Studio BrowserLink and ASP.NET Core
11+
/// hot-reload script tags that are automatically inserted into HTML responses during development.
12+
/// Must be registered as the outermost middleware via <see cref="BrowserLinkNonceStartupFilter"/>.
13+
/// </summary>
14+
public partial class BrowserLinkNonceMiddleware
15+
{
16+
#region Private Members
17+
[GeneratedRegex(@"(<script\b[^>]*\bsrc=""/_vs/browserLink""[^>]*?)(>)", RegexOptions.IgnoreCase)]
18+
private static partial Regex BrowserLinkPattern();
19+
20+
[GeneratedRegex(@"(<script\b[^>]*\bsrc=""/_framework/aspnetcore-browser-refresh\.js""[^>]*?)(>)", RegexOptions.IgnoreCase)]
21+
private static partial Regex BrowserRefreshPattern();
22+
23+
private readonly RequestDelegate _next;
24+
#endregion
25+
26+
#region Constructors
27+
/// <summary>
28+
/// Initializes a new instance of the <see cref="BrowserLinkNonceMiddleware"/> class.
29+
/// </summary>
30+
/// <param name="next">The next middleware in the pipeline.</param>
31+
public BrowserLinkNonceMiddleware(RequestDelegate next)
32+
{
33+
_next = next;
34+
}
35+
#endregion
36+
37+
#region Middleware Members
38+
/// <summary>
39+
/// Invokes the middleware for the specified <see cref="HttpContext"/>.
40+
/// </summary>
41+
/// <param name="context">The <see cref="HttpContext"/>.</param>
42+
/// <param name="nonceContext">The <see cref="NonceContext"/> for the current request.</param>
43+
/// <returns>An awaitable <see cref="Task"/>.</returns>
44+
public async Task InvokeAsync(HttpContext context, NonceContext nonceContext)
45+
{
46+
Guard.IsNotNull(context, nameof(context));
47+
Guard.IsNotNull(nonceContext, nameof(nonceContext));
48+
49+
Stream originalBody = context.Response.Body;
50+
51+
using var buffer = new MemoryStream();
52+
context.Response.Body = buffer;
53+
54+
try
55+
{
56+
await _next(context);
57+
}
58+
finally
59+
{
60+
context.Response.Body = originalBody;
61+
}
62+
63+
// Nonce is set by security middleware during _next — read it after the inner pipeline completes.
64+
string? nonce = nonceContext.Current;
65+
66+
string? contentType = context.Response.ContentType;
67+
bool isHtml = !string.IsNullOrEmpty(contentType) &&
68+
contentType.Contains("text/html", StringComparison.OrdinalIgnoreCase);
69+
70+
if (string.IsNullOrEmpty(nonce) || !isHtml)
71+
{
72+
buffer.Position = 0;
73+
await buffer.CopyToAsync(originalBody);
74+
return;
75+
}
76+
77+
buffer.Position = 0;
78+
string html = Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length);
79+
80+
string replacement = $"$1 nonce=\"{nonce}\"$2";
81+
html = BrowserLinkPattern().Replace(html, replacement);
82+
html = BrowserRefreshPattern().Replace(html, replacement);
83+
84+
byte[] modifiedBytes = Encoding.UTF8.GetBytes(html);
85+
context.Response.ContentLength = modifiedBytes.Length;
86+
await originalBody.WriteAsync(modifiedBytes);
87+
}
88+
#endregion
89+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using Microsoft.AspNetCore.Builder;
2+
using Microsoft.AspNetCore.Hosting;
3+
4+
namespace Umbrella.AspNetCore.WebUtilities.Middleware;
5+
6+
/// <summary>
7+
/// An <see cref="IStartupFilter"/> that registers <see cref="BrowserLinkNonceMiddleware"/> as the
8+
/// outermost middleware in the pipeline. This ensures it wraps the BrowserLink and browser-refresh
9+
/// startup-filter middlewares and can inject nonces after they have written their script tags.
10+
/// </summary>
11+
public sealed class BrowserLinkNonceStartupFilter : IStartupFilter
12+
{
13+
/// <inheritdoc />
14+
public Action<IApplicationBuilder> Configure(Action<IApplicationBuilder> next) => app =>
15+
{
16+
_ = app.UseMiddleware<BrowserLinkNonceMiddleware>();
17+
next(app);
18+
};
19+
}

0 commit comments

Comments
 (0)