Skip to content

Commit

Permalink
Added helpers to simplify delivery of HTML documents through ASP.NET …
Browse files Browse the repository at this point in the history
…Core. Added automation for Content Security Policy "nonce".
  • Loading branch information
RyanLamansky committed Jul 28, 2024
1 parent e06effc commit 6fca665
Show file tree
Hide file tree
Showing 5 changed files with 45 additions and 30 deletions.
28 changes: 12 additions & 16 deletions Examples/AspNetPageEmitter/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,17 @@

var app = builder.Build();

app.MapFallback((HttpContext context) =>
context.WriteDocumentAsync(new TestDocument()));
app.Use((context, next) =>
{
context.Response.GetTypedHeaders().CacheControl = new Microsoft.Net.Http.Headers.CacheControlHeaderValue()
{
Private = true,
NoCache = true,
};

return next();
});
app.MapFallback(new TestDocument().WriteToAsync);

await app.RunAsync().ConfigureAwait(false);

Expand All @@ -21,20 +30,7 @@ class TestDocument : IHtmlDocument
Task IHtmlDocument.WriteBodyContentsAsync(HtmlWriter writer, CancellationToken cancellationToken)
{
writer.WriteElement("p", null, children => children.WriteText("Test bytes."));
writer.WriteScript(ValidatedScript.ForInlineSource("console.log('test')"));
return Task.CompletedTask;
}
}

static class Extensions
{
public static Task WriteDocumentAsync(this HttpContext context, IHtmlDocument document)
{
var request = context.Request;
var response = context.Response;
response.ContentType = "text/html; charset=utf-8";
var baseUri = $"{request.Scheme}://{request.Host}/";
response.Headers.ContentSecurityPolicy = $"default-src {baseUri}; base-uri {baseUri}";

return document.WriteAsync(response.BodyWriter, context.RequestAborted);
}
}
31 changes: 19 additions & 12 deletions HtmlUtilities/HtmlDocumentExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Buffers;
using Microsoft.AspNetCore.Http;
using System.Buffers;

namespace HtmlUtilities;

Expand All @@ -8,19 +9,25 @@ namespace HtmlUtilities;
public static class HtmlDocumentExtensions
{
/// <summary>
/// Writes the document to <paramref name="writer"/>.
/// Writes the document to <paramref name="context"/>.
/// </summary>
/// <param name="document">The document to write.</param>
/// <param name="writer">Receives the written document.</param>
/// <param name="cancellationToken">Cancels emission of document data.</param>
/// <returns></returns>
public static async Task WriteAsync(
this IHtmlDocument document,
IBufferWriter<byte> writer,
CancellationToken cancellationToken = default)
/// <param name="context">Receives the written document.</param>
/// <returns>A task that shows completion when the document is written.</returns>
public static Task WriteToAsync(this IHtmlDocument document, HttpContext context)
{
ArgumentNullException.ThrowIfNull(document, nameof(document));
cancellationToken.ThrowIfCancellationRequested();
ArgumentNullException.ThrowIfNull(context, nameof(context));

var request = context.Request;
var response = context.Response;
response.ContentType = "text/html; charset=utf-8";
Span<char> cspNonceUtf16 = stackalloc char[32];
System.Security.Cryptography.RandomNumberGenerator.GetHexString(cspNonceUtf16, true);
response.Headers.ContentSecurityPolicy = $"base-uri {request.Scheme}://{request.Host}/;default-src 'none';script-src 'unsafe-inline' 'nonce-{cspNonceUtf16}'";
// unsafe-inline only applies to browsers that don't support nonce. Can be removed someday.

var writer = context.Response.BodyWriter;

writer.Write("<!DOCTYPE html><html"u8);

Expand Down Expand Up @@ -50,8 +57,8 @@ public static async Task WriteAsync(

writer.Write("</head><body>"u8);

await document.WriteBodyContentsAsync(new HtmlWriter(writer), cancellationToken).ConfigureAwait(false);
return document.WriteBodyContentsAsync(new HtmlWriter(writer, new Validated.ValidatedAttribute("nonce", cspNonceUtf16)), context.RequestAborted);

writer.Write("</body></html>"u8);
// HTML5 spec doesn't require </body></html>, so that simplifies things a bit here.
}
}
3 changes: 3 additions & 0 deletions HtmlUtilities/HtmlUtilities.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<AnalysisMode>Recommended</AnalysisMode>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
</Project>
10 changes: 9 additions & 1 deletion HtmlUtilities/HtmlWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,24 @@ namespace HtmlUtilities;
/// A high-performance writer for HTML content.
/// </summary>
/// <remarks>UTF-8 is always used.</remarks>
public readonly struct HtmlWriter
public sealed class HtmlWriter
{
private static readonly ValidatedElement html = new(
"<!DOCTYPE html><html>"u8.ToArray(),
"</html>"u8.ToArray());

private readonly IBufferWriter<byte> writer;
private readonly ValidatedAttribute cspNonce;

internal HtmlWriter(IBufferWriter<byte> writer)
: this(writer, new ValidatedAttribute())
{
}

internal HtmlWriter(IBufferWriter<byte> writer, ValidatedAttribute cspNonce)
{
ArgumentNullException.ThrowIfNull(this.writer = writer, nameof(writer));
ArgumentNullException.ThrowIfNull(this.cspNonce = cspNonce, nameof(cspNonce));
}

/// <summary>
Expand Down Expand Up @@ -317,6 +324,7 @@ public void WriteScript(ValidatedScript script)
throw new ArgumentException("script was never initialized.", nameof(script));

writer.Write("<script"u8);
writer.Write(this.cspNonce.value);
writer.Write(script.value);
writer.Write("</script>"u8);
}
Expand Down
3 changes: 2 additions & 1 deletion HtmlUtilities/IHtmlDocument.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using HtmlUtilities.Validated;
using System.Buffers;

namespace HtmlUtilities;

Expand Down Expand Up @@ -35,6 +34,8 @@ public interface IHtmlDocument
/// <remarks>By default, directs the viewer to https://github.com/RyanLamansky/html-utilities to learn how to use this function.</remarks>
Task WriteBodyContentsAsync(HtmlWriter writer, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(writer, nameof(writer));

writer.WriteElement("p", null, children =>
{
writer.WriteText("Visit ");
Expand Down

0 comments on commit 6fca665

Please sign in to comment.