diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Docs/Security.md b/Lombiq.HelpfulLibraries.AspNetCore/Docs/Security.md new file mode 100644 index 00000000..6abb72f4 --- /dev/null +++ b/Lombiq.HelpfulLibraries.AspNetCore/Docs/Security.md @@ -0,0 +1,13 @@ +# Lombiq Helpful Libraries - ASP.NET Core Libraries - Security + +## `Content-Security-Policy` + +- `ApplicationBuilderExtensions`: Contains the `AddContentSecurityPolicyHeader` extension method to add a middleware that provides the `Content-Security-Policy` header. +- `CdnContentSecurityPolicyProvider`: An optional policy provider that permits additional CDN host names for the `script-scr` and `style-src` directives. +- `ContentSecurityPolicyDirectives`: The `Content-Security-Policy` directive names that are defined in the W3C [recommendation](https://www.w3.org/TR/CSP2/#directives) and some common values. +- `IContentSecurityPolicyProvider`: Interface for services that update the dictionary that will be turned into the `Content-Security-Policy` header value. +- `ServiceCollectionExtensions`: Extensions methods for `IServiceCollection`, e.g. `AddContentSecurityPolicyProvider()` is a shortcut to register `IContentSecurityPolicyProvider` in dependency injection. + +There is a similar section for security extensions related to Orchard Core [here](../../Lombiq.HelpfulLibraries.OrchardCore/Docs/Security.md). + +These extensions provide additional security and can resolve issues reported by the [ZAP security scanner](https://github.com/Lombiq/UI-Testing-Toolbox/blob/dev/Lombiq.Tests.UI/Docs/SecurityScanning.md). diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Extensions/CookieHttpContextExtensions .cs b/Lombiq.HelpfulLibraries.AspNetCore/Extensions/CookieHttpContextExtensions .cs index a23bd9e7..759ce23f 100644 --- a/Lombiq.HelpfulLibraries.AspNetCore/Extensions/CookieHttpContextExtensions .cs +++ b/Lombiq.HelpfulLibraries.AspNetCore/Extensions/CookieHttpContextExtensions .cs @@ -7,12 +7,13 @@ public static class CookieHttpContextExtensions /// /// Sets the cookie with the given name with a maximal expiration time. /// - public static void SetCookieForever(this HttpContext httpContext, string name, string value) => + public static void SetCookieForever(this HttpContext httpContext, string name, string value, SameSiteMode sameSite = SameSiteMode.Lax) => httpContext.Response.Cookies.Append(name, value, new CookieOptions { Expires = DateTimeOffset.MaxValue, Secure = true, HttpOnly = true, + SameSite = sameSite, }); /// diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Readme.md b/Lombiq.HelpfulLibraries.AspNetCore/Readme.md index 5db79d96..70461a41 100644 --- a/Lombiq.HelpfulLibraries.AspNetCore/Readme.md +++ b/Lombiq.HelpfulLibraries.AspNetCore/Readme.md @@ -14,3 +14,4 @@ Please see the inline documentation of each piece of the API to learn more. - [Localization](Docs/Localization.md) - [Middlewares](Docs/Middlewares.md) - [MVC](Docs/Mvc.md) +- [Security](Docs/Security.md) diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Security/ApplicationBuilderExtensions.cs b/Lombiq.HelpfulLibraries.AspNetCore/Security/ApplicationBuilderExtensions.cs new file mode 100644 index 00000000..082a8c75 --- /dev/null +++ b/Lombiq.HelpfulLibraries.AspNetCore/Security/ApplicationBuilderExtensions.cs @@ -0,0 +1,158 @@ +using Lombiq.HelpfulLibraries.AspNetCore.Security; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Primitives; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Mime; +using System.Threading.Tasks; +using static Lombiq.HelpfulLibraries.AspNetCore.Security.ContentSecurityPolicyDirectives; +using static Lombiq.HelpfulLibraries.AspNetCore.Security.ContentSecurityPolicyDirectives.CommonValues; + +namespace Microsoft.AspNetCore.Builder; + +public static class ApplicationBuilderExtensions +{ + /// + /// Adds a middleware that supplies the Content-Security-Policy header. It may be further expanded by + /// registering services that implement . + /// + /// + /// If then inline scripts are permitted. When using Orchard Core a lot of front end shapes + /// use inline script blocks without a nonce (see https://github.com/OrchardCMS/OrchardCore/issues/13389) making + /// this a required setting. + /// + /// + /// If then inline styles are permitted. Note that even if your site has no embedded style + /// blocks and no style attributes, some Javascript libraries may still create some from code. + /// + public static IApplicationBuilder UseContentSecurityPolicyHeader( + this IApplicationBuilder app, + bool allowInlineScript, + bool allowInlineStyle) => + app.Use(async (context, next) => + { + const string key = "Content-Security-Policy"; + + if (context.Response.Headers.ContainsKey(key)) + { + await next(); + return; + } + + var securityPolicies = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + // Default values enforcing a same origin policy for all resources. + [BaseUri] = Self, + [DefaultSrc] = Self, + [FrameSrc] = Self, + [ScriptSrc] = Self, + [StyleSrc] = Self, + [FormAction] = Self, + // Needed for SVG images using "data:image/svg+xml,..." data URLs. + [ImgSrc] = $"{Self} {Data}", + // Modern sites shouldn't need , , and elements. + [ObjectSrc] = None, + // Necessary to prevent clickjacking (https://developer.mozilla.org/en-US/docs/Glossary/Clickjacking). + [FrameAncestors] = Self, + }; + + if (allowInlineScript) securityPolicies[ScriptSrc] = $"{Self} {UnsafeInline}"; + if (allowInlineStyle) securityPolicies[StyleSrc] = $"{Self} {UnsafeInline}"; + + context.Response.OnStarting(async () => + { + // No need to do content security policy on non-HTML responses. + if (context.Response.ContentType?.ContainsOrdinalIgnoreCase(MediaTypeNames.Text.Html) != true) return; + + // The thought behind this provider model is that if you need something else than the default, you + // should add a provider that only applies the additional directive on screens where it's actually + // needed. This way we maintain minimal permissions. Also if you need additional permissions for a + // specific action you can use the [ContentSecurityPolicyAttribute(value, name, parentName)] attribute. + foreach (var provider in context.RequestServices.GetServices()) + { + await provider.UpdateAsync(securityPolicies, context); + } + + var policy = string.Join("; ", securityPolicies.Select(pair => $"{pair.Key} {pair.Value}")); + context.Response.Headers[key] = policy; + }); + + await next(); + }); + + /// + /// Adds a middleware that sets the X-Content-Type-Options header to nosniff. + /// + /// + /// "The Anti-MIME-Sniffing header X-Content-Type-Options was not set to ’nosniff’. This allows older versions of + /// Internet Explorer and Chrome to perform MIME-sniffing on the response body, potentially causing the response + /// body to be interpreted and displayed as a content type other than the declared content type. Current (early + /// 2014) and legacy versions of Firefox will use the declared content type (if one is set), rather than performing + /// MIME-sniffing." As written in the documentation. + /// + public static IApplicationBuilder UseNosniffContentTypeOptionsHeader(this IApplicationBuilder app) => + app.Use(async (context, next) => + { + const string key = "X-Content-Type-Options"; + + if (!context.Response.Headers.ContainsKey(key)) + { + context.Response.Headers.Add(key, "nosniff"); + } + + await next(); + }); + + /// + /// Adds a middleware that checks all Set-Cookie headers and replaces any with a version containing + /// Secure and SameSite=Strict modifiers if they were missing. + /// + /// + /// With this all cookies will only work in a secure context, so you should have some way to automatically redirect + /// any HTTP request to HTTPS. + /// + public static IApplicationBuilder UseStrictAndSecureCookies(this IApplicationBuilder app) + { + static void UpdateIfMissing(ref string cookie, ref bool changed, string test, string append) + { + if (!cookie.ContainsOrdinalIgnoreCase(test)) + { + cookie += append; + changed = true; + } + } + + return app.Use((context, next) => + { + const string setCookieHeader = "Set-Cookie"; + context.Response.OnStarting(() => + { + var setCookie = context.Response.Headers[setCookieHeader]; + if (!setCookie.Any()) return Task.CompletedTask; + + var newCookies = new List(capacity: setCookie.Count); + var changed = false; + + foreach (var cookie in setCookie.WhereNot(string.IsNullOrWhiteSpace)) + { + var newCookie = cookie; + + UpdateIfMissing(ref newCookie, ref changed, "SameSite", "; SameSite=Strict"); + UpdateIfMissing(ref newCookie, ref changed, "Secure", "; Secure"); + + newCookies.Add(newCookie); + } + + if (changed) + { + context.Response.Headers[setCookieHeader] = new StringValues(newCookies.ToArray()); + } + + return Task.CompletedTask; + }); + + return next(); + }); + } +} diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Security/CdnContentSecurityPolicyProvider.cs b/Lombiq.HelpfulLibraries.AspNetCore/Security/CdnContentSecurityPolicyProvider.cs new file mode 100644 index 00000000..0f38bebe --- /dev/null +++ b/Lombiq.HelpfulLibraries.AspNetCore/Security/CdnContentSecurityPolicyProvider.cs @@ -0,0 +1,85 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +using static Lombiq.HelpfulLibraries.AspNetCore.Security.ContentSecurityPolicyDirectives; + +namespace Lombiq.HelpfulLibraries.AspNetCore.Security; + +/// +/// A content security policy directive provider that provides additional permitted host names for and . +/// +public class CdnContentSecurityPolicyProvider : IContentSecurityPolicyProvider +{ + /// + /// Gets the URLs whose will be added to the directive. + /// + public static ConcurrentBag PermittedStyleSources { get; } = new(new[] + { + new Uri("https://fonts.googleapis.com/css"), + new Uri("https://fonts.gstatic.com/"), + new Uri("https://cdn.jsdelivr.net/npm"), + }); + + /// + /// Gets the URLs whose will be added to the directive. + /// + public static ConcurrentBag PermittedScriptSources { get; } = new(new[] + { + new Uri("https://cdn.jsdelivr.net/npm"), + }); + + /// + /// Gets the URLs whose will be added to the directive. + /// + public static ConcurrentBag PermittedFontSources { get; } = new(new[] + { + new Uri("https://fonts.googleapis.com/"), + new Uri("https://fonts.gstatic.com/"), + }); + + public ValueTask UpdateAsync(IDictionary securityPolicies, HttpContext context) + { + var any = false; + + if (PermittedStyleSources.Any()) + { + any = true; + MergeValues(securityPolicies, StyleSrc, PermittedStyleSources); + } + + if (PermittedScriptSources.Any()) + { + any = true; + MergeValues(securityPolicies, ScriptSrc, PermittedScriptSources); + } + + if (PermittedFontSources.Any()) + { + any = true; + MergeValues(securityPolicies, FontSrc, PermittedFontSources); + } + + if (any) + { + var allPermittedSources = PermittedStyleSources.Concat(PermittedScriptSources).Concat(PermittedFontSources); + MergeValues(securityPolicies, ConnectSrc, allPermittedSources); + } + + return ValueTask.CompletedTask; + } + + private static void MergeValues(IDictionary policies, string key, IEnumerable sources) + { + var directiveValue = policies.GetMaybe(key) ?? policies.GetMaybe(DefaultSrc) ?? string.Empty; + + policies[key] = string.Join(' ', directiveValue + .Split(' ') + .Union(sources.Select(uri => uri.Host)) + .Distinct()); + } +} diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Security/ContentSecurityPolicyDirectives.cs b/Lombiq.HelpfulLibraries.AspNetCore/Security/ContentSecurityPolicyDirectives.cs new file mode 100644 index 00000000..81c0c321 --- /dev/null +++ b/Lombiq.HelpfulLibraries.AspNetCore/Security/ContentSecurityPolicyDirectives.cs @@ -0,0 +1,40 @@ +namespace Lombiq.HelpfulLibraries.AspNetCore.Security; + +/// +/// The Content-Security-Policy directives defined in the W3C +/// Recommendation. +/// +public static class ContentSecurityPolicyDirectives +{ + public const string BaseUri = "base-uri"; + public const string ChildSrc = "child-src"; + public const string ConnectSrc = "connect-src"; + public const string DefaultSrc = "default-src"; + public const string FontSrc = "font-src"; + public const string FormAction = "form-action"; + public const string FrameAncestors = "frame-ancestors"; + public const string FrameSrc = "frame-src"; + public const string ImgSrc = "img-src"; + public const string MediaSrc = "media-src"; + public const string ObjectSrc = "object-src"; + public const string PluginTypes = "plugin-types"; + public const string ReportUri = "report-uri"; + public const string Sandbox = "sandbox"; + public const string ScriptSrc = "script-src"; + public const string StyleSrc = "style-src"; + public const string WorkerSrc = "worker-src"; + + public static class CommonValues + { + // These values represent special words so they must be surrounded with apostrophes. + public const string Self = "'self'"; + public const string None = "'none'"; + public const string UnsafeInline = "'unsafe-inline'"; + public const string UnsafeEval = "'unsafe-eval'"; + + // These values represent allowed protocol schemes. + public const string Https = "https:"; + public const string Data = "data:"; + public const string Blob = "blob:"; + } +} diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Security/IContentSecurityPolicyProvider.cs b/Lombiq.HelpfulLibraries.AspNetCore/Security/IContentSecurityPolicyProvider.cs new file mode 100644 index 00000000..10c464ee --- /dev/null +++ b/Lombiq.HelpfulLibraries.AspNetCore/Security/IContentSecurityPolicyProvider.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using System.Collections.Generic; +using System.Threading.Tasks; +using static Lombiq.HelpfulLibraries.AspNetCore.Security.ContentSecurityPolicyDirectives; + +namespace Lombiq.HelpfulLibraries.AspNetCore.Security; + +/// +/// A service for updating the dictionary that will be turned into the Content-Security-Policy header value by +/// . +/// +public interface IContentSecurityPolicyProvider +{ + /// + /// Updates the dictionary where the keys are the Content-Security-Policy + /// directives names and the values are the matching directive values. + /// + public ValueTask UpdateAsync(IDictionary securityPolicies, HttpContext context); + + /// + /// Returns the first non-empty directive from the or or an empty + /// string. + /// + public static string GetDirective(IDictionary securityPolicies, params string[] names) + { + foreach (var name in names) + { + if (securityPolicies.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return securityPolicies.GetMaybe(DefaultSrc) ?? string.Empty; + } +} diff --git a/Lombiq.HelpfulLibraries.AspNetCore/Security/ServiceCollectionExtensions.cs b/Lombiq.HelpfulLibraries.AspNetCore/Security/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..b1a648e4 --- /dev/null +++ b/Lombiq.HelpfulLibraries.AspNetCore/Security/ServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using Lombiq.HelpfulLibraries.AspNetCore.Security; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers a Content Security Policy provider that implements and + /// will be used by . + /// + public static IServiceCollection AddContentSecurityPolicyProvider(this IServiceCollection services) + where TProvider : class, IContentSecurityPolicyProvider => + services.AddScoped(); + + /// + /// Configures the session cookie to be always secure. With this configuration the token won't work in an HTTP + /// environment so make sure that HTTPS redirection is enabled. + /// + public static IServiceCollection ConfigureSessionCookieAlwaysSecure(this IServiceCollection services) => + services.Configure(options => options.Cookie.SecurePolicy = CookieSecurePolicy.Always); +} diff --git a/Lombiq.HelpfulLibraries.Common/Extensions/StringExtensions.cs b/Lombiq.HelpfulLibraries.Common/Extensions/StringExtensions.cs index 98cf0b8b..4e814797 100644 --- a/Lombiq.HelpfulLibraries.Common/Extensions/StringExtensions.cs +++ b/Lombiq.HelpfulLibraries.Common/Extensions/StringExtensions.cs @@ -397,4 +397,20 @@ private static (string? Left, string? Separator, string? Right) Partition( var end = index + separator.Length; return (text![..index], text[index..end], text[end..]); } + + /// + /// Combines all provided parameters into a single string and eliminates duplicates. This can be used to get the + /// union of space separated word lists. For example it's used to build the values of individual directives in the + /// Content-Security-Policy HTTP header. + /// + /// + /// Given the words "script-src 'self'" and otherWords containing "script-src example.com", the result would be + /// "script-src 'self' example.com". + /// + public static string MergeWordSets(this string words, params string[] otherWords) => + string.Join( + separator: ' ', + $"{words} {string.Join(separator: ' ', otherWords)}" + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Distinct()); } diff --git a/Lombiq.HelpfulLibraries.OrchardCore/DependencyInjection/InlineStartup.cs b/Lombiq.HelpfulLibraries.OrchardCore/DependencyInjection/InlineStartup.cs new file mode 100644 index 00000000..2d45f6ca --- /dev/null +++ b/Lombiq.HelpfulLibraries.OrchardCore/DependencyInjection/InlineStartup.cs @@ -0,0 +1,43 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Modules; +using System; + +namespace Lombiq.HelpfulLibraries.OrchardCore.DependencyInjection; + +/// +/// A startup class that invokes the delegates provided in the constructor. +/// +public class InlineStartup : StartupBase +{ + private readonly Action _configureServices; + private readonly Action _configure; + private readonly int _order; + + public override int Order => _order; + + public InlineStartup( + Action configureServices, + Action configure, + int order = 0) + : this(configureServices, (app, _, _) => configure(app), order) + { + } + + public InlineStartup( + Action configureServices, + Action configure = null, + int order = 0) + { + _configureServices = configureServices; + _configure = configure; + _order = order; + } + + public override void ConfigureServices(IServiceCollection services) => + _configureServices?.Invoke(services); + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) => + _configure?.Invoke(app, routes, serviceProvider); +} diff --git a/Lombiq.HelpfulLibraries.OrchardCore/DependencyInjection/ServiceCollectionExtensions.cs b/Lombiq.HelpfulLibraries.OrchardCore/DependencyInjection/ServiceCollectionExtensions.cs index 18e2ab9b..8a282561 100644 --- a/Lombiq.HelpfulLibraries.OrchardCore/DependencyInjection/ServiceCollectionExtensions.cs +++ b/Lombiq.HelpfulLibraries.OrchardCore/DependencyInjection/ServiceCollectionExtensions.cs @@ -1,6 +1,10 @@ using Lombiq.HelpfulLibraries.Common.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using OrchardCore.Modules; +using System; namespace Lombiq.HelpfulLibraries.OrchardCore.DependencyInjection; @@ -15,4 +19,26 @@ public static void AddOrchardServices(this IServiceCollection services) services.AddLazyInjectionSupport(); services.TryAddTransient(typeof(IOrchardServices<>), typeof(OrchardServices<>)); } + + /// + /// Creates a new instance using the provided parameters, and adds it to the service + /// collection. + /// + public static IServiceCollection AddInlineStartup( + this IServiceCollection services, + Action configureServices, + Action configure, + int order = 0) => + services.AddSingleton(new InlineStartup(configureServices, configure, order)); + + /// + /// Creates a new instance using the provided parameters, and adds it to the service + /// collection. + /// + public static IServiceCollection AddInlineStartup( + this IServiceCollection services, + Action configureServices, + Action configure, + int order = 0) => + services.AddSingleton(new InlineStartup(configureServices, configure, order)); } diff --git a/Lombiq.HelpfulLibraries.OrchardCore/Docs/Security.md b/Lombiq.HelpfulLibraries.OrchardCore/Docs/Security.md new file mode 100644 index 00000000..705da267 --- /dev/null +++ b/Lombiq.HelpfulLibraries.OrchardCore/Docs/Security.md @@ -0,0 +1,9 @@ +# Lombiq Helpful Libraries - Orchard Core Libraries - Security + +## Extensions + +- `SecurityOrchardCoreBuilderExtensions`: Adds `BuilderExtensions` extensions. For example, the `ConfigureSecurityDefaults()` that provides some default security configuration for Orchard Core. + +There is a similar section for security extensions related to ASP.NET Core [here](../../Lombiq.HelpfulLibraries.AspNetCore/Docs/Security.md). + +These extensions provide additional security and can resolve issues reported by the [ZAP security scanner](https://github.com/Lombiq/UI-Testing-Toolbox/blob/dev/Lombiq.Tests.UI/Docs/SecurityScanning.md). diff --git a/Lombiq.HelpfulLibraries.OrchardCore/Lombiq.HelpfulLibraries.OrchardCore.csproj b/Lombiq.HelpfulLibraries.OrchardCore/Lombiq.HelpfulLibraries.OrchardCore.csproj index 1441bef3..b313eb54 100644 --- a/Lombiq.HelpfulLibraries.OrchardCore/Lombiq.HelpfulLibraries.OrchardCore.csproj +++ b/Lombiq.HelpfulLibraries.OrchardCore/Lombiq.HelpfulLibraries.OrchardCore.csproj @@ -44,6 +44,7 @@ + diff --git a/Lombiq.HelpfulLibraries.OrchardCore/Readme.md b/Lombiq.HelpfulLibraries.OrchardCore/Readme.md index 3acfdd9c..fc5334f0 100644 --- a/Lombiq.HelpfulLibraries.OrchardCore/Readme.md +++ b/Lombiq.HelpfulLibraries.OrchardCore/Readme.md @@ -20,6 +20,7 @@ For general details about and on using the Helpful Libraries see the [root Readm - [Mvc](Docs/Mvc.md) - [Navigation](Docs/Navigation.md) - [ResourceManagement](Docs/ResourceManagement.md) +- [Security](Docs/Security.md) - [Settings](Docs/Settings.md) - [Shapes](Docs/Shapes.md) - [TagHelpers](Docs/TagHelpers.md) diff --git a/Lombiq.HelpfulLibraries.OrchardCore/Security/ContentSecurityPolicyAttributeContentSecurityPolicyProvider.cs b/Lombiq.HelpfulLibraries.OrchardCore/Security/ContentSecurityPolicyAttributeContentSecurityPolicyProvider.cs new file mode 100644 index 00000000..7c8c3b59 --- /dev/null +++ b/Lombiq.HelpfulLibraries.OrchardCore/Security/ContentSecurityPolicyAttributeContentSecurityPolicyProvider.cs @@ -0,0 +1,76 @@ +using Lombiq.HelpfulLibraries.AspNetCore.Security; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.Infrastructure; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Threading.Tasks; +using static Lombiq.HelpfulLibraries.AspNetCore.Security.ContentSecurityPolicyDirectives; +using static Lombiq.HelpfulLibraries.AspNetCore.Security.ContentSecurityPolicyDirectives.CommonValues; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Indicates that the action's view should have the script-src: unsafe-eval content security policy directive. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class ScriptUnsafeEvalAttribute : ContentSecurityPolicyAttribute +{ + public ScriptUnsafeEvalAttribute() + : base(UnsafeEval, ScriptSrc) + { + } +} + +/// +/// Indicates that the action's view should have the provided content security policy directive. +/// +[AttributeUsage(AttributeTargets.Method)] +[SuppressMessage( + "Performance", + "CA1813:Avoid unsealed attributes", + Justification = $"Inherited by {nameof(ScriptUnsafeEvalAttribute)}.")] +public class ContentSecurityPolicyAttribute : Attribute +{ + /// + /// Gets the fallback chain of the directive, excluding . This is used to get the current + /// value. + /// + public string[] DirectiveNames { get; } + + /// + /// Gets the value to be added to the directive. The content is split into words and added to the current values + /// without repetition. + /// + public string DirectiveValue { get; } + + public ContentSecurityPolicyAttribute(string directiveValue, params string[] directiveNames) + { + DirectiveValue = directiveValue; + DirectiveNames = directiveNames; + } +} + +/// +/// Updates the content security policy based on applied to the MVC action. +/// +public class ContentSecurityPolicyAttributeContentSecurityPolicyProvider : IContentSecurityPolicyProvider +{ + public ValueTask UpdateAsync(IDictionary securityPolicies, HttpContext context) + { + if (context.RequestServices.GetService() is + { ActionContext.ActionDescriptor: ControllerActionDescriptor actionDescriptor }) + { + foreach (var attribute in actionDescriptor.MethodInfo.GetCustomAttributes()) + { + securityPolicies[ScriptSrc] = IContentSecurityPolicyProvider + .GetDirective(securityPolicies, attribute.DirectiveNames) + .MergeWordSets(attribute.DirectiveValue); + } + } + + return ValueTask.CompletedTask; + } +} diff --git a/Lombiq.HelpfulLibraries.OrchardCore/Security/OrchardCoreBuilderExtensions.cs b/Lombiq.HelpfulLibraries.OrchardCore/Security/OrchardCoreBuilderExtensions.cs new file mode 100644 index 00000000..a285fcca --- /dev/null +++ b/Lombiq.HelpfulLibraries.OrchardCore/Security/OrchardCoreBuilderExtensions.cs @@ -0,0 +1,120 @@ +using Lombiq.HelpfulLibraries.AspNetCore.Security; +using Lombiq.HelpfulLibraries.OrchardCore.DependencyInjection; +using Microsoft.AspNetCore.Antiforgery; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.StaticFiles; +using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Models; +using System.Linq; + +namespace Microsoft.Extensions.DependencyInjection; + +public static class SecurityOrchardCoreBuilderExtensions +{ + /// + /// Configures the anti-forgery token to be always secure. With this configuration the token won't work in an HTTP + /// environment so make sure that HTTPS redirection is enabled. + /// + public static OrchardCoreBuilder ConfigureAntiForgeryAlwaysSecure(this OrchardCoreBuilder builder) => + builder.ConfigureServices((services, _) => + services.Configure(options => options.Cookie.SecurePolicy = CookieSecurePolicy.Always)); + + /// + /// Provides some default security configuration for Orchard Core. + /// + /// + /// This extension method configures the application as listed below. + /// + /// + /// + /// Add to permit using script and style resources from + /// some common CDNs. + /// + /// + /// + /// + /// Make the session token's cookie always secure. + /// + /// + /// + /// + /// Make the anti-forgery token's cookie always secure. + /// + /// + /// + /// + /// Enable the OrchardCore.Diagnostics feature to provide custom error screens in production and + /// don't leak error information. + /// + /// + /// + /// + /// Adds a middleware that supplies the Content-Security-Policy header. + /// + /// + /// + /// + /// Adds a middleware that supplies the X-Content-Type-Options: nosniff header. + /// + /// + /// + /// + /// If you also need static file support, consider using + /// instead. Alternatively, make sure to put the app.UseStaticFiles() call at the very end of your app + /// configuration chain so it won't short-circuit prematurely and miss adding security headers to your static files. + /// + /// + public static OrchardCoreBuilder ConfigureSecurityDefaults( + this OrchardCoreBuilder builder, + bool allowInlineScript = true, + bool allowInlineStyle = false) => + builder.ConfigureSecurityDefaultsInner(allowInlineScript, allowInlineStyle, useStaticFiles: false); + + /// + /// The same as , but also registers the + /// at the end of the chain, so app.UseStaticFiles() should not be called when this is used. This is helpful + /// because short-circuits the call chain when delivering static files, so later + /// middlewares are not executed (e.g. the X-Content-Type-Options: nosniff header wouldn't be added). + /// + public static OrchardCoreBuilder ConfigureSecurityDefaultsWithStaticFiles( + this OrchardCoreBuilder builder, + bool allowInlineScript = true, + bool allowInlineStyle = false) => + builder.ConfigureSecurityDefaultsInner(allowInlineScript, allowInlineStyle, useStaticFiles: true); + + private static OrchardCoreBuilder ConfigureSecurityDefaultsInner( + this OrchardCoreBuilder builder, + bool allowInlineScript, + bool allowInlineStyle, + bool useStaticFiles) + { + builder.ApplicationServices.AddInlineStartup( + services => services + .AddContentSecurityPolicyProvider() + .AddContentSecurityPolicyProvider() + .AddContentSecurityPolicyProvider() + .ConfigureSessionCookieAlwaysSecure(), + (app, _, serviceProvider) => + { + // Don't add any middlewares if the site is in setup mode. + var shellSettings = serviceProvider + .GetRequiredService() + .GetAllSettings() + .FirstOrDefault(settings => settings.Name == ShellSettings.DefaultShellName); + if (shellSettings?.State == TenantState.Uninitialized) return; + + app + .UseContentSecurityPolicyHeader(allowInlineScript, allowInlineStyle) + .UseNosniffContentTypeOptionsHeader() + .UseStrictAndSecureCookies(); + + if (useStaticFiles) app.UseStaticFiles(); + }, + order: 99); // Makes this service load fairly late. This should make the setup detection more accurate. + + return builder + .ConfigureAntiForgeryAlwaysSecure() + .AddTenantFeatures("OrchardCore.Diagnostics"); + } +} diff --git a/Lombiq.HelpfulLibraries.OrchardCore/Security/ResourceManagerContentSecurityPolicyProvider.cs b/Lombiq.HelpfulLibraries.OrchardCore/Security/ResourceManagerContentSecurityPolicyProvider.cs new file mode 100644 index 00000000..cbae5b7b --- /dev/null +++ b/Lombiq.HelpfulLibraries.OrchardCore/Security/ResourceManagerContentSecurityPolicyProvider.cs @@ -0,0 +1,51 @@ +using Lombiq.HelpfulLibraries.AspNetCore.Security; +using Microsoft.AspNetCore.Http; +using OrchardCore.ResourceManagement; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Looks in the resource manager for a resource of type called . +/// If found, the directive is amended with the value or values in . The refers to the resolution order where to look for the +/// existing directive values. Its first item is the . +/// +public abstract class ResourceManagerContentSecurityPolicyProvider : IContentSecurityPolicyProvider +{ + protected abstract string ResourceType { get; } + protected abstract string ResourceName { get; } + protected abstract IReadOnlyCollection DirectiveNameChain { get; } + protected abstract string DirectiveValue { get; } + + protected string DirectiveName => DirectiveNameChain.First(); + + public ValueTask UpdateAsync(IDictionary securityPolicies, HttpContext context) + { + var resourceManager = context.RequestServices.GetRequiredService(); + var resourceExists = resourceManager + .GetRequiredResources(ResourceType) + .Any(script => script.Resource.Name == ResourceName); + + if (resourceExists) + { + securityPolicies[DirectiveName] = IContentSecurityPolicyProvider + .GetDirective(securityPolicies, DirectiveNameChain.ToArray()) + .MergeWordSets(DirectiveValue); + } + + return ThenUpdateAsync(securityPolicies, context, resourceExists); + } + + /// + /// When overridden, this may be used for additional updates related to the resource in . + /// + protected virtual ValueTask ThenUpdateAsync( + IDictionary securityPolicies, + HttpContext context, + bool resourceExists) => + ValueTask.CompletedTask; +} diff --git a/Lombiq.HelpfulLibraries.OrchardCore/Security/VueContentSecurityPolicyProvider.cs b/Lombiq.HelpfulLibraries.OrchardCore/Security/VueContentSecurityPolicyProvider.cs new file mode 100644 index 00000000..0f3087c7 --- /dev/null +++ b/Lombiq.HelpfulLibraries.OrchardCore/Security/VueContentSecurityPolicyProvider.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using static Lombiq.HelpfulLibraries.AspNetCore.Security.ContentSecurityPolicyDirectives; +using static Lombiq.HelpfulLibraries.AspNetCore.Security.ContentSecurityPolicyDirectives.CommonValues; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Enable the value for the directive. This is necessary to evaluate +/// dynamic (not precompiled) templates. These are extensively used in stock Orchard Core. Also in many third party +/// modules where the DOM HTML template may contain Razor generated content. +/// +public class VueContentSecurityPolicyProvider : ResourceManagerContentSecurityPolicyProvider +{ + protected override string ResourceType => "script"; + protected override string ResourceName => "vuejs"; + protected override IReadOnlyCollection DirectiveNameChain { get; } = new[] { ScriptSrc }; + protected override string DirectiveValue => UnsafeEval; +}