OWASP A02: Security Misconfiguration — Error Pages, OpenAPI, Headers and XXE
Same code, wrong settings: a developer exception page enabled unconditionally in Production dumps a stack trace, and a forgotten MapOpenApi line hands the whole API map to a customer. We fix it with ProblemDetails, per-environment configuration, AllowedHosts, a disabled Server header, a small middleware for CSP and nosniff and a look at why HSTS doesn't show up on localhost, then build a hardened XmlReader that rejects an XML document containing a DTD.
Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).
Guess First
This time we run the lab in the Production environment. When someone who isn't logged in calls an endpoint that throws, what do they see in the response? What does Alan, an ordinary customer, get from /openapi/v1.json? Which of the response headers tells the browser a security rule, and which one only gives away unnecessary information?
This post is about cases where the code is right but the settings are wrong. Again, we only work against the lab on your own machine; only try these techniques on systems you own or have written permission to test.
Misconfiguration: Right Code, Wrong Settings
A02:2025 Security Misconfiguration moved up from fifth place in 2021 to second and groups 16 CWEs. Since 2021 the category also covers XXE (CWE-611); in 2017 XXE was a category of its own, A4. The common thread: a setting, a default or a feature left switched on in the framework or the server gives an attacker information or a way in, regardless of the code you wrote.
ASP.NET Core gets most of this right: the developer exception page turns itself on only in Development, the Web API template puts the OpenAPI document inside a Development check, and the web app templates add HSTS only outside Development. The problem is that a single line can undo each of these safe defaults, and most of those lines are added "for now" and then forgotten.
A Talkative Error Page in Production
I brought back the deliberately broken endpoint from the second post: /books/3/summary accesses the Body of a null review for the third book, which has no reviews yet. Now imagine these lines were added to look into a bug in Production and stayed there:
builder.Services.AddOpenApi();
var app = builder.Build();
// Added to look into a bug in Production, then forgotten
app.UseDeveloperExceptionPage();
app.MapOpenApi();
In this post the http profile is on 5110 and the https profile on 5111. Let's start the app in the Production environment and send a request without logging in:
$ dotnet run --launch-profile https -e ASPNETCORE_ENVIRONMENT=Production
...
$ curl -i https://localhost:5111/books/3/summary
HTTP/2 500
content-type: text/plain; charset=utf-8
date: Fri, 06 Nov 2026 19:30:44 GMT
server: Kestrel
System.NullReferenceException: Object reference not set to an instance of an object.
at Program.<>c.<<<Main>$>b__0_12>d.MoveNext() in /.../Program.cs:line 153
--- End of stack trace from previous location ---
at Microsoft.AspNetCore.Http.RequestDelegateFactory.ExecuteTaskResult[T](Task`1 task, HttpContext httpContext)
at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
HEADERS
=======
Accept: */*
Host: localhost:5111
User-Agent: curl/8.7.1
The environment is Production, yet all the detail we saw in Development in the second post is back: the exception type, the file path, the line number, the middleware order and the request headers. The moment you call UseDeveloperExceptionPage() yourself, the environment check is gone. The fix is to put the environment split back in the code and give Production a handler that reports the error without the details:
builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
else
{
app.UseExceptionHandler();
app.UseHsts();
}
app.UseHttpsRedirection();
$ curl -i https://localhost:5111/books/3/summary
HTTP/2 500
content-type: application/problem+json
date: Fri, 06 Nov 2026 19:31:26 GMT
cache-control: no-cache,no-store
expires: -1
pragma: no-cache
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"An error occurred while processing your request.","status":500,"traceId":"00-b32969c9f12da17a6bd1af9e5637b1c3-b4d22578e884e87e-00"}
The client only sees that an error happened, plus a traceId. The stack trace is in the server log, searchable by the same trace id. Calling UseExceptionHandler() without arguments requires AddProblemDetails(). The rest of error handling, IExceptionHandler and failing closed, is the topic of the A10 post.
Unneeded Endpoints: An OpenAPI Document Left Open
Since .NET 9 the Web API template ships with the Microsoft.AspNetCore.OpenApi package instead of Swashbuckle; I added version 10.0.14 to the lab. What does the forgotten MapOpenApi() line above publish in Production? Thanks to the fallback policy, someone who isn't logged in gets a 401, but Alan's cookie is enough:
$ curl -s -b alan.txt https://localhost:5111/openapi/v1.json | grep '^ "/'
"/login": {
"/books": {
"/orders/{id}": {
"/admin/users": {
"/me": {
"/downloads": {
"/books/{id}/cover": {
"/books/{id}/summary": {
"/orders/import": {
This is the whole map of the API: admin endpoints, parameter types and the fields of the returned models. Even if we protected these endpoints properly in the earlier posts, we're doing the attacker's reconnaissance for free. The fallback policy helping here is also luck; if everything were anonymous, as in the first posts, the document would be public. In the fixed version, MapOpenApi() only runs in Development:
$ curl -i -b alan.txt https://localhost:5111/openapi/v1.json
HTTP/2 404
date: Fri, 06 Nov 2026 19:31:26 GMT
content-length: 0
...
The same logic applies to Swagger UI, health check details, helper endpoints like /debug and diagnostic pages that dump configuration. If Production really needs one, protect it with its own policy; if not, don't map it at all.
Defaults and Per-Environment Configuration
The first responses above had one more line: server: Kestrel. It isn't a vulnerability on its own, but it tells the other side which server you run for free. In Kestrel it's one line to turn off:
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
A more important default is "AllowedHosts": "*" in appsettings.json. The host filtering middleware compares the request's Host header with this list, and the asterisk accepts any value. If the application builds absolute URLs from the Host header (a password reset link, for example), whoever sends the request decides that value. For Production we write the real domain in a separate file; localhost is on the list only for the lab:
{
"AllowedHosts": "bookstore.test;localhost"
}
$ curl -i https://localhost:5111/books -H "Host: other.test"
HTTP/2 400
content-type: text/html
date: Fri, 06 Nov 2026 19:31:26 GMT
content-length: 334
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN""http://www.w3.org/TR/html4/strict.dtd">
<HTML><HEAD><TITLE>Bad Request</TITLE>
<META HTTP-EQUIV="Content-Type" Content="text/html; charset=us-ascii"></ HEAD >
<BODY><h2>Bad Request - Invalid Hostname</h2>
<hr><p>HTTP Error 400. The request hostname is invalid.</p>
</BODY></HTML>
That file is appsettings.Production.json. ASP.NET Core reads configuration in layers: first appsettings.json, then appsettings.{Environment}.json, user secrets in Development, then environment variables and the command line. Later sources override earlier ones. This layering is the natural way to manage security settings per environment: shared values in the main file, strict Production-only values in their own file, secrets in no file at all. We'll talk about secret management separately in the A04 posts.
One default sits in our own lab: Initialize() creates the user ada with the password dev-only-ada in every environment. That's fine for the lab, but in a real project sample data belongs only in Development; an admin account that ships with a known password is the very definition of default credentials.
A Small Middleware for Security Headers
Some protections are enforced by the browser, not the server; the server's job is to send the right header. ASP.NET Core has a built-in middleware for HSTS, but none for CSP and the others. A few lines of middleware are enough:
app.Use(async (context, next) =>
{
// Headers are added right before the response goes out, error responses included
context.Response.OnStarting(() =>
{
IHeaderDictionary headers = context.Response.Headers;
// The API produces no HTML: load no resources, let no page frame us
headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'";
headers["X-Content-Type-Options"] = "nosniff";
headers["Referrer-Policy"] = "no-referrer";
return Task.CompletedTask;
});
await next(context);
});
Since the Bookstore is a JSON API, its CSP is as strict as it gets: default-src 'none' says that if a response somehow opens in the browser as a document, no script or resource loads, and frame-ancestors 'none' says no page may embed the API in an iframe. Older browsers used X-Frame-Options: DENY for the same job. nosniff stops the browser from guessing the Content-Type and treating JSON as script or HTML. An application that produces HTML, such as the site serving an Angular app, needs a much more detailed CSP; we'll cover that in the XSS post.
I use OnStarting for a reason. In my first attempt I added the headers directly before next; they showed up on 200 responses, but all three were missing from the 500 ProblemDetails response, because the exception handler clears the response before writing the error. OnStarting runs right before the response is sent, so the headers survive on error responses too. For HSTS I tightened the built-in settings; the default max-age is 30 days:
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(365);
options.IncludeSubDomains = true;
});
$ curl -i https://localhost:5111/books
HTTP/2 200
content-type: application/json; charset=utf-8
date: Fri, 06 Nov 2026 19:31:26 GMT
content-security-policy: default-src 'none'; frame-ancestors 'none'
x-content-type-options: nosniff
referrer-policy: no-referrer
...
$ curl -i https://localhost:5111/books -H "Host: bookstore.test"
HTTP/2 200
content-type: application/json; charset=utf-8
date: Fri, 06 Nov 2026 19:31:26 GMT
strict-transport-security: max-age=31536000; includeSubDomains
content-security-policy: default-src 'none'; frame-ancestors 'none'
x-content-type-options: nosniff
referrer-policy: no-referrer
...
The first request has no HSTS, and that's not a bug: UseHsts's ExcludedHosts list contains localhost by default, because once the browser sees this header it enforces HTTPS for that host for months and can break other projects on your development machine. To see the header I sent the Production domain as the Host. HSTS only means something on HTTPS responses; a plain HTTP request is redirected to HTTPS with a 307 by UseHttpsRedirection. Before turning on IncludeSubDomains, make sure every subdomain supports HTTPS; getting onto the browsers' HSTS preload list is a decision that's hard to undo.
XXE: Closing the Door on DTDs When Reading XML
We're adding an import endpoint to the Bookstore that accepts orders from a legacy system as XML. One feature of XML is that a DTD (Document Type Definition) at the top of the document can define entities. An entity stands in for some other text inside the document. An external entity takes its content from a file path or a URL. If the parser is set up to resolve external entities, an attacker can send a document that makes the server read a file and put it in the response, or make the server send a request to an internal address; this is XXE (XML External Entity). Nested entities can also blow a small document up into a huge string in memory (CWE-776). These are the two dangerous settings:
XmlReaderSettings settings = new()
{
// Process DTDs and let it resolve external resources: the two conditions for XXE
DtdProcessing = DtdProcessing.Parse,
XmlResolver = new XmlUrlResolver()
};
In modern .NET the default of XmlReaderSettings.DtdProcessing is Prohibit; print new XmlReaderSettings().DtdProcessing and you'll see it. So someone has to write this code on purpose, usually after a complaint along the lines of "a file with a DTD came in and it failed". The right endpoint states the default explicitly and returns a generic error:
app.MapPost("/orders/import", async (HttpRequest request, ILogger<Program> logger) =>
{
XmlReaderSettings settings = new()
{
// Prohibit is already the default; writing it out documents the intent
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null,
Async = true
};
try
{
using XmlReader reader = XmlReader.Create(request.Body, settings);
int imported = 0;
while (await reader.ReadAsync())
{
if (reader.NodeType == XmlNodeType.Element && reader.Name == "order")
{
imported++;
}
}
return Results.Ok(new { imported });
}
catch (XmlException ex)
{
// Details go to the log, the client gets a generic message
logger.LogWarning(ex, "Rejected XML import");
return Results.Problem("The XML document is not valid.", statusCode: StatusCodes.Status400BadRequest);
}
}).RequireAuthorization("AdminOnly");
Async = true and ReadAsync are required, because Kestrel doesn't allow synchronous reads of the request body by default. I prepared two files to try it. orders.xml is a plain list of orders. orders-dtd.xml adds a one-line, completely harmless DTD to the top of the same document; there's no entity in it at all:
<?xml version="1.0"?>
<!DOCTYPE orders [
<!ELEMENT orders ANY>
]>
<orders>
<order book="1" quantity="2" />
</orders>
$ curl -i -b ada.txt -X POST https://localhost:5111/orders/import -H "Content-Type: application/xml" --data-binary @orders.xml
HTTP/2 200
content-type: application/json; charset=utf-8
date: Fri, 06 Nov 2026 19:31:39 GMT
...
{"imported":2}
$ curl -i -b ada.txt -X POST https://localhost:5111/orders/import -H "Content-Type: application/xml" --data-binary @orders-dtd.xml
HTTP/2 400
content-type: application/problem+json
date: Fri, 06 Nov 2026 19:31:39 GMT
...
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"The XML document is not valid.","traceId":"00-3ea3577d1ed6f4e721f372865e3cf03e-392697a2dc62bdf6-00"}
The real reason is in the server log:
warn: Program[0]
Rejected XML import
System.Xml.XmlException: For security reasons DTD is prohibited in this XML document. To enable DTD processing set the DtdProcessing property on XmlReaderSettings to Parse and pass the settings into XmlReader.Create method.
Rejecting a harmless DTD is deliberate: the parser stops as soon as it sees a DOCTYPE, without looking at whether there are entities in it. An order import doesn't need a DTD. If you use XDocument, XmlDocument or XmlSerializer, creating your own XmlReader with the same settings and passing it to them is safer than guessing which defaults they run with.
Common Pitfalls
The most common one is temporarily switching the server to Development, or adding UseDeveloperExceptionPage() unconditionally, to understand a bug in Production. The right way is to read the log; that's what the traceId in ProblemDetails is for.
The second is sending security headers only on successful responses. As we saw above, the exception handler can clear the response; check your headers on an error response too. Also decide whether headers are added in the reverse proxy or in the application; add them in both places and the response carries two CSPs, the browser enforces both, and things you didn't expect get blocked.
The third is testing a setting in only one environment. HSTS not showing up on localhost, AllowedHosts being an asterisk in Development and OpenAPI being open only in Development are all correct behavior; but to see how Production behaves you need to run the app at least once with ASPNETCORE_ENVIRONMENT=Production and check the responses with your own eyes.
Checklist
Are the developer exception page and the OpenAPI document available only in Development, and do errors come back as ProblemDetails in Production? Does AllowedHosts list the real domain names in Production, and is the Server header off? Does every response, error responses included, carry CSP, X-Content-Type-Options and Referrer-Policy, and does HSTS come with the right max-age on HTTPS responses? Are sample users and known passwords loaded only in Development? Is the DTD off and the resolver absent wherever you read XML?
A Small Experiment
★☆☆ Run the lab in Development and in Production and put the headers of the /books/3/summary, /openapi/v1.json and /books responses side by side. Which differences come from code and which from configuration?
★★☆ Move the header middleware into an extension method called UseSecurityHeaders(), read the header values from appsettings.json and add a Permissions-Policy to the list.
★★★ Add a size limit to the XML import: limit the document size with XmlReaderSettings.MaxCharactersInDocument and apply RequestSizeLimit to the endpoint. What's the difference between the two, and which one kicks in earlier?
In the next post we move on to A03:2025 Software Supply Chain Failures: the risks that arrive through the packages you use and your build and delivery chain, however clean your own code is; NuGetAudit, npm audit, lock files and SBOMs.