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;
+}