diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/Csp/CspNonceService.cs b/src/Joonasw.AspNetCore.SecurityHeaders/Csp/CspNonceService.cs index a2aea6e..466b330 100644 --- a/src/Joonasw.AspNetCore.SecurityHeaders/Csp/CspNonceService.cs +++ b/src/Joonasw.AspNetCore.SecurityHeaders/Csp/CspNonceService.cs @@ -6,6 +6,12 @@ namespace Joonasw.AspNetCore.SecurityHeaders.Csp public class CspNonceService : ICspNonceService { private readonly string _nonce; + private const char Base64PadCharacter = '='; + private const string DoubleBase64PadCharacter = "=="; + private const char Base64Character62 = '+'; + private const char Base64Character63 = '/'; + private const char Base64UrlCharacter62 = '-'; + private const char Base64UrlCharacter63 = '_'; public CspNonceService(int nonceByteAmount = 32) { @@ -15,7 +21,15 @@ public CspNonceService(int nonceByteAmount = 32) rng.GetBytes(nonceBytes); } - _nonce = Convert.ToBase64String(nonceBytes); + var s = Convert.ToBase64String(nonceBytes); + // a base64String can have elements that aren't URL friendly. In testing chrome appeared to ignore nonces that weren't URL friendly (or the tag helper did). + // the replacement code comes from Microsoft.IdentityModel.Tokens.Base64UrlEncoder + s = s.Split(Base64PadCharacter)[0]; // Remove any trailing padding + s = s.Replace(Base64Character62, Base64UrlCharacter62); // 62nd char of encoding + s = s.Replace(Base64Character63, Base64UrlCharacter63); // 63rd char of encoding + + _nonce = s; + } public string GetNonce() diff --git a/src/Joonasw.AspNetCore.SecurityHeaders/TagHelpers/NonceTagHelper.cs b/src/Joonasw.AspNetCore.SecurityHeaders/TagHelpers/NonceTagHelper.cs index 7133660..dea8908 100644 --- a/src/Joonasw.AspNetCore.SecurityHeaders/TagHelpers/NonceTagHelper.cs +++ b/src/Joonasw.AspNetCore.SecurityHeaders/TagHelpers/NonceTagHelper.cs @@ -26,7 +26,11 @@ public override void Process(TagHelperContext context, TagHelperOutput output) { // The nonce service is created per request, so we // get the same nonce here as the CSP header - output.Attributes.Add("nonce", _nonceService.GetNonce()); + // when doing this, the nonce wasn't reliably being injected (that might have been due to the nonce have non url friendly chars). + //output.Attributes.Add("nonce", _nonceService.GetNonce()); + // this injected the nonce all the time. + var attribute = new TagHelperAttribute("nonce", _nonceService.GetNonce(), HtmlAttributeValueStyle.DoubleQuotes); + output.Attributes.SetAttribute(attribute); } } } diff --git a/test/Joonasw.AspNetCore.SecurityHeaders.Tests/CspScriptsBuilderTests.cs b/test/Joonasw.AspNetCore.SecurityHeaders.Tests/CspScriptsBuilderTests.cs index b2c846d..e35f312 100644 --- a/test/Joonasw.AspNetCore.SecurityHeaders.Tests/CspScriptsBuilderTests.cs +++ b/test/Joonasw.AspNetCore.SecurityHeaders.Tests/CspScriptsBuilderTests.cs @@ -6,7 +6,9 @@ namespace Joonasw.AspNetCore.SecurityHeaders.Tests { - public class CspScriptsBuilderTests + using Csp; + + public class CspScriptsBuilderTests { [Fact] public void FromNowhere_SetsAllowNoneToTrue() @@ -30,6 +32,24 @@ public void FromSelf_SetsAllowSelfToTrue() Assert.True(options.AllowSelf); } + [Fact] + public void FromSelf_WithNonce_HasValue() + { + var nonceService = new CspNonceService(32); + var nonce = nonceService.GetNonce(); + + var builder = new CspBuilder(); + builder.AllowScripts.FromSelf().AddNonce(); + + var headerValue = builder.BuildCspOptions().ToString(nonceService).headerValue; + + Assert.DoesNotContain("+", nonce); + Assert.DoesNotContain("/", nonce); + Assert.DoesNotContain("=", nonce); + + Assert.Equal($"script-src 'self' 'nonce-{nonce}'", headerValue); + } + [Fact] public void From_AddsUrlToAllowedSources() {