From 64710761042e4f70e01b52409d4d61af689e10af Mon Sep 17 00:00:00 2001 From: Andrew Lock Date: Sat, 31 Aug 2024 15:22:01 +0100 Subject: [PATCH] Allow specifying different properties for endpoints (#172) * nit: Remove dead code * Remove unused package references * Remove ICustomHeaderService and make header service static * Add support for adding multiple named policies, and referencing them in the middleware * Add support for specifying a different header policy for a given endpoint * Allow configuring the default policy in the services * Add ReadMe * Add support for MVC attributes with named policies --- README.md | 101 +++++++++- .../EndpointConventionBuilderExtensions.cs | 30 +++ .../Infrastructure/CustomHeaderOptions.cs | 19 +- .../Infrastructure/CustomHeaderService.cs | 8 +- .../Infrastructure/CustomHeadersResult.cs | 2 +- .../Infrastructure/ICustomHeaderService.cs | 26 --- .../ISecurityHeadersPolicyMetadata.cs | 12 ++ .../SecurityHeaderPolicyBuilder.cs | 73 +++++++ .../SecurityHeadersPolicyMetadata.cs | 11 ++ ...scapades.AspNetCore.SecurityHeaders.csproj | 8 +- .../SecurityHeadersMiddleware.cs | 90 +++++---- .../SecurityHeadersMiddlewareExtensions.cs | 58 +++++- .../SecurityHeadersPolicyAttribute.cs | 13 ++ .../ServiceCollectionExtensions.cs | 28 +++ .../HeaderAssertionHelpers.cs | 44 +++++ ...ecurityHeadersMiddlewareFunctionalTests.cs | 48 +++-- ...ecurityHeadersMiddlewareFunctionalTests.cs | 46 +++-- .../SecurityHeadersMiddlewareTests.cs | 171 ++++++++++++---- test/RazorWebSite/Startup.cs | 17 +- .../EchoMiddleware.cs | 34 ---- .../HomeController.cs | 16 ++ .../Project_Readme.html | 187 ------------------ .../Startup.cs | 28 ++- 23 files changed, 676 insertions(+), 394 deletions(-) create mode 100644 src/NetEscapades.AspNetCore.SecurityHeaders/EndpointConventionBuilderExtensions.cs delete mode 100644 src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/ICustomHeaderService.cs create mode 100644 src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/ISecurityHeadersPolicyMetadata.cs create mode 100644 src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/SecurityHeaderPolicyBuilder.cs create mode 100644 src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/SecurityHeadersPolicyMetadata.cs create mode 100644 src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersPolicyAttribute.cs create mode 100644 src/NetEscapades.AspNetCore.SecurityHeaders/ServiceCollectionExtensions.cs create mode 100644 test/NetEscapades.AspNetCore.SecurityHeaders.Test/HeaderAssertionHelpers.cs delete mode 100644 test/SecurityHeadersMiddlewareWebSite/EchoMiddleware.cs create mode 100644 test/SecurityHeadersMiddlewareWebSite/HomeController.cs delete mode 100644 test/SecurityHeadersMiddlewareWebSite/Project_Readme.html diff --git a/README.md b/README.md index e50501d..0ad42a4 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ When you install the package, it should be added to your `.csproj`. Alternativel ``` -Simply add the middleware to your ASP.NET Core application by configuring it as part of your normal `Startup` pipeline. Note that the order of middleware matters, so to apply the headers to all requests it should be configured first in your pipeline. +There are various ways to configure the headers for your application. + +In the simplest scenario, add the middleware to your ASP.NET Core application by configuring it as part of your normal `Startup` pipeline (or `WebApplication` in .NET 6+). Note that the order of middleware matters, so to apply the headers to all requests it should be configured first in your pipeline. To use the default security headers for your application, add the middleware using: @@ -152,6 +154,103 @@ public void Configure(IApplicationBuilder app) } ``` +## Applying different headers to different endpoints + +In some situations, you may need to apply different security headers to different endpoints. For example, you may want to have a very restrictive Content-Security-Policy by default, but then have a more relaxed on specific endpoints that require it. This is supported, but requires more configuration. + +### 1. Configure your policies using `AddSecurityHeaderPolicies()` + +You can configure named and default policies by calling `AddSecurityHeaderPolicies()` on `IServiceCollection`. You can configure the default policy to use, as well as any named policies. For example, the following configures the default policy (used when `UseSecurityHeaders()` is called without any arguments), and a named policy: + +```csharp +var builder = WebApplication.CreateBuilder(); + +// 👇 Call AddSecurityHeaderPolicies() +builder.Services.AddSecurityHeaderPolicies() + .SetDefaultPolicy(policy => policy.AddDefaultSecurityHeaders()) // 👈 Configure the default policy + .AddPolicy("CustomPolicy", policy => policy.AddCustomHeader("X-Custom", "SomeValue")); // 👈 Configure named policies + +``` + +### 2. Add the default middleware early to the pipeline + +The security headers middleware can only add headers to _all_ requests if it is early in the middleware pipeline, so it's important to add the headders middleware at the start of your middleware pipeline. However, if you want to have endpoint-specific policies, then you _also_ need to place the middleware after the call to `UseRouting()`, as that is the point at which the endpoint that will be executed is selected. + +```csharp +var builder = WebApplication.CreateBuilder(); + +// 👇 Configure policies as shown previously +builder.Services.AddSecurityHeaderPolicies() + .SetDefaultPolicy(policy => policy.AddDefaultSecurityHeaders()) + .AddPolicy("CustomPolicy", policy => policy.AddCustomHeader("X-Custom", "SomeValue")); + +var app = builder.Build(); + +// Set the default headers for requests that +// 👇 don't make it to the routing middleware +app.UseSecurityHeaders(); + +app.UseStaticFiles(); // other middleware +app.UseAuthentication(); +app.UseRouting(); + +app.UseSecurityHeaders(); // 👈 Add after the routing middleware +app.UseAuthorization(); + +app.MapGet("/", () => "Hello world"); +app.Run(); +``` + +Note that if you pass a policy to any call to `UseSecurityHeaders()` it will override the "default" policy used at that point. + +### 3. Apply custom policies to endpoints + +To apply a non-default policy to an endpoint, use the `WithSecurityHeadersPolicy(policy)` endpoint extension method, and pass in the name of the policy to apply: + +```csharp +var builder = WebApplication.CreateBuilder(); + +builder.Services.AddSecurityHeaderPolicies() + .SetDefaultPolicy(policy => policy.AddDefaultSecurityHeaders()) + .AddPolicy("CustomPolicy", policy => policy.AddCustomHeader("X-Custom", "SomeValue")); + +var app = builder.Build(); + +app.UseSecurityHeaders(); + +app.UseStaticFiles(); +app.UseAuthentication(); +app.UseRouting(); + +app.UseSecurityHeaders(); +app.UseAuthorization(); + +app.MapGet("/", () => "Hello world") + .WithSecurityHeadersPolicy("CustomPolicy"); // 👈 Apply a named policy to the endpoint +app.Run(); +``` + +If you're using MVC controllers or Razor Pages, you can apply the `[SecurityHeadersPolicy(policyName)]` attribute to your endpoints: + +```csharp +public class HomeController : ControllerBase +{ + [SecurityHeadersPolicy("CustomHeader")] // 👈 Apply a custom header to the endpoint + public IActionResult Index() + { + return View(); + } +} +``` + +Each call to `UseSecurityHeaders()` will re-evaluate the applicable policies; the headers are applied just before the response is sent. The policy to apply is determined as follows, with the first applicable policy selected. + +1. If an endpoint has been selected, and a named policy is applied, use that. +2. If a named or policy instance is passed to the `SecurityHeadersMiddleware`, use that. +3. If the default policy has been set using `SetDefaultPolicy()`, use that. +4. Otherwise, apply the default headers (those added by `AddDefaultSecurityHeaders()`) + + ## RemoveServerHeader One point to be aware of is that the `RemoveServerHeader` method will rarely (ever?) be sufficient to remove the `Server` header from your output. If any subsequent middleware in your application pipeline add the header, then this will be able to remove it. However Kestrel will generally add the `Server` header too late in the pipeline to be able to modify it. diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/EndpointConventionBuilderExtensions.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/EndpointConventionBuilderExtensions.cs new file mode 100644 index 0000000..6c28848 --- /dev/null +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/EndpointConventionBuilderExtensions.cs @@ -0,0 +1,30 @@ +using System; +using NetEscapades.AspNetCore.SecurityHeaders; + +// ReSharper disable once CheckNamespace +namespace Microsoft.AspNetCore.Builder; + +/// +/// Header extension methods for . +/// +public static class EndpointConventionBuilderExtensions +{ + /// + /// Adds a security headers policy with the provided policy name to the endpoint(s). + /// + /// The endpoint convention builder. + /// The security headers policy to use. + /// The original convention builder parameter. + /// The type of the endpoint convention builder + public static TBuilder WithSecurityHeadersPolicy(this TBuilder builder, string policyName) + where TBuilder : IEndpointConventionBuilder + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Add(endpointBuilder => { endpointBuilder.Metadata.Add(new SecurityHeadersPolicyMetadata(policyName)); }); + return builder; + } +} \ No newline at end of file diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeaderOptions.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeaderOptions.cs index 957d1a8..7b46203 100644 --- a/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeaderOptions.cs +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeaderOptions.cs @@ -7,27 +7,18 @@ namespace NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; /// /// Provides programmatic configuration for Security Headers. /// -[Obsolete("This class is unused since v0.5.0, and will be removed in a future version of the package")] -public class CustomHeaderOptions +internal class CustomHeaderOptions { - private string _defaultPolicyName = "__DefaultSecurityHeadersPolicy"; - /// /// The collections of policies to apply /// /// The collection of policies, indexed by header name - public Dictionary PolicyCollections { get; } = - new Dictionary(); + public Dictionary NamedPolicyCollections { get; } = new(); /// - /// Gets or sets the name of the default policy + /// The default policy to apply /// - /// The name of the default policy - public string DefaultPolicyName - { - get => _defaultPolicyName; - set => _defaultPolicyName = value ?? throw new ArgumentNullException(nameof(value)); - } + public HeaderPolicyCollection? DefaultPolicy { get; set; } /// /// Gets the policy based on the @@ -41,6 +32,6 @@ public string DefaultPolicyName throw new ArgumentNullException(nameof(name)); } - return PolicyCollections.ContainsKey(name) ? PolicyCollections[name] : null; + return NamedPolicyCollections.GetValueOrDefault(name); } } \ No newline at end of file diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeaderService.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeaderService.cs index cd30c56..045104e 100644 --- a/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeaderService.cs +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeaderService.cs @@ -6,9 +6,9 @@ namespace NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; /// -/// Default implementation of . +/// Handles processing of headers /// -public class CustomHeaderService : ICustomHeaderService +internal static class CustomHeaderService { /// /// Evaluates the given using the passed in . @@ -17,7 +17,7 @@ public class CustomHeaderService : ICustomHeaderService /// The containing policies to be evaluated. /// A which contains the result of policy evaluation and can be /// used by the caller to set appropriate response headers. - public virtual CustomHeadersResult EvaluatePolicy(HttpContext context, HeaderPolicyCollection policies) + public static CustomHeadersResult EvaluatePolicy(HttpContext context, HeaderPolicyCollection policies) { if (context == null) { @@ -50,7 +50,7 @@ public virtual CustomHeadersResult EvaluatePolicy(HttpContext context, HeaderPol /// /// The associated with the current call. /// The used to read the allowed values. - public virtual void ApplyResult(HttpResponse response, CustomHeadersResult result) + public static void ApplyResult(HttpResponse response, CustomHeadersResult result) { if (response == null) { diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeadersResult.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeadersResult.cs index 99235f2..fd065ab 100644 --- a/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeadersResult.cs +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/CustomHeadersResult.cs @@ -3,7 +3,7 @@ namespace NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; /// -/// Results returned by +/// Results returned by /// public class CustomHeadersResult { diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/ICustomHeaderService.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/ICustomHeaderService.cs deleted file mode 100644 index e2595f5..0000000 --- a/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/ICustomHeaderService.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; - -namespace NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; - -/// -/// A type which can evaluate a policy for a particular . -/// -public interface ICustomHeaderService -{ - /// - /// Evaluates the given using the passed in . - /// - /// The associated with the call. - /// The containing policies to be evaluated. - /// A which contains the result of policy evaluation and can be - /// used by the caller to set appropriate response headers. - CustomHeadersResult EvaluatePolicy(HttpContext context, HeaderPolicyCollection policies); - - /// - /// Adds and removes the required headers to the given . - /// - /// The associated with the current call. - /// The used to read the allowed values. - void ApplyResult(HttpResponse response, CustomHeadersResult result); -} \ No newline at end of file diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/ISecurityHeadersPolicyMetadata.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/ISecurityHeadersPolicyMetadata.cs new file mode 100644 index 0000000..25d1162 --- /dev/null +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/ISecurityHeadersPolicyMetadata.cs @@ -0,0 +1,12 @@ +namespace NetEscapades.AspNetCore.SecurityHeaders; + +/// +/// Metadata about which policy to apply to and endpoint +/// +internal interface ISecurityHeadersPolicyMetadata +{ + /// + /// The name of the policy to apply + /// + public string PolicyName { get; } +} \ No newline at end of file diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/SecurityHeaderPolicyBuilder.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/SecurityHeaderPolicyBuilder.cs new file mode 100644 index 0000000..93b6a7d --- /dev/null +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/SecurityHeaderPolicyBuilder.cs @@ -0,0 +1,73 @@ +using System; +using Microsoft.AspNetCore.Builder; + +namespace NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; + +/// +/// Used to configure for security headers +/// +public class SecurityHeaderPolicyBuilder +{ + private readonly CustomHeaderOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// The options where the configuration is stored + internal SecurityHeaderPolicyBuilder(CustomHeaderOptions options) + { + _options = options; + } + + /// + /// Adds a security header policy to the application with the given name. + /// This name can be used to refer to the policy in endpoints + /// + /// The name of the policy + /// An to configure the security headers for the policy + /// The for chaining + public SecurityHeaderPolicyBuilder AddPolicy(string name, Action configurePolicy) + { + var policyCollection = new HeaderPolicyCollection(); + configurePolicy(policyCollection); + return AddPolicy(name, policyCollection); + } + + /// + /// Adds a security header policy to the application with the given name. + /// This name can be used to refer to the policy in endpoints + /// + /// The name of the policy + /// The security headers for the policy + /// The for chaining + public SecurityHeaderPolicyBuilder AddPolicy(string name, HeaderPolicyCollection policyCollection) + { + _options.NamedPolicyCollections[name] = policyCollection; + return this; + } + + /// + /// Sets the default security header policy to use when no other named policy is provided + /// This policy is used wherever a named policy does not apply + /// + /// An to configure the security headers for the policy + /// The for chaining + public SecurityHeaderPolicyBuilder SetDefaultPolicy(Action configurePolicy) + { + var policyCollection = new HeaderPolicyCollection(); + configurePolicy(policyCollection); + return SetDefaultPolicy(policyCollection); + } + + /// + /// Adds the default security header policy to the application. + /// This policy is used wherever a named policy does not apply + /// + /// The security headers for the policy + /// The for chaining + public SecurityHeaderPolicyBuilder SetDefaultPolicy(HeaderPolicyCollection policyCollection) + { + _options.DefaultPolicy = policyCollection; + return this; + } +} \ No newline at end of file diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/SecurityHeadersPolicyMetadata.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/SecurityHeadersPolicyMetadata.cs new file mode 100644 index 0000000..22b3bcc --- /dev/null +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/Infrastructure/SecurityHeadersPolicyMetadata.cs @@ -0,0 +1,11 @@ +namespace NetEscapades.AspNetCore.SecurityHeaders; + +/// +/// Creates a new instance of with the specified +/// +/// The name of the policy to apply +internal class SecurityHeadersPolicyMetadata(string policyName) : ISecurityHeadersPolicyMetadata +{ + /// + public string PolicyName { get; } = policyName; +} \ No newline at end of file diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/NetEscapades.AspNetCore.SecurityHeaders.csproj b/src/NetEscapades.AspNetCore.SecurityHeaders/NetEscapades.AspNetCore.SecurityHeaders.csproj index d33f76a..3f208e3 100644 --- a/src/NetEscapades.AspNetCore.SecurityHeaders/NetEscapades.AspNetCore.SecurityHeaders.csproj +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/NetEscapades.AspNetCore.SecurityHeaders.csproj @@ -28,13 +28,7 @@ - - - - - - - + diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersMiddleware.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersMiddleware.cs index 98cc076..331f12a 100644 --- a/src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersMiddleware.cs +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersMiddleware.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; using NetEscapades.AspNetCore.SecurityHeaders.Headers; using NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; @@ -11,42 +12,31 @@ namespace NetEscapades.AspNetCore.SecurityHeaders; /// /// An ASP.NET Core middleware for adding security headers. /// -public class SecurityHeadersMiddleware +internal class SecurityHeadersMiddleware { + private const string HttpContextKey = "__NetEscapades.AspNetCore.SecurityHeaders"; private readonly RequestDelegate _next; - private readonly HeaderPolicyCollection _policy; - private readonly NonceGenerator _nonceGenerator; - private readonly bool _mustGenerateNonce; + private readonly ILogger _logger; + private readonly CustomHeaderOptions? _options; + private readonly HeaderPolicyCollection _defaultPolicy; + private readonly NonceGenerator? _nonceGenerator; /// /// Initializes a new instance of the class. /// /// The next middleware in the pipeline. - /// An instance of . - /// A containing the policies to be applied. - public SecurityHeadersMiddleware(RequestDelegate next, ICustomHeaderService service, HeaderPolicyCollection policies) - : this(next, service, policies, new NonceGenerator()) - { - _mustGenerateNonce = MustGenerateNonce(_policy); - } - - /// - /// Initializes a new instance of the class. - /// - /// The next middleware in the pipeline. - /// An instance of . - /// A containing the policies to be applied. - /// Used to generate nonce (number used once) values for headers - internal SecurityHeadersMiddleware(RequestDelegate next, ICustomHeaderService service, HeaderPolicyCollection policies, NonceGenerator nonceGenerator) + /// A logger for recording errors. + /// Options on how to control the settings that are applied + /// A containing the policies to be applied. + public SecurityHeadersMiddleware(RequestDelegate next, ILogger logger, CustomHeaderOptions? options, HeaderPolicyCollection defaultPolicy) { _next = next ?? throw new ArgumentNullException(nameof(next)); - CustomHeaderService = service ?? throw new ArgumentNullException(nameof(service)); - _policy = policies ?? throw new ArgumentNullException(nameof(policies)); - _nonceGenerator = nonceGenerator ?? throw new ArgumentException(nameof(nonceGenerator)); + _logger = logger; + _options = options; + _defaultPolicy = defaultPolicy ?? throw new ArgumentNullException(nameof(defaultPolicy)); + _nonceGenerator = MustGenerateNonce(_defaultPolicy) ? new() : null; } - private ICustomHeaderService CustomHeaderService { get; } - /// /// Invoke the middleware /// @@ -54,32 +44,58 @@ internal SecurityHeadersMiddleware(RequestDelegate next, ICustomHeaderService se /// A representing the asynchronous operation. public async Task Invoke(HttpContext context) { - if (context == null) + // Policy resolution rules: + // + // 1. If there is an endpoint with a named policy, then fetch that policy + // 2. Use the provided default policy + var endpoint = context.GetEndpoint(); + var metadata = endpoint?.Metadata.GetMetadata(); + + HeaderPolicyCollection policy = _defaultPolicy; + + if (!string.IsNullOrEmpty(metadata?.PolicyName)) { - throw new ArgumentNullException(nameof(context)); + if (_options?.GetPolicy(metadata.PolicyName) is { } namedPolicy) + { + policy = namedPolicy; + } + else + { + // log that we couldn't find the policy + _logger.LogWarning( + "Error configuring security headers middleware: policy '{PolicyName}' could not be found. " + + "Configure the policies for your application by calling AddSecurityHeaderPolicies() on IServiceCollection " + + "and adding a policy with the required name. Using default policy for request", + metadata.PolicyName); + } } - if (_mustGenerateNonce) + if (context.Items[HttpContextKey] is null) { - context.SetNonce(_nonceGenerator.GetNonce(Constants.DefaultBytesInNonce)); + context.Response.OnStarting(OnResponseStarting, context); + if (_nonceGenerator is not null) + { + context.SetNonce(_nonceGenerator.GetNonce(Constants.DefaultBytesInNonce)); + } } - context.Response.OnStarting(OnResponseStarting, Tuple.Create(this, context, _policy)); + // Write into the context, so that subsequent requests can "overwrite" it + context.Items[HttpContextKey] = policy; + await _next(context); } private static Task OnResponseStarting(object state) { - var (middleware, context, policy) = (Tuple)state; + var context = (HttpContext)state; - var result = middleware.CustomHeaderService.EvaluatePolicy(context, policy); - middleware.CustomHeaderService.ApplyResult(context.Response, result); + if (context.Items[HttpContextKey] is HeaderPolicyCollection policy) + { + var result = CustomHeaderService.EvaluatePolicy(context, policy); + CustomHeaderService.ApplyResult(context.Response, result); + } -#if NET451 - return Task.FromResult(true); -#else return Task.CompletedTask; -#endif } private static bool MustGenerateNonce(HeaderPolicyCollection policy) diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersMiddlewareExtensions.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersMiddlewareExtensions.cs index 4184cbd..527c949 100644 --- a/src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersMiddlewareExtensions.cs +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersMiddlewareExtensions.cs @@ -1,4 +1,6 @@ using System; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using NetEscapades.AspNetCore.SecurityHeaders; using NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; @@ -14,7 +16,7 @@ public static class SecurityHeadersMiddlewareExtensions /// Adds middleware to your web application pipeline to automatically add security headers to requests /// /// The IApplicationBuilder passed to your Configure method. - /// A configured policy collection. + /// A configured policy collection to use by default. /// The original app parameter public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app, HeaderPolicyCollection policies) { @@ -28,9 +30,8 @@ public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder ap throw new ArgumentNullException(nameof(policies)); } - var service = app.ApplicationServices.GetService(typeof(ICustomHeaderService)) ?? new CustomHeaderService(); - - return app.UseMiddleware(service, policies); + var options = (CustomHeaderOptions)app.ApplicationServices.GetService(typeof(CustomHeaderOptions)); + return app.UseSecurityHeaders(options, policies); } /// @@ -61,6 +62,53 @@ public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder ap /// The original app parameter public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app) { - return app.UseSecurityHeaders(policies => policies.AddDefaultSecurityHeaders()); + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + var options = app.ApplicationServices.GetService(typeof(CustomHeaderOptions)) as CustomHeaderOptions; + var policy = options?.DefaultPolicy ?? new HeaderPolicyCollection().AddDefaultSecurityHeaders(); + + return app.UseSecurityHeaders(options, policy); + } + + /// + /// Adds middleware to your web application pipeline using the specified policy by default. + /// + /// The IApplicationBuilder passed to your Configure method. + /// The name of the policy to apply + /// The original app parameter + public static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app, string policyName) + { + if (app == null) + { + throw new ArgumentNullException(nameof(app)); + } + + if (string.IsNullOrEmpty(policyName)) + { + throw new ArgumentNullException(nameof(policyName)); + } + + var options = app.ApplicationServices.GetService(typeof(CustomHeaderOptions)) as CustomHeaderOptions; + var policy = options?.GetPolicy(policyName); + if (policy is null) + { + var log = ((ILoggerFactory)app.ApplicationServices.GetRequiredService(typeof(ILoggerFactory))).CreateLogger(typeof(SecurityHeadersMiddlewareExtensions)); + log.LogWarning( + "Error configuring security headers middleware: policy '{PolicyName}' could not be found. " + + "Configure the policies for your application by calling AddSecurityHeaderPolicies() on IServiceCollection " + + "and adding a policy with the required name.", + policyName); + return app; + } + + return app.UseSecurityHeaders(options, policy); + } + + private static IApplicationBuilder UseSecurityHeaders(this IApplicationBuilder app, CustomHeaderOptions? options, HeaderPolicyCollection policies) + { + return app.UseMiddleware(options ?? new CustomHeaderOptions(), policies); } } \ No newline at end of file diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersPolicyAttribute.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersPolicyAttribute.cs new file mode 100644 index 0000000..f1b5566 --- /dev/null +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/SecurityHeadersPolicyAttribute.cs @@ -0,0 +1,13 @@ +using System; + +namespace NetEscapades.AspNetCore.SecurityHeaders; + +/// +/// Adds a security headers policy with the provided policy name to the endpoint(s). +/// +/// The security headers policy to use. +public class SecurityHeadersPolicyAttribute(string policyName) : Attribute, ISecurityHeadersPolicyMetadata +{ + /// + public string PolicyName { get; } = policyName; +} \ No newline at end of file diff --git a/src/NetEscapades.AspNetCore.SecurityHeaders/ServiceCollectionExtensions.cs b/src/NetEscapades.AspNetCore.SecurityHeaders/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..e8e0eef --- /dev/null +++ b/src/NetEscapades.AspNetCore.SecurityHeaders/ServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using System; +using NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; + +// ReSharper disable once CheckNamespace +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods for setting up security header services in an . +/// +public static class ServiceCollectionExtensions +{ + /// + /// Creates a builder for configuring security header policies. + /// + /// The to add services to. + /// The so that additional calls can be chained. + public static SecurityHeaderPolicyBuilder AddSecurityHeaderPolicies(this IServiceCollection services) + { + if (services == null) + { + throw new ArgumentNullException(nameof(services)); + } + + var options = new CustomHeaderOptions(); + services.AddSingleton(options); + return new SecurityHeaderPolicyBuilder(options); + } +} \ No newline at end of file diff --git a/test/NetEscapades.AspNetCore.SecurityHeaders.Test/HeaderAssertionHelpers.cs b/test/NetEscapades.AspNetCore.SecurityHeaders.Test/HeaderAssertionHelpers.cs new file mode 100644 index 0000000..1eab150 --- /dev/null +++ b/test/NetEscapades.AspNetCore.SecurityHeaders.Test/HeaderAssertionHelpers.cs @@ -0,0 +1,44 @@ +using System.Linq; +using System.Net.Http.Headers; +using FluentAssertions; +using NetEscapades.AspNetCore.SecurityHeaders.Headers; +using Xunit; + +namespace NetEscapades.AspNetCore.SecurityHeaders.Test; + +public static class HeaderAssertionHelpers +{ + public static void AssertHttpRequestDefaultSecurityHeaders(this HttpResponseHeaders headers) + { + string header = headers.GetValues("X-Content-Type-Options").FirstOrDefault()!; + header.Should().Be("nosniff"); + header = headers.GetValues("X-Frame-Options").FirstOrDefault()!; + header.Should().Be("DENY"); + header = headers.GetValues("Referrer-Policy").FirstOrDefault()!; + header.Should().Be("strict-origin-when-cross-origin"); + header = headers.GetValues("Content-Security-Policy").FirstOrDefault()!; + header.Should().Be("object-src 'none'; form-action 'self'; frame-ancestors 'none'"); + + Assert.False(headers.Contains("Server"), + "Should not contain server header"); + Assert.False(headers.Contains("Strict-Transport-Security"), + "Should not contain Strict-Transport-Security header over http"); + } + + public static void AssertSecureRequestDefaultSecurityHeaders(this HttpResponseHeaders headers) + { + string header = headers.GetValues("X-Content-Type-Options").FirstOrDefault()!; + header.Should().Be("nosniff"); + header = headers.GetValues("X-Frame-Options").FirstOrDefault()!; + header.Should().Be("DENY"); + header = headers.GetValues("Strict-Transport-Security").FirstOrDefault()!; + header.Should().Be($"max-age={StrictTransportSecurityHeader.OneYearInSeconds}"); + header = headers.GetValues("Referrer-Policy").FirstOrDefault()!; + header.Should().Be("strict-origin-when-cross-origin"); + header = headers.GetValues("Content-Security-Policy").FirstOrDefault()!; + header.Should().Be("object-src 'none'; form-action 'self'; frame-ancestors 'none'"); + + Assert.False(headers.Contains("Server"), + "Should not contain server header"); + } +} \ No newline at end of file diff --git a/test/NetEscapades.AspNetCore.SecurityHeaders.Test/HttpSecurityHeadersMiddlewareFunctionalTests.cs b/test/NetEscapades.AspNetCore.SecurityHeaders.Test/HttpSecurityHeadersMiddlewareFunctionalTests.cs index c49da14..6b7f6b9 100644 --- a/test/NetEscapades.AspNetCore.SecurityHeaders.Test/HttpSecurityHeadersMiddlewareFunctionalTests.cs +++ b/test/NetEscapades.AspNetCore.SecurityHeaders.Test/HttpSecurityHeadersMiddlewareFunctionalTests.cs @@ -3,12 +3,14 @@ using System.Net.Http; using System.Threading.Tasks; using FluentAssertions; +using NetEscapades.AspNetCore.SecurityHeaders.Test; using Xunit; namespace NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; public class HttpSecurityHeadersMiddlewareFunctionalTests : IClassFixture> { + private const string EchoPath = "/SecurityHeadersMiddleware/EC6AA70D-BA3E-4B71-A87F-18625ADDB2BD"; public HttpSecurityHeadersMiddlewareFunctionalTests(HttpSecurityHeadersTestFixture fixture) { Client = fixture.Client; @@ -17,14 +19,14 @@ public HttpSecurityHeadersMiddlewareFunctionalTests(HttpSecurityHeadersTestFixtu public HttpClient Client { get; } [Theory] - [InlineData("GET")] - [InlineData("HEAD")] - [InlineData("POST")] - [InlineData("PUT")] - public async Task AllMethods_AddSecurityHeaders_ExceptStrict(string method) + [InlineData("GET", EchoPath)] + [InlineData("HEAD", EchoPath)] + [InlineData("POST", EchoPath)] + [InlineData("PUT", EchoPath)] + [InlineData("GET", "/api/index")] + public async Task AllMethods_AddSecurityHeaders_ExceptStrict(string method, string path) { // Arrange - var path = "/SecurityHeadersMiddleware/EC6AA70D-BA3E-4B71-A87F-18625ADDB2BD"; var request = new HttpRequestMessage(new HttpMethod(method), path); // Act @@ -34,18 +36,30 @@ public async Task AllMethods_AddSecurityHeaders_ExceptStrict(string method) response.StatusCode.Should().Be(HttpStatusCode.OK); var content = await response.Content.ReadAsStringAsync(); content.Should().Be(path); - var responseHeaders = response.Headers; - var header = response.Headers.GetValues("X-Content-Type-Options").FirstOrDefault(); - header.Should().Be("nosniff"); - header = response.Headers.GetValues("X-Frame-Options").FirstOrDefault(); - header.Should().Be("DENY"); - header = response.Headers.GetValues("Referrer-Policy").FirstOrDefault(); - header.Should().Be("strict-origin-when-cross-origin"); - header = response.Headers.GetValues("Content-Security-Policy").FirstOrDefault(); - header.Should().Be("object-src 'none'; form-action 'self'; frame-ancestors 'none'"); + response.Headers.AssertHttpRequestDefaultSecurityHeaders(); //http so no Strict transport - responseHeaders.Should().NotContain(x => x.Key == "Strict-Transport-Security"); - responseHeaders.Should().NotContain(x => x.Key == "Server"); + response.Headers.Should().NotContain(x => x.Key == "Strict-Transport-Security"); + response.Headers.Should().NotContain(x => x.Key == "Server"); + } + + [Theory] + [InlineData("/custom", "Hello World!")] + [InlineData("/api/custom", "Hello Controller!")] + public async Task WhenUsingEndpoint_Overrides_Default(string path, string expected) + { + // Arrange + // Act + var response = await Client.GetAsync(path); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Be(expected); + + // no security headers + response.Headers.Should().NotContain("X-Frame-Options"); + response.Headers.TryGetValues("Custom-Header", out var customHeader).Should().BeTrue(); + customHeader.Should().ContainSingle("MyValue"); } } \ No newline at end of file diff --git a/test/NetEscapades.AspNetCore.SecurityHeaders.Test/HttpsSecurityHeadersMiddlewareFunctionalTests.cs b/test/NetEscapades.AspNetCore.SecurityHeaders.Test/HttpsSecurityHeadersMiddlewareFunctionalTests.cs index 15cad76..fb89769 100644 --- a/test/NetEscapades.AspNetCore.SecurityHeaders.Test/HttpsSecurityHeadersMiddlewareFunctionalTests.cs +++ b/test/NetEscapades.AspNetCore.SecurityHeaders.Test/HttpsSecurityHeadersMiddlewareFunctionalTests.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using FluentAssertions; using NetEscapades.AspNetCore.SecurityHeaders.Headers; +using NetEscapades.AspNetCore.SecurityHeaders.Test; using Xunit; @@ -11,6 +12,7 @@ namespace NetEscapades.AspNetCore.SecurityHeaders.Infrastructure; public class HttpsSecurityHeadersMiddlewareFunctionalTests : IClassFixture> { + private const string EchoPath = "/SecurityHeadersMiddleware/BG36A632-C4D2-4B71-B2BD-18625ADDA87F"; public HttpsSecurityHeadersMiddlewareFunctionalTests(HttpsSecurityHeadersTestFixture fixture) { Client = fixture.Client; @@ -19,14 +21,14 @@ public HttpsSecurityHeadersMiddlewareFunctionalTests(HttpsSecurityHeadersTestFix public HttpClient Client { get; } [Theory] - [InlineData("GET")] - [InlineData("HEAD")] - [InlineData("POST")] - [InlineData("PUT")] - public async Task AllMethods_AddSecurityHeaders_IncludingStrict(string method) + [InlineData("GET", EchoPath)] + [InlineData("HEAD", EchoPath)] + [InlineData("POST", EchoPath)] + [InlineData("PUT", EchoPath)] + [InlineData("GET", "/api/index")] + public async Task AllMethods_AddSecurityHeaders_IncludingStrict(string method, string path) { // Arrange - var path = "/SecurityHeadersMiddleware/BG36A632-C4D2-4B71-B2BD-18625ADDA87F"; var request = new HttpRequestMessage(new HttpMethod(method), path); // Act @@ -38,17 +40,27 @@ public async Task AllMethods_AddSecurityHeaders_IncludingStrict(string method) Assert.Equal(path, content); var responseHeaders = response.Headers; - var header = response.Headers.GetValues("X-Content-Type-Options").FirstOrDefault(); - header.Should().Be("nosniff"); - header = response.Headers.GetValues("X-Frame-Options").FirstOrDefault(); - header.Should().Be("DENY"); - header = response.Headers.GetValues("Referrer-Policy").FirstOrDefault(); - header.Should().Be("strict-origin-when-cross-origin"); - header = response.Headers.GetValues("Strict-Transport-Security").FirstOrDefault(); - header.Should().Be($"max-age={StrictTransportSecurityHeader.OneYearInSeconds}"); - header = response.Headers.GetValues("Content-Security-Policy").FirstOrDefault(); - header.Should().Be("object-src 'none'; form-action 'self'; frame-ancestors 'none'"); - + responseHeaders.AssertSecureRequestDefaultSecurityHeaders(); responseHeaders.Should().NotContain(x => x.Key == "Server"); } + + [Theory] + [InlineData("/custom", "Hello World!")] + [InlineData("/api/custom", "Hello Controller!")] + public async Task WhenUsingEndpoint_Overrides_Default(string path, string expected) + { + // Arrange + // Act + var response = await Client.GetAsync(path); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var content = await response.Content.ReadAsStringAsync(); + content.Should().Be(expected); + + // no security headers + response.Headers.Should().NotContain("X-Frame-Options"); + response.Headers.TryGetValues("Custom-Header", out var customHeader).Should().BeTrue(); + customHeader.Should().ContainSingle("MyValue"); + } } \ No newline at end of file diff --git a/test/NetEscapades.AspNetCore.SecurityHeaders.Test/SecurityHeadersMiddlewareTests.cs b/test/NetEscapades.AspNetCore.SecurityHeaders.Test/SecurityHeadersMiddlewareTests.cs index 361d9ce..06d65b0 100644 --- a/test/NetEscapades.AspNetCore.SecurityHeaders.Test/SecurityHeadersMiddlewareTests.cs +++ b/test/NetEscapades.AspNetCore.SecurityHeaders.Test/SecurityHeadersMiddlewareTests.cs @@ -1,13 +1,12 @@ using System; using System.Linq; -using System.Net.Http.Headers; using System.Threading.Tasks; using FluentAssertions; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.TestHost; -using NetEscapades.AspNetCore.SecurityHeaders.Headers; +using Microsoft.Extensions.DependencyInjection; using Xunit; namespace NetEscapades.AspNetCore.SecurityHeaders.Test; @@ -42,7 +41,7 @@ public async Task HttpRequest_WithDefaultSecurityHeaders_SetsSecurityHeaders() response.EnsureSuccessStatusCode(); (await response.Content.ReadAsStringAsync()).Should().Be("Test response"); - AssertHttpRequestDefaultSecurityHeaders(response.Headers); + response.Headers.AssertHttpRequestDefaultSecurityHeaders(); } } @@ -72,7 +71,135 @@ public async Task HttpRequest_WithDefaultSecurityHeadersUsingConfigureAction_Set response.EnsureSuccessStatusCode(); (await response.Content.ReadAsStringAsync()).Should().Be("Test response"); - AssertHttpRequestDefaultSecurityHeaders(response.Headers); + response.Headers.AssertHttpRequestDefaultSecurityHeaders(); + } + } + + [Fact] + public async Task HttpRequest_WithDefaultSecurityHeaders_WithNoExtraConfiguration_SetsSecurityHeaders() + { + // Arrange + var hostBuilder = new WebHostBuilder() + .Configure(app => + { + app.UseSecurityHeaders(); + app.Run(async context => + { + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync("Test response"); + }); + }); + + using (var server = new TestServer(hostBuilder)) + { + // Act + // Actual request. + var response = await server.CreateRequest("/") + .SendAsync("PUT"); + + // Assert + response.EnsureSuccessStatusCode(); + + (await response.Content.ReadAsStringAsync()).Should().Be("Test response"); + response.Headers.AssertHttpRequestDefaultSecurityHeaders(); + } + } + + [Fact] + public async Task HttpRequest_WithDefaultSecurityHeaders_WithNamedPolicy_SetsSecurityHeaders() + { + var policyName = "default"; + + // Arrange + var hostBuilder = new WebHostBuilder() + .ConfigureServices(s => s.AddSecurityHeaderPolicies() + .AddPolicy(policyName, p => p.AddDefaultSecurityHeaders())) + .Configure(app => + { + app.UseSecurityHeaders(policyName); + app.Run(async context => + { + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync("Test response"); + }); + }); + + using (var server = new TestServer(hostBuilder)) + { + // Act + // Actual request. + var response = await server.CreateRequest("/") + .SendAsync("PUT"); + + // Assert + response.EnsureSuccessStatusCode(); + + (await response.Content.ReadAsStringAsync()).Should().Be("Test response"); + response.Headers.AssertHttpRequestDefaultSecurityHeaders(); + } + } + + [Fact] + public async Task HttpRequest_WithDefaultSecurityHeaders_WithUnknownNamedPolicy_DoesNotSetHeaders() + { + // Arrange + var hostBuilder = new WebHostBuilder() + .Configure(app => + { + app.UseSecurityHeaders("Unknown name"); + app.Run(async context => + { + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync("Test response"); + }); + }); + + using (var server = new TestServer(hostBuilder)) + { + // Act + // Actual request. + var response = await server.CreateRequest("/") + .SendAsync("PUT"); + + // Assert + response.EnsureSuccessStatusCode(); + + (await response.Content.ReadAsStringAsync()).Should().Be("Test response"); + response.Headers.TryGetValues("X-Frame-Options", out _).Should().BeFalse(); + } + } + + [Fact] + public async Task HttpRequest_WithDefaultSecurityHeaders_WithConfiguredDefaultPolicy_SetsCustomHeaders() + { + // Arrange + var hostBuilder = new WebHostBuilder() + .ConfigureServices(s => s.AddSecurityHeaderPolicies() + .SetDefaultPolicy(p => p.AddCustomHeader("Custom-Value", "MyValue"))) + .Configure(app => + { + app.UseSecurityHeaders(); + app.Run(async context => + { + context.Response.ContentType = "text/html"; + await context.Response.WriteAsync("Test response"); + }); + }); + + using (var server = new TestServer(hostBuilder)) + { + // Act + // Actual request. + var response = await server.CreateRequest("/") + .SendAsync("PUT"); + + // Assert + response.EnsureSuccessStatusCode(); + + (await response.Content.ReadAsStringAsync()).Should().Be("Test response"); + response.Headers.TryGetValues("X-Frame-Options", out _).Should().BeFalse(); + response.Headers.TryGetValues("Custom-Value", out var h).Should().BeTrue(); + h.Should().ContainSingle("MyValue"); } } @@ -106,7 +233,7 @@ public async Task SecureRequest_WithDefaultSecurityHeaders_WhenNotOnLocalhost_Se response.EnsureSuccessStatusCode(); (await response.Content.ReadAsStringAsync()).Should().Be("Test response"); - AssertSecureRequestDefaultSecurityHeaders(response.Headers); + response.Headers.AssertSecureRequestDefaultSecurityHeaders(); } } @@ -1835,38 +1962,4 @@ public async Task HttpRequest_WithReportingEndpoints_SetsHeader() header.Should().Be("default=\"https://localhost:5000/default\", endpoint-1=\"http://localhost/endpoint-1\""); } } - - private static void AssertHttpRequestDefaultSecurityHeaders(HttpResponseHeaders headers) - { - string header = headers.GetValues("X-Content-Type-Options").FirstOrDefault()!; - header.Should().Be("nosniff"); - header = headers.GetValues("X-Frame-Options").FirstOrDefault()!; - header.Should().Be("DENY"); - header = headers.GetValues("Referrer-Policy").FirstOrDefault()!; - header.Should().Be("strict-origin-when-cross-origin"); - header = headers.GetValues("Content-Security-Policy").FirstOrDefault()!; - header.Should().Be("object-src 'none'; form-action 'self'; frame-ancestors 'none'"); - - Assert.False(headers.Contains("Server"), - "Should not contain server header"); - Assert.False(headers.Contains("Strict-Transport-Security"), - "Should not contain Strict-Transport-Security header over http"); - } - - private static void AssertSecureRequestDefaultSecurityHeaders(HttpResponseHeaders headers) - { - string header = headers.GetValues("X-Content-Type-Options").FirstOrDefault()!; - header.Should().Be("nosniff"); - header = headers.GetValues("X-Frame-Options").FirstOrDefault()!; - header.Should().Be("DENY"); - header = headers.GetValues("Strict-Transport-Security").FirstOrDefault()!; - header.Should().Be($"max-age={StrictTransportSecurityHeader.OneYearInSeconds}"); - header = headers.GetValues("Referrer-Policy").FirstOrDefault()!; - header.Should().Be("strict-origin-when-cross-origin"); - header = headers.GetValues("Content-Security-Policy").FirstOrDefault()!; - header.Should().Be("object-src 'none'; form-action 'self'; frame-ancestors 'none'"); - - Assert.False(headers.Contains("Server"), - "Should not contain server header"); - } } \ No newline at end of file diff --git a/test/RazorWebSite/Startup.cs b/test/RazorWebSite/Startup.cs index 8b3fdbf..721e4b9 100644 --- a/test/RazorWebSite/Startup.cs +++ b/test/RazorWebSite/Startup.cs @@ -1,7 +1,9 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; +using NetEscapades.AspNetCore.SecurityHeaders; namespace RazorWebSite; @@ -11,6 +13,11 @@ public class Startup public void ConfigureServices(IServiceCollection services) { services.AddMvc(); + services.AddSecurityHeaderPolicies() + .AddPolicy("CustomHeader", policy => + { + policy.AddCustomHeader("Custom-Header", "MyValue"); + }); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -45,12 +52,16 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env) builder.AddStyleSrc().From("*").WithHashTagHelper().UnsafeHashes(); }) .RemoveServerHeader(); - - app.UseSecurityHeaders(policyCollection); app.UseStaticFiles(); app.UseRouting(); - app.UseEndpoints(endpoints => { endpoints.MapRazorPages(); }); + app.UseSecurityHeaders(policyCollection); + app.UseEndpoints(endpoints => + { + endpoints.MapRazorPages(); + endpoints.MapGet("/api", context => context.Response.WriteAsync("ping-pong")) + .WithSecurityHeadersPolicy("CustomHeader"); + }); } } \ No newline at end of file diff --git a/test/SecurityHeadersMiddlewareWebSite/EchoMiddleware.cs b/test/SecurityHeadersMiddlewareWebSite/EchoMiddleware.cs deleted file mode 100644 index 31c97dc..0000000 --- a/test/SecurityHeadersMiddlewareWebSite/EchoMiddleware.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Text; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; - -namespace SecurityHeadersMiddlewareWebSite; - -public class EchoMiddleware -{ - /// - /// Instantiates a new . - /// - /// The next middleware in the pipeline. - public EchoMiddleware(RequestDelegate next) - { - } - - /// - /// Echo the request's path in the response. Does not invoke later middleware in the pipeline. - /// - /// The of the current request. - /// A that completes when writing to the response is done. - public Task Invoke(HttpContext context) - { - if (context == null) - { - throw new ArgumentNullException(nameof(context)); - } - - context.Response.ContentType = "text/html; charset=utf-8"; - var path = context.Request.PathBase + context.Request.Path + context.Request.QueryString; - return context.Response.WriteAsync(path, Encoding.UTF8); - } -} \ No newline at end of file diff --git a/test/SecurityHeadersMiddlewareWebSite/HomeController.cs b/test/SecurityHeadersMiddlewareWebSite/HomeController.cs new file mode 100644 index 0000000..10f9d19 --- /dev/null +++ b/test/SecurityHeadersMiddlewareWebSite/HomeController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; +using NetEscapades.AspNetCore.SecurityHeaders; + +namespace SecurityHeadersMiddlewareWebSite; + +public class HomeController : ControllerBase +{ + [HttpGet("/api/index")] + public IActionResult Index() + { + return Content("/api/index", "text/html"); + } + + [HttpGet("/api/custom"), SecurityHeadersPolicy("CustomHeader")] + public string Custom() => "Hello Controller!"; +} \ No newline at end of file diff --git a/test/SecurityHeadersMiddlewareWebSite/Project_Readme.html b/test/SecurityHeadersMiddlewareWebSite/Project_Readme.html deleted file mode 100644 index bddf864..0000000 --- a/test/SecurityHeadersMiddlewareWebSite/Project_Readme.html +++ /dev/null @@ -1,187 +0,0 @@ - - - - - Welcome to ASP.NET Core - - - - - - - - - - diff --git a/test/SecurityHeadersMiddlewareWebSite/Startup.cs b/test/SecurityHeadersMiddlewareWebSite/Startup.cs index 4e56221..6312e21 100644 --- a/test/SecurityHeadersMiddlewareWebSite/Startup.cs +++ b/test/SecurityHeadersMiddlewareWebSite/Startup.cs @@ -1,6 +1,9 @@ -using Microsoft.AspNetCore.Builder; +using System.Text; +using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; +using NetEscapades.AspNetCore.SecurityHeaders; namespace SecurityHeadersMiddlewareWebSite; @@ -8,12 +11,33 @@ public class Startup { public void ConfigureServices(IServiceCollection services) { + services.AddControllers(); + services.AddSecurityHeaderPolicies() + .AddPolicy("CustomHeader", policy => + { + policy.AddCustomHeader("Custom-Header", "MyValue"); + }); } public void Configure(IApplicationBuilder app) { app.UseSecurityHeaders(); - app.UseMiddleware(); + app.UseRouting(); + app.UseSecurityHeaders(); + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/custom", context => context.Response.WriteAsync("Hello World!")) + .WithSecurityHeadersPolicy("CustomHeader"); + + endpoints.MapFallback(context => + { + context.Response.ContentType = "text/html; charset=utf-8"; + var path = context.Request.PathBase + context.Request.Path + context.Request.QueryString; + return context.Response.WriteAsync(path, Encoding.UTF8); + }); + + endpoints.MapControllers(); + }); } public static void Main(string[] args)