Security Headers and Hardening
Content-Security-Policy, nosniff, frame options, dropping the Server header, and auditing dependencies as routine work.
context.Response.Headers.ContentSecurityPolicy = "default-src 'self'";Some protections are not code in your app at all. They are response headers that tell the browser to be stricter than its defaults. A short piece of middleware can add them to every response.
Content-Security-Policy
CSP lists where a page may load scripts, styles, images and frames from. default-src 'self' means "this origin only", which stops an injected <script src> from reaching an attacker's server. Inline script does not run under that policy either, so a CSP is only worth having if the app has no inline script left. Roll it out with Content-Security-Policy-Report-Only first: the browser reports violations and blocks nothing, so you find your own inline handlers before your users do.
The short ones
X-Content-Type-Options: nosniff tells the browser to believe the declared Content-Type instead of guessing from the bytes, so an uploaded text file cannot be treated as HTML. frame-ancestors 'none' in the CSP, with X-Frame-Options: DENY beside it for older browsers, stops your pages being framed by another site. Referrer-Policy: no-referrer keeps your URLs out of other servers' logs.
Kestrel sends Server: Kestrel by default. Set AddServerHeader to false. It is a small thing: it removes a free hint about what to attack, nothing more.
Dependencies
Since the .NET 8 SDK, dotnet restore audits packages against known advisories and warns, and projects targeting net10.0 audit transitive packages too. Make those warnings visible in CI and act on them.
Example
var builder = WebApplication.CreateBuilder(args);
// Kestrel announces itself in every response. Stop it.
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
var app = builder.Build();
// First in the pipeline, so the headers are set even on an error response.
app.Use(async (context, next) =>
{
IHeaderDictionary headers = context.Response.Headers;
// Scripts, styles and images from this origin only. No framing, no plugins.
headers.ContentSecurityPolicy =
"default-src 'self'; frame-ancestors 'none'; object-src 'none'";
// Believe the Content-Type; do not guess from the bytes.
headers.XContentTypeOptions = "nosniff";
// The older header, for browsers that ignore frame-ancestors.
headers.XFrameOptions = "DENY";
// Longhand: the same thing through the indexer, used here because there is
// no strongly typed property for Referrer-Policy.
headers["Referrer-Policy"] = "no-referrer";
await next(context);
});
app.MapGet("/", () => Results.Content("<p>hello</p>", "text/html"));
app.Run();When to use it
- Add a content security policy to an existing site without breaking its pages.
- Stop a partner site from embedding your admin pages in an iframe.
- Find out whether a newly published advisory affects a service already in production.
More examples
Rolling out a CSP without breaking the site
Report-only mode is the difference between a rollout and an outage. Leave it on long enough to cover real traffic, read the reports, remove the inline script and the third-party embeds they name, then change the one property. Rate-limit or sample /csp-report: a popular page with a violation generates a report per view.
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// Stage one: report, do not block. The browser sends a POST to /csp-report for
// every violation and renders the page exactly as before.
const string Policy =
"default-src 'self'; script-src 'self'; style-src 'self'; " +
"frame-ancestors 'none'; report-uri /csp-report";
app.Use(async (context, next) =>
{
// Swap this property for ContentSecurityPolicy once the reports stop.
context.Response.Headers.ContentSecurityPolicyReportOnly = Policy;
await next(context);
});
app.MapPost("/csp-report", async (HttpContext http, ILogger<Program> logger) =>
{
using var reader = new StreamReader(http.Request.Body);
logger.LogWarning("CSP violation: {Report}", await reader.ReadToEndAsync());
return Results.NoContent();
});
app.Run();Auditing dependencies as part of the build
Advisories are published after you ship, so a package that was clean last week can be vulnerable today with no change on your side. This splits the difference: a dedicated pipeline run with -p:AuditPipeline=true fails on a vulnerability, while ordinary builds only warn. Reach for it as soon as a repository has more than one contributor.
<!-- Directory.Build.props at the repository root: applies to every project. -->
<Project>
<PropertyGroup>
<!-- On by default since the .NET 8 SDK; stated here so it is not lost. -->
<NuGetAudit>true</NuGetAudit>
<!-- Direct and transitive packages. This is already the default when the
project targets net10.0 or later. -->
<NuGetAuditMode>all</NuGetAuditMode>
<!-- Report everything from low severity upward. -->
<NuGetAuditLevel>low</NuGetAuditLevel>
<!-- A new advisory should not break yesterday's build on a developer
machine, so audit warnings stay warnings outside the audit pipeline. -->
<NuGetAuditCodes>NU1901;NU1902;NU1903;NU1904</NuGetAuditCodes>
<WarningsAsErrors Condition=" '$(AuditPipeline)' == 'true' ">$(WarningsAsErrors);$(NuGetAuditCodes)</WarningsAsErrors>
<WarningsNotAsErrors Condition=" '$(AuditPipeline)' != 'true' ">$(WarningsNotAsErrors);$(NuGetAuditCodes)</WarningsNotAsErrors>
</PropertyGroup>
</Project>Checking what you actually ship
Two separate checks, both worth running before a release. The package commands tell you what is in the build; the curl tells you what the browser is told. Reach for dotnet nuget why when a vulnerability sits in a package you have never heard of: usually one direct dependency needs an upgrade, not the vulnerable package itself.
# Known vulnerabilities, including packages you never referenced directly.
# --include-transitive is not the default, so pass it.
dotnet list package --vulnerable --include-transitive
# .NET 10 SDK spelling of the same command.
dotnet package list --vulnerable --include-transitive
# Why is that package here at all?
dotnet nuget why Payments.Api System.Text.Json
# .NET 10 SDK: move every vulnerable package to the first fixed version.
dotnet package update --vulnerable
# And confirm the response headers on the running app.
curl -sI https://localhost:8443/ | grep -iE 'content-security|nosniff|frame|referrer|server'
Discussion