Security Headers and Hardening

Content-Security-Policy, nosniff, frame options, dropping the Server header, and auditing dependencies as routine work.

Syntaxcontext.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

Example · csharp
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.

Example · csharp
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.

Example · xml
<!-- 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.

Example · bash
# 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

  • Be the first to comment on this lesson.