diff --git a/src/React.AspNet/HtmlHelperExtensions.cs b/src/React.AspNet/HtmlHelperExtensions.cs index b83f1ad6c..a3d796eb2 100644 --- a/src/React.AspNet/HtmlHelperExtensions.cs +++ b/src/React.AspNet/HtmlHelperExtensions.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2014-Present, Facebook, Inc. * All rights reserved. * @@ -8,14 +8,14 @@ */ using System; -using React.Exceptions; -using React.TinyIoC; +using System.IO; #if LEGACYASPNET using System.Web; using System.Web.Mvc; using IHtmlHelper = System.Web.Mvc.HtmlHelper; #else +using System.Text.Encodings.Web; using Microsoft.AspNetCore.Mvc.Rendering; using IHtmlString = Microsoft.AspNetCore.Html.IHtmlContent; using Microsoft.AspNetCore.Html; @@ -129,16 +129,7 @@ public static IHtmlString ReactWithInit( } var html = reactComponent.RenderHtml(clientOnly, exceptionHandler: exceptionHandler); -#if LEGACYASPNET - var script = new TagBuilder("script") - { - InnerHtml = reactComponent.RenderJavaScript() - }; -#else - var script = new TagBuilder("script"); - script.InnerHtml.AppendHtml(reactComponent.RenderJavaScript()); -#endif - return new HtmlString(html + System.Environment.NewLine + script.ToString()); + return new HtmlString(html + System.Environment.NewLine + RenderToString(GetScriptTag(reactComponent.RenderJavaScript()))); } finally { @@ -155,23 +146,53 @@ public static IHtmlString ReactInitJavaScript(this IHtmlHelper htmlHelper, bool { try { - var script = Environment.GetInitJavaScript(clientOnly); + return GetScriptTag(Environment.GetInitJavaScript(clientOnly)); + } + finally + { + Environment.ReturnEngineToPool(); + } + } + + private static IHtmlString GetScriptTag(string script) + { #if LEGACYASPNET - var tag = new TagBuilder("script") - { - InnerHtml = script - }; - return new HtmlString(tag.ToString()); + var tag = new TagBuilder("script") + { + InnerHtml = script, + }; + + if (Environment.Configuration.ScriptNonceProvider != null) + { + tag.Attributes.Add("nonce", Environment.Configuration.ScriptNonceProvider()); + } + + return new HtmlString(tag.ToString()); #else var tag = new TagBuilder("script"); tag.InnerHtml.AppendHtml(script); + + if (Environment.Configuration.ScriptNonceProvider != null) + { + tag.Attributes.Add("nonce", Environment.Configuration.ScriptNonceProvider()); + } + return tag; #endif - } - finally + } + + // In ASP.NET Core, you can no longer call `.ToString` on `IHtmlString` + private static string RenderToString(IHtmlString source) + { +#if LEGACYASPNET + return source.ToString(); +#else + using (var writer = new StringWriter()) { - Environment.ReturnEngineToPool(); + source.WriteTo(writer, HtmlEncoder.Default); + return writer.ToString(); } +#endif } } } diff --git a/src/React.Core/IReactEnvironment.cs b/src/React.Core/IReactEnvironment.cs index 599ffd1f8..67991360f 100644 --- a/src/React.Core/IReactEnvironment.cs +++ b/src/React.Core/IReactEnvironment.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2014-Present, Facebook, Inc. * All rights reserved. * @@ -7,7 +7,6 @@ * of patent rights can be found in the PATENTS file in the same directory. */ -using System; namespace React { @@ -110,5 +109,10 @@ public interface IReactEnvironment /// Returns the currently held JS engine to the pool. (no-op if engine pooling is disabled) /// void ReturnEngineToPool(); + + /// + /// Gets the site-wide configuration. + /// + IReactSiteConfiguration Configuration { get; } } } diff --git a/src/React.Core/IReactSiteConfiguration.cs b/src/React.Core/IReactSiteConfiguration.cs index 8c3eed6da..b414e5d25 100644 --- a/src/React.Core/IReactSiteConfiguration.cs +++ b/src/React.Core/IReactSiteConfiguration.cs @@ -8,8 +8,8 @@ */ using System; -using Newtonsoft.Json; using System.Collections.Generic; +using Newtonsoft.Json; namespace React { @@ -193,5 +193,19 @@ public interface IReactSiteConfiguration /// /// IReactSiteConfiguration SetExceptionHandler(Action handler); + + /// + /// A provider that returns a nonce to be used on any script tags on the page. + /// This value must match the nonce used in the Content Security Policy header on the response. + /// + Func ScriptNonceProvider { get; set; } + + /// + /// Sets a provider that returns a nonce to be used on any script tags on the page. + /// This value must match the nonce used in the Content Security Policy header on the response. + /// + /// + /// + IReactSiteConfiguration SetScriptNonceProvider(Func provider); } } diff --git a/src/React.Core/ReactSiteConfiguration.cs b/src/React.Core/ReactSiteConfiguration.cs index 879a92db9..dd735b1c9 100644 --- a/src/React.Core/ReactSiteConfiguration.cs +++ b/src/React.Core/ReactSiteConfiguration.cs @@ -326,5 +326,23 @@ public IReactSiteConfiguration SetExceptionHandler(Action + /// A provider that returns a nonce to be used on any script tags on the page. + /// This value must match the nonce used in the Content Security Policy header on the response. + /// + public Func ScriptNonceProvider { get; set; } + + /// + /// Sets a provider that returns a nonce to be used on any script tags on the page. + /// This value must match the nonce used in the Content Security Policy header on the response. + /// + /// + /// + public IReactSiteConfiguration SetScriptNonceProvider(Func provider) + { + ScriptNonceProvider = provider; + return this; + } } } diff --git a/src/React.Sample.Mvc4/Content/Sample.jsx b/src/React.Sample.Mvc4/Content/Sample.jsx index 8875ef8f1..97d078f09 100644 --- a/src/React.Sample.Mvc4/Content/Sample.jsx +++ b/src/React.Sample.Mvc4/Content/Sample.jsx @@ -1,4 +1,4 @@ -/** +/** * Copyright (c) 2014-Present, Facebook, Inc. * All rights reserved. * @@ -9,8 +9,8 @@ class CommentsBox extends React.Component { static propTypes = { - initialComments: React.PropTypes.array.isRequired, - page: React.PropTypes.number + initialComments: PropTypes.array.isRequired, + page: PropTypes.number }; state = { @@ -76,7 +76,7 @@ class CommentsBox extends React.Component { class Comment extends React.Component { static propTypes = { - author: React.PropTypes.object.isRequired + author: PropTypes.object.isRequired }; render() { @@ -92,7 +92,7 @@ class Comment extends React.Component { class Avatar extends React.Component { static propTypes = { - author: React.PropTypes.object.isRequired + author: PropTypes.object.isRequired }; render() { diff --git a/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs b/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs index 1b2a59619..b1937b58c 100644 --- a/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs +++ b/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs @@ -7,9 +7,11 @@ * of patent rights can be found in the PATENTS file in the same directory. */ +using System; +using System.Security.Cryptography; using Moq; -using Xunit; using React.Web.Mvc; +using Xunit; namespace React.Tests.Mvc { @@ -20,9 +22,10 @@ public class HtmlHelperExtensionsTests /// This is only required because can not be /// injected :( /// - private Mock ConfigureMockEnvironment() + private Mock ConfigureMockEnvironment(IReactSiteConfiguration configuration = null) { var environment = new Mock(); + environment.Setup(x => x.Configuration).Returns(configuration ?? new ReactSiteConfiguration()); AssemblyRegistration.Container.Register(environment.Object); return environment; } @@ -54,6 +57,61 @@ public void ReactWithInitShouldReturnHtmlAndScript() ); } + [Fact] + public void ScriptNonceIsReturned() + { + string nonce; + using (var random = new RNGCryptoServiceProvider()) + { + byte[] nonceBytes = new byte[16]; + random.GetBytes(nonceBytes); + nonce = Convert.ToBase64String(nonceBytes); + } + + var component = new Mock(); + component.Setup(x => x.RenderHtml(false, false, null)).Returns("HTML"); + component.Setup(x => x.RenderJavaScript()).Returns("JS"); + + var config = new Mock(); + + var environment = ConfigureMockEnvironment(config.Object); + + environment.Setup(x => x.Configuration).Returns(config.Object); + environment.Setup(x => x.CreateComponent( + "ComponentName", + new { }, + null, + false, + false + )).Returns(component.Object); + + // without nonce + var result = HtmlHelperExtensions.ReactWithInit( + htmlHelper: null, + componentName: "ComponentName", + props: new { }, + htmlTag: "span" + ); + Assert.Equal( + "HTML" + System.Environment.NewLine + "", + result.ToString() + ); + + config.Setup(x => x.ScriptNonceProvider).Returns(() => nonce); + + // with nonce + result = HtmlHelperExtensions.ReactWithInit( + htmlHelper: null, + componentName: "ComponentName", + props: new { }, + htmlTag: "span" + ); + Assert.Equal( + "HTML" + System.Environment.NewLine + "", + result.ToString() + ); + } + [Fact] public void EngineIsReturnedToPoolAfterRender() {