OWASP A01: Broken Access Control (2) — Path Traversal, CORS and SSRF
A download endpoint that takes a file name from the client, a CORS policy that echoes back every origin and cover-download code that requests whatever URL it is given. We examine the three vulnerable patterns and close them with a canonical path check, a policy that lists origins one by one and an HttpClient with an allowlist, disabled redirects and an IP check in ConnectCallback, then watch the fixed code reject bad input in the lab.
Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).
Guess First
We're adding an endpoint to the Bookstore API that downloads sample chapters: GET /downloads?name=clean-code-sample.txt. The server appends the incoming file name to the path of the downloads folder and reads the file. If the name parameter is ../appsettings.json, which file comes back? Second question: an admin gives the server a URL and has it download a book's cover image. If that URL points to an address on the server's own network, who actually sent the request?
This post is the second half of A01:2025 Broken Access Control. Today's three topics come down to the same question: a value supplied by the client opens a door to something the server can reach but the client shouldn't. In order: the file system, the browser's same-origin policy and the server's network.
I'll show the vulnerable patterns as code and explain why they're dangerous; the requests we run against the lab will show how the fixed code rejects bad input. Only try these techniques on systems you own or have written permission to test.
Path Traversal: Trusting a File Name
The lab continues from the previous post's project, this time with the http profile on port 5106. The project folder has a downloads folder with clean-code-sample.txt in it. The first version of the download endpoint that comes to mind looks like this:
app.MapGet("/downloads", (string name, IWebHostEnvironment env) =>
{
// The file name comes from the client and goes straight into the path
string path = Path.Combine(env.ContentRootPath, "downloads", name);
return File.Exists(path) ? Results.File(path, "text/plain") : Results.NotFound();
}).AllowAnonymous();
The code assumes name is always a bare file name. To the operating system, though, .. means "the parent folder": ../appsettings.json points at the application's configuration file, and with enough ../ at any file the application can read. This is path traversal, CWE-22 in the CWE catalog. The process can read those files, the client shouldn't, and the only thing drawing that line is our code.
Path.Combine has one more surprise: if one of the arguments is a rooted path (/etc/hosts, or a value starting with C:\), it drops everything before it. So looking for .. isn't enough. Kestrel normalizes .. segments in the URL path (a request to /x/../books lands on /books), but it doesn't touch the query string or the body.
Canonical Path and a Root Folder Check
The fix: turn the path into what the operating system will finally see, the canonical path, then check that it is still inside the folder you allow.
string downloadsRoot = Path.Join(Path.GetFullPath(Path.Join(app.Environment.ContentRootPath, "downloads")),
Path.DirectorySeparatorChar.ToString());
app.MapGet("/downloads", (string name) =>
{
string fullPath = Path.GetFullPath(Path.Join(downloadsRoot, name));
// If the canonical path is not inside the root folder, don't touch the file system
if (!fullPath.StartsWith(downloadsRoot, StringComparison.Ordinal))
{
return Results.BadRequest();
}
return File.Exists(fullPath) ? Results.File(fullPath, "text/plain") : Results.NotFound();
}).AllowAnonymous();
Path.GetFullPath resolves .. and . segments. Path.Join, unlike Path.Combine, doesn't drop the first argument when the second one is rooted. The trailing separator on the root is deliberate too: without it, a sibling folder like .../downloads-private would pass the check.
$ curl -i "http://localhost:5106/downloads?name=clean-code-sample.txt"
HTTP/1.1 200 OK
Content-Length: 28
Content-Type: text/plain
Date: Fri, 06 Nov 2026 19:18:00 GMT
Server: Kestrel
Last-Modified: Fri, 06 Nov 2026 19:17:20 GMT
Clean Code - sample chapter
$ curl -i "http://localhost:5106/downloads?name=../appsettings.json"
HTTP/1.1 400 Bad Request
Content-Length: 0
Date: Fri, 06 Nov 2026 19:18:00 GMT
Server: Kestrel
$ curl -i "http://localhost:5106/downloads?name=..%2F..%2FProgram.cs"
HTTP/1.1 400 Bad Request
Content-Length: 0
Date: Fri, 06 Nov 2026 19:18:00 GMT
Server: Kestrel
$ curl -i "http://localhost:5106/downloads?name=/etc/hosts"
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Fri, 06 Nov 2026 19:18:00 GMT
Server: Kestrel
The two values containing .. get a 400; the second one is URL-encoded, but ASP.NET Core decodes the query string, so the check sees the final value. /etc/hosts becomes downloads/etc/hosts thanks to Path.Join, and since no such file exists, it's a 404.
The most robust option is to not take a file name from the client at all: take the book id and map it to a file on the server. If you must, reject any value containing a folder with Path.GetFileName(name) == name and put an allowlist on extensions. For static files, the PhysicalFileProvider behind UseStaticFiles already does this; the problem almost always shows up in hand-written download endpoints.
What CORS Does and Doesn't Do
By default the browser won't hand the response of a request sent to another origin (the scheme, host and port triple) to the JavaScript that sent it; this is the same-origin policy. CORS (Cross-Origin Resource Sharing) relaxes the rule at the server's request: with the Access-Control-Allow-Origin header the server says "this origin may read my response". An Angular app on http://localhost:4200 and the API on 5106 are two separate origins because the ports differ.
The trouble usually starts during development, with the words "let's just allow everything". ASP.NET Core blocks the most obvious mistake itself: if you allow every origin and also enable credentials, meaning cookies, the application won't start.
builder.Services.AddCors(options =>
{
// Allow every origin and send cookies too: the two settings can't be combined
options.AddPolicy("Frontend", policy => policy
.AllowAnyOrigin()
.AllowCredentials());
});
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
Hosting failed to start
System.InvalidOperationException: The CORS protocol does not allow specifying a wildcard (any) origin and credentials at the same time. Configure the CORS policy by listing individual origins if credentials needs to be supported.
at Microsoft.AspNetCore.Cors.Infrastructure.CorsPolicyBuilder.Build()
at Microsoft.AspNetCore.Cors.Infrastructure.CorsOptions.AddPolicy(String name, Action`1 configurePolicy)
The restriction comes from the CORS protocol; browsers don't accept * on a credentialed request either. The common "fix" is to work around the rule:
builder.Services.AddCors(options =>
{
// Accept whatever origin arrives and send cookies too
options.AddPolicy("Frontend", policy => policy
.SetIsOriginAllowed(_ => true)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials());
});
This policy echoes back whatever Origin arrives and allows credentials. While a user is logged in to the Bookstore, any site they open in another tab can call the API with their cookie and read the response. A SameSite=Lax cookie prevents this in most cases, because the browser doesn't attach a Lax cookie to a fetch sent from another site. But when the frontend and the API live on different sites, the cookie usually becomes SameSite=None and that protection is gone too.
Setting Up the CORS Policy Correctly
A correct policy lists origins one by one and limits methods and headers to the ones you use:
builder.Services.AddCors(options =>
{
options.AddPolicy("Frontend", policy => policy
.WithOrigins("http://localhost:4200")
.WithMethods("GET", "POST")
.WithHeaders("Content-Type")
.AllowCredentials());
});
var app = builder.Build();
app.UseCors("Frontend");
app.UseAuthentication();
app.UseAuthorization();
In the previous post we didn't write UseAuthentication and UseAuthorization, because WebApplication adds them itself. But the automatically added ones are placed before our UseCors, and the preflight (OPTIONS) request, which the browser sends without cookies, gets caught by the fallback policy. The response before I wrote the three lines:
$ curl -i -X OPTIONS http://localhost:5106/books/1/cover -H "Origin: http://localhost:4200" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type"
HTTP/1.1 401 Unauthorized
Content-Length: 0
Date: Fri, 06 Nov 2026 19:25:23 GMT
Server: Kestrel
With the order written out explicitly, the same preflight returns 204 with the right headers. Now, with Alan's cookie, from two origins, one we trust and one we don't:
$ curl -i -b alan.txt http://localhost:5106/me -H "Origin: http://localhost:4200"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:18:22 GMT
Server: Kestrel
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: http://localhost:4200
Transfer-Encoding: chunked
{"id":"2","name":"alan"}
$ curl -i -b alan.txt http://localhost:5106/me -H "Origin: http://untrusted.test"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:18:22 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"id":"2","name":"alan"}
$ curl -i -X OPTIONS http://localhost:5106/books/1/cover -H "Origin: http://untrusted.test" -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type"
HTTP/1.1 204 No Content
Date: Fri, 06 Nov 2026 19:18:22 GMT
Server: Kestrel
Look closely at the second response: the server processed the request and sent the body; the only thing missing is Access-Control-Allow-Origin. The server doesn't enforce anything here, the browser does. The untrusted origin's preflight also got a 204 without headers; in that case the browser never sends the actual POST. To see this in a real browser, I served a small static page on 5108, sent a fetch to /books from the http://127.0.0.1:5108 origin and ran the page in headless Chrome:
"Access to fetch at 'http://localhost:5106/books' from origin 'http://127.0.0.1:5108' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource."
So CORS is not an authorization mechanism; curl or another server doesn't care about it at all, and the endpoint always needs its own authorization check. CORS also doesn't always stop the request from reaching the server: a simple form POST goes out without a preflight. That is CSRF territory, which we'll cover in the A07 posts.
SSRF: A Request from the Server's Network
The admin wants to fetch a book's cover image from a URL. The first code that comes to mind:
app.MapPost("/books/{id:int}/cover", async (int id, CoverRequest request, IHttpClientFactory httpClientFactory) =>
{
HttpClient http = httpClientFactory.CreateClient();
// Whoever supplied the URL, the server sends the request there
byte[] image = await http.GetByteArrayAsync(request.Url);
return Results.Ok(new { bookId = id, size = image.Length });
}).RequireAuthorization("AdminOnly");
This code gives the client the power to make the server send a request, from the server's network position, to any address it likes. It's called SSRF (Server-Side Request Forgery). In 2021 it was a category of its own, A10; in 2025 it joined A01 as CWE-918, because at its core it's an access control violation too: reaching a resource you can't reach through the hands of a server that can.
The risk lies in addresses the server can see and the internet can't: an admin panel listening only on localhost, an internal service without authentication, or the metadata service cloud providers expose on a link-local address such as 169.254.169.254, which in some environments also returns temporary credentials. I'm not simulating any of these here; what matters is that once you leave the URL to the client, all of them are within reach. Restricting the endpoint to admins only narrows the risk; a compromised admin account walks through the same door.
Looking at the incoming URL isn't enough either. HttpClient follows redirects by default; a 302 from an allowed host can take you to a disallowed address. A host name can also resolve to one IP when you check it and another when you connect (DNS rebinding). That's why the defense has layers.
Hardening HttpClient Against SSRF
We move the download into a typed client called CoverFetcher. The first layer: only absolute https URLs and hosts on the allowlist. The allowlist sits in appsettings.json as "Covers": { "AllowedHosts": [ "images.bookstore.test" ] }.
using System.Net;
using System.Net.Sockets;
public sealed record CoverSettings(string[] AllowedHosts);
public sealed class CoverRejectedException(string message) : Exception(message);
public sealed class CoverFetcher(HttpClient http, CoverSettings settings)
{
public async Task<byte[]> DownloadAsync(string url, CancellationToken cancellationToken)
{
if (!Uri.TryCreate(url, UriKind.Absolute, out Uri? uri) || uri.Scheme != Uri.UriSchemeHttps)
{
throw new CoverRejectedException("Only absolute https URLs are accepted.");
}
if (!settings.AllowedHosts.Contains(uri.IdnHost, StringComparer.OrdinalIgnoreCase))
{
throw new CoverRejectedException($"Host '{uri.IdnHost}' is not on the allowlist.");
}
try
{
using HttpResponseMessage response = await http.GetAsync(uri, cancellationToken);
// Anything other than 200 is rejected, 3xx included; redirects are not followed
if (response.StatusCode != HttpStatusCode.OK)
{
throw new CoverRejectedException($"Upstream returned {(int)response.StatusCode}.");
}
if (response.Content.Headers.ContentType?.MediaType is not ("image/jpeg" or "image/png"))
{
throw new CoverRejectedException("Only JPEG and PNG images are accepted.");
}
return await response.Content.ReadAsByteArrayAsync(cancellationToken);
}
catch (HttpRequestException ex) when (ex.InnerException is CoverRejectedException rejected)
{
throw rejected;
}
}
}
The second layer works at the connection level. HttpClient has no built-in SSRF protection, but SocketsHttpHandler.ConnectCallback lets us open the TCP connection ourselves. The callback resolves the host name, refuses if even one address is in a blocked range, and otherwise connects to exactly the addresses it checked:
public static async ValueTask<Stream> ConnectToPublicAddressAsync(
SocketsHttpConnectionContext context, CancellationToken cancellationToken)
{
IPAddress[] addresses = await Dns.GetHostAddressesAsync(context.DnsEndPoint.Host, cancellationToken);
// If even one address points inside the network, don't connect at all
if (addresses.Length == 0 || addresses.Any(IsBlocked))
{
throw new CoverRejectedException($"Host '{context.DnsEndPoint.Host}' resolves to a non-public address.");
}
Socket socket = new(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
// Connect to the addresses we checked; no second DNS lookup
await socket.ConnectAsync(addresses, context.DnsEndPoint.Port, cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
// Short list for the lab: loopback, private ranges, link-local (cloud metadata address included)
private static readonly IPNetwork[] BlockedNetworks =
[
IPNetwork.Parse("0.0.0.0/8"), IPNetwork.Parse("10.0.0.0/8"), IPNetwork.Parse("100.64.0.0/10"),
IPNetwork.Parse("127.0.0.0/8"), IPNetwork.Parse("169.254.0.0/16"), IPNetwork.Parse("172.16.0.0/12"),
IPNetwork.Parse("192.168.0.0/16"), IPNetwork.Parse("::1/128"), IPNetwork.Parse("fc00::/7"),
IPNetwork.Parse("fe80::/10")
];
private static bool IsBlocked(IPAddress address)
{
if (address.IsIPv4MappedToIPv6)
{
address = address.MapToIPv4();
}
return BlockedNetworks.Any(network => network.Contains(address));
}
Not doing a second DNS lookup closes the rebinding window between the check and the connection. IPNetwork has lived in System.Net since .NET 8; it parses a CIDR range and checks it with Contains. IPv4 addresses embedded in IPv6 are unwrapped with MapToIPv4. The list is short for the lab; in a real project, extend it from IANA's special-purpose address registries. Let's wire the handler into the typed client:
builder.Services.AddSingleton(new CoverSettings(
builder.Configuration.GetSection("Covers:AllowedHosts").Get<string[]>() ?? []));
builder.Services.AddHttpClient<CoverFetcher>(client =>
{
client.Timeout = TimeSpan.FromSeconds(10);
client.MaxResponseContentBufferSize = 2 * 1024 * 1024;
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AllowAutoRedirect = false,
UseProxy = false,
ConnectCallback = CoverFetcher.ConnectToPublicAddressAsync
});
With AllowAutoRedirect = false, a 3xx response comes back as is and DownloadAsync rejects anything other than 200. UseProxy = false makes sure that if a proxy is configured on the system, the callback sees the target and not the proxy. The timeout and the 2 MB limit keep the server from being tied up by a slow or huge response. The endpoint never passes the remote response body on to the client:
app.MapPost("/books/{id:int}/cover", async (int id, CoverRequest request, CoverFetcher covers,
CancellationToken cancellationToken) =>
{
try
{
byte[] image = await covers.DownloadAsync(request.Url, cancellationToken);
return Results.Ok(new { bookId = id, size = image.Length });
}
catch (CoverRejectedException ex)
{
return Results.Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest);
}
catch (HttpRequestException)
{
return Results.Problem("Cover could not be downloaded.", statusCode: StatusCodes.Status502BadGateway);
}
}).RequireAuthorization("AdminOnly");
Let's try internal addresses with Ada's cookie. The first two are stopped by the first layer:
$ curl -i -b ada.txt -X POST http://localhost:5106/books/1/cover -H "Content-Type: application/json" -d '{"url":"http://localhost:5106/admin/users"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Date: Fri, 06 Nov 2026 19:19:17 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"Only absolute https URLs are accepted."}
$ curl -i -b ada.txt -X POST http://localhost:5106/books/1/cover -H "Content-Type: application/json" -d '{"url":"https://localhost:5107/admin/users"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Date: Fri, 06 Nov 2026 19:19:17 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"Host 'localhost' is not on the allowlist."}
To see the second layer, assume someone accidentally added a host that resolves into the internal network to the allowlist, and start the app with localhost added. Configuration merges arrays by index, hence __1; __0 would overwrite the existing value:
$ dotnet run --launch-profile http -e Covers__AllowedHosts__1=localhost
...
$ curl -i -b ada.txt -X POST http://localhost:5106/books/1/cover -H "Content-Type: application/json" -d '{"url":"https://localhost:5107/admin/users"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Date: Fri, 06 Nov 2026 19:19:20 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Bad Request","status":400,"detail":"Host 'localhost' resolves to a non-public address."}
The host passed the allowlist, but because localhost resolves to ::1 and 127.0.0.1, the connection was never opened. The allowlist is the real defense and the IP check is the seat belt behind it; relying on an IP blocklist alone is weak, because what counts as "internal" varies by environment. Egress rules at the network layer go on top of both.
Common Pitfalls
The most common path traversal mistake is searching the string for .. instead of using the canonical path: it misses rooted paths and rejects innocent names like a..b.txt. Compare the canonical path with a root path that ends in a separator.
The first CORS pitfall is thinking of it as a security layer; CORS doesn't add protection, it relaxes a boundary the browser protects. The second is writing the origin check with hand-made string operations: SetIsOriginAllowed(origin => origin.EndsWith("bookstore.test")) also accepts another domain that ends with the same suffix. Read origins as exact values from per-environment configuration.
With SSRF, the pitfall is checking how the host name is spelled, like blocking the string localhost. There are always other spellings that reach the same address; the decision has to be made right before connecting, on the resolved IP. Returning the remote response or the error details to the client as is also means describing the inside of the internal network to someone who can't see it.
Checklist
Does every endpoint that uses a client value in a file path compare the canonical path with the root folder, or better, decide the file on the server? Does the CORS policy list origins one by one, and is there any code that echoes the origin back? Does UseCors run before authentication and authorization? Wherever the server sends requests out, is there a host allowlist, disabled redirects, a check on the resolved IP, a timeout and a size limit?
A Small Experiment
★☆☆ Add a Path.GetFileName(name) != name check to the download endpoint and return 400. What changed for ../appsettings.json and sub/../clean-code-sample.txt? Was the second one a legitimate request?
★★☆ Read the CORS policy's origin list from appsettings.json: http://localhost:4200 in Development, your own domain in Production. Try both environments with curl by changing the Origin header.
★★★ Add a size check to CoverFetcher: if the response's Content-Length exceeds 2 MB, reject it without reading the body. What's the difference between this check and MaxResponseContentBufferSize, and which one protects you when no Content-Length is sent at all?
In the next post we move on to A02:2025 Security Misconfiguration: a developer exception page left on in Production, a public OpenAPI document, a small middleware for CSP, which ASP.NET Core doesn't ship, why HSTS doesn't show up on localhost, and keeping DTDs off when reading XML.