Mert Özen Further With Every Line
Security Web Security (OWASP Top 10) — #02

The Security Lab: A Bookstore API with ASP.NET Core 10 and Reading HTTP with curl

Mert Özen Nov 6, 2026 11 min 6 views
The Security Lab: A Bookstore API with ASP.NET Core 10 and Reading HTTP with curl

We set up the lab for the rest of the series: an ASP.NET Core 10 minimal API on SQLite. With curl -i we read methods, status codes, headers and cookies through a security lens, see how the same error reaches the client differently in Development and Production, and cover the HTTPS development certificate and the lab rules.

Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).

Guess First

An unhandled NullReferenceException is thrown inside an endpoint. Without changing a single line of code, you run the app once in the Development environment and once in Production. Does the client get the same response in both cases? If not, which one is more valuable to an attacker? We will see the answer halfway through this post, in our own lab application.

The goal of this post is to set up the lab we will use for the rest of the series: a small ASP.NET Core 10 minimal API on SQLite, called the Bookstore API. While building it, we will practice how a security person looks at HTTP, that is, how they read methods, status codes, headers and cookies, using curl -i.

Lab Rules

I start with the rules before the code, because the rest of the series depends on them. Every attack shown in this series is run only against this lab application, running on your own machine on localhost. Only try these techniques on systems you own or have written permission to test. The test environment at work is not covered by that either; it needs written permission too.

Keep the lab in its own folder, not next to your real projects. The passwords, keys and connection strings in the examples are fake and look obviously fake: they start with dev-only-. Don't copy a real secret into the lab; if you push the lab code to a repo, that secret goes with it. The database is a single bookstore.db file. When you break something, delete the file and restart the app; the tables and the sample data are created again from scratch. Don't forget to add that file to .gitignore.

Setting Up the Bookstore API

We open an empty web project and add Microsoft's ADO.NET provider for SQLite. I am leaving EF Core out on purpose; seeing the SQL explicitly will pay off in the injection post:

$ dotnet new web -n Bookstore.Api
$ cd Bookstore.Api
$ dotnet add package Microsoft.Data.Sqlite

The command adds the latest version of the package at the time; for this post, this line ended up in the csproj:

<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.14" />

We put the database work into a single class. Initialize creates four tables and adds the sample data: three users (Ada the admin, and customers Alan and Grace), three books, three orders and two reviews. Thanks to INSERT OR IGNORE, restarting the app doesn't duplicate the data:

using Microsoft.Data.Sqlite;

public sealed class BookstoreDb(string connectionString)
{
    public SqliteConnection Open()
    {
        SqliteConnection connection = new(connectionString);
        connection.Open();
        return connection;
    }

    public void Initialize()
    {
        using SqliteConnection connection = Open();
        using SqliteCommand command = connection.CreateCommand();
        command.CommandText = """
            CREATE TABLE IF NOT EXISTS users (
                id INTEGER PRIMARY KEY, username TEXT NOT NULL UNIQUE,
                email TEXT NOT NULL, role TEXT NOT NULL);
            CREATE TABLE IF NOT EXISTS books (
                id INTEGER PRIMARY KEY, title TEXT NOT NULL,
                author TEXT NOT NULL, price REAL NOT NULL);
            CREATE TABLE IF NOT EXISTS orders (
                id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id),
                book_id INTEGER NOT NULL REFERENCES books(id), quantity INTEGER NOT NULL);
            CREATE TABLE IF NOT EXISTS reviews (
                id INTEGER PRIMARY KEY, book_id INTEGER NOT NULL REFERENCES books(id),
                user_id INTEGER NOT NULL REFERENCES users(id), body TEXT NOT NULL);

            INSERT OR IGNORE INTO users VALUES
                (1, 'ada', 'ada@bookstore.test', 'admin'),
                (2, 'alan', 'alan@bookstore.test', 'customer'),
                (3, 'grace', 'grace@bookstore.test', 'customer');
            INSERT OR IGNORE INTO books VALUES
                (1, 'Clean Code', 'Robert C. Martin', 34.90),
                (2, 'The Pragmatic Programmer', 'Andrew Hunt, David Thomas', 39.50),
                (3, 'Designing Data-Intensive Applications', 'Martin Kleppmann', 45.00);
            INSERT OR IGNORE INTO orders VALUES
                (1, 2, 1, 1),
                (2, 3, 2, 2),
                (3, 1, 3, 1);
            INSERT OR IGNORE INTO reviews VALUES
                (1, 1, 2, 'Still relevant.'),
                (2, 2, 3, 'Read it twice.');
            """;
        command.ExecuteNonQuery();
    }

    public async Task<List<Book>> GetBooksAsync()
    {
        await using SqliteConnection connection = Open();
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT id, title, author, price FROM books ORDER BY id";
        await using SqliteDataReader reader = await command.ExecuteReaderAsync();
        List<Book> books = [];
        while (await reader.ReadAsync())
        {
            books.Add(new Book(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetDecimal(3)));
        }
        return books;
    }

    public async Task<Book?> GetBookAsync(int id)
    {
        await using SqliteConnection connection = Open();
        await using SqliteCommand command = connection.CreateCommand();
        // The value is not embedded in the SQL text; it goes as a parameter
        command.CommandText = "SELECT id, title, author, price FROM books WHERE id = $id";
        command.Parameters.AddWithValue("$id", id);
        await using SqliteDataReader reader = await command.ExecuteReaderAsync();
        return await reader.ReadAsync()
            ? new Book(reader.GetInt32(0), reader.GetString(1), reader.GetString(2), reader.GetDecimal(3))
            : null;
    }

    public async Task<Review?> GetLatestReviewAsync(int bookId)
    {
        await using SqliteConnection connection = Open();
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT id, book_id, body FROM reviews WHERE book_id = $bookId ORDER BY id DESC LIMIT 1";
        command.Parameters.AddWithValue("$bookId", bookId);
        await using SqliteDataReader reader = await command.ExecuteReaderAsync();
        return await reader.ReadAsync()
            ? new Review(reader.GetInt32(0), reader.GetInt32(1), reader.GetString(2))
            : null;
    }
}

public record Book(int Id, string Title, string Author, decimal Price);
public record Review(int Id, int BookId, string Body);
public record Preferences(string Theme);

In the queries we use parameters such as $id instead of pasting values into the SQL text. The rest of the lab is written with this habit; we will see what string concatenation costs by breaking it on purpose in the A05 Injection post. Program.cs looks like this:

var builder = WebApplication.CreateBuilder(args);

string connectionString = builder.Configuration.GetConnectionString("Bookstore")
    ?? "Data Source=bookstore.db";
builder.Services.AddSingleton(new BookstoreDb(connectionString));

var app = builder.Build();

app.Services.GetRequiredService<BookstoreDb>().Initialize();

app.UseHttpsRedirection();

app.MapGet("/books", async (BookstoreDb db) => await db.GetBooksAsync());

app.MapGet("/books/{id:int}", async (int id, BookstoreDb db) =>
    await db.GetBookAsync(id) is Book book ? Results.Ok(book) : Results.NotFound());

// Bug left on purpose: fails for a book with no reviews yet
app.MapGet("/books/{id:int}/summary", async (int id, BookstoreDb db) =>
{
    Review? latest = await db.GetLatestReviewAsync(id);
    return Results.Ok(new { id, latestReview = latest!.Body });
});

app.MapPost("/preferences", (Preferences preferences, HttpResponse response) =>
{
    response.Cookies.Append("theme", preferences.Theme);
    return Results.NoContent();
});

app.Run();

To pin the ports, I changed the applicationUrl values in Properties/launchSettings.json: the http profile uses http://localhost:5102, the https profile https://localhost:5103;http://localhost:5102. launchBrowser is false so the browser doesn't open by itself. Let's start the app with the HTTPS profile:

$ dotnet run --launch-profile https
Using launch settings from /.../Bookstore.Api/Properties/launchSettings.json...
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:5103
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5102
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: /.../Bookstore.Api

The line to watch is Hosting environment: Development. The launch profile sets the ASPNETCORE_ENVIRONMENT variable to Development; we will see shortly what that changes.

Reading HTTP Through a Security Lens: Methods and Status Codes

A browser shows you only the body of a response. In security, the real story is often in the status line and the headers. curl -i prints both:

$ curl -i https://localhost:5103/books
HTTP/2 200
content-type: application/json; charset=utf-8
date: Fri, 06 Nov 2026 19:02:38 GMT
server: Kestrel

[{"id":1,"title":"Clean Code","author":"Robert C. Martin","price":34.9},{"id":2,"title":"The Pragmatic Programmer","author":"Andrew Hunt, David Thomas","price":39.5},{"id":3,"title":"Designing Data-Intensive Applications","author":"Martin Kleppmann","price":45.0}]

The connection went over HTTPS with HTTP/2; in HTTP/2, header names arrive in lowercase. Now let's ask for a book that doesn't exist and try a method that isn't allowed:

$ curl -i https://localhost:5103/books/99
HTTP/2 404
date: Fri, 06 Nov 2026 19:02:38 GMT
server: Kestrel
content-length: 0

$ curl -i -X DELETE https://localhost:5103/books/1
HTTP/2 405
date: Fri, 06 Nov 2026 19:02:38 GMT
server: Kestrel
allow: GET
content-length: 0

Status codes carry information for an attacker too. 404 says "no such record", 405 says "this address exists, but not with this method"; the allow: GET header even tells you which method is accepted. None of this is a problem; it is how HTTP naturally behaves. But these differences will matter for authorization: in the next post we will make a conscious choice between 403 and 404 for another user's order. A body sent with the wrong Content-Type is rejected by the minimal API itself:

$ curl -i -X POST https://localhost:5103/preferences -d 'theme=dark'
HTTP/2 415
date: Fri, 06 Nov 2026 19:03:42 GMT
server: Kestrel
content-length: 0

curl -d sends form data by default; since the endpoint expects JSON, the answer is 415 Unsupported Media Type.

Headers and Cookies

One header stands out in every response: server: Kestrel. It tells everyone which web server the app runs on. On its own it is not a vulnerability, but it is free information about the attack surface; we will turn it off in the A02 Security Misconfiguration post. Now let's look at the endpoint that sets a cookie:

$ curl -i -X POST https://localhost:5103/preferences -H "Content-Type: application/json" -d '{"theme":"dark"}'
HTTP/2 204
date: Fri, 06 Nov 2026 19:02:38 GMT
server: Kestrel
set-cookie: theme=dark; path=/

If you give Cookies.Append no options, the cookie arrives with only path=/. Any JavaScript on the page can read it through document.cookie, and the browser may send it on plain HTTP requests and on some requests triggered from other sites. For a theme preference that doesn't matter; but if you write the same line for a cookie that carries a session or a token, it does. Let's build the right habit from the start:

app.MapPost("/preferences", (Preferences preferences, HttpResponse response) =>
{
    response.Cookies.Append("theme", preferences.Theme, new CookieOptions
    {
        HttpOnly = true,
        Secure = true,
        SameSite = SameSiteMode.Strict,
        MaxAge = TimeSpan.FromDays(30)
    });
    return Results.NoContent();
});
$ curl -i -X POST https://localhost:5103/preferences -H "Content-Type: application/json" -d '{"theme":"dark"}'
HTTP/2 204
date: Fri, 06 Nov 2026 19:03:42 GMT
server: Kestrel
set-cookie: theme=dark; max-age=2592000; path=/; secure; samesite=strict; httponly

httponly hides the cookie from JavaScript, secure makes sure it is sent only over HTTPS, and samesite=strict keeps the cookie off requests that start on another site. The details of these three flags and the browsers' defaults are the topic of the A07 Authentication Failures post.

Development and Production

Back to the opening question. The /books/3/summary endpoint tries to read the Body of a null review for the third book, which has no reviews yet. In Development:

$ curl -i https://localhost:5103/books/3/summary
HTTP/2 500
content-type: text/plain; charset=utf-8
date: Fri, 06 Nov 2026 19:02:39 GMT
server: Kestrel

System.NullReferenceException: Object reference not set to an instance of an object.
   at Program.<>c.<<<Main>$>b__0_2>d.MoveNext() in /.../Bookstore.Api/Program.cs:line 22
--- End of stack trace from previous location ---
   at Microsoft.AspNetCore.Http.RequestDelegateFactory.ExecuteTaskResult[T](Task`1 task, HttpContext httpContext)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)

HEADERS
=======
Accept: */*
Host: localhost:5103
User-Agent: curl/8.7.1

The exception type, the full path of the file on the server, the line number, framework internals and the request headers. This comes from the developer exception page; WebApplication turns it on by itself only in the Development environment. It is very valuable while developing, because you see the error immediately. The same information is valuable to an attacker as well: they learn which framework you use, how your files are laid out and where the code breaks. Now let's run the same code in Production. The -e option overrides the variable from the launch profile:

$ dotnet run --launch-profile https -e ASPNETCORE_ENVIRONMENT=Production
...
      Hosting environment: Production
...
$ curl -i https://localhost:5103/books/3/summary
HTTP/2 500
date: Fri, 06 Nov 2026 19:03:08 GMT
server: Kestrel
content-length: 0

The body is empty. The client only learned that "something went wrong on the server". The details are not lost; they went where they belong, to the server's log:

fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HNPS72C9G1FB", Request id "0HNPS72C9G1FB:00000001": An unhandled exception was thrown by the application.
      System.NullReferenceException: Object reference not set to an instance of an object.
         at Program.<>c.<<<Main>$>b__0_2>d.MoveNext() in /.../Bookstore.Api/Program.cs:line 22

That is the answer to the opening question: same code, different response. Development is "chatty" on purpose to help the developer; Production should stay quiet. The environment name is itself a configuration setting; if a server is accidentally left on Development, all of this detail is exposed to the internet. We will talk about returning a proper ProblemDetails response instead of an empty 500 in the A10 post, and about per-environment configuration in the A02 post. If you want to know how environments work in general, see the post on Program.cs and environments in the ASP.NET Core Web API series.

The HTTPS Development Certificate

The requests above worked over HTTPS without trouble because the machine has a trusted development certificate. You can check its state with this command:

$ dotnet dev-certs https --check --trust
A trusted certificate was found: 4682C08F...25AE0 - CN=localhost - Valid from 2026-03-07 23:13:48Z to 2027-03-07 23:13:48Z - IsHttpsDevelopmentCertificate: true - IsExportable: true

If the certificate is missing or not trusted, dotnet dev-certs https --trust creates it and adds it to the operating system's trust store; on macOS and Windows a confirmation prompt may appear. This certificate is valid only for localhost and only on your machine. Production uses a real certificate.

The app.UseHttpsRedirection() line in the program redirects plain HTTP requests to HTTPS:

$ curl -i http://localhost:5102/books
HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Date: Fri, 06 Nov 2026 19:02:38 GMT
Server: Kestrel
Location: https://localhost:5103/books

The default code is 307. The redirect helps, but it is not enough on its own: the first request still goes out unencrypted. The way to tell the browser "only come back to this site over HTTPS" is the HSTS header; we will cover it in the A02 and A04 posts. I am not making HTTPS mandatory in the lab; in later posts I will often use plain http://localhost to keep the examples simple, and I will say so where HTTPS makes a difference.

Common Pitfalls

The first pitfall is setting up the lab inside a real project. An endpoint left open on purpose can slip into real code one day through a wrong merge. The lab lives in its own folder and its own repo.

The second is not checking the environment name on the server. If ASPNETCORE_ENVIRONMENT is never set in the container or hosting settings, the default is Production, and that is a good thing. The problem is someone switching it to Development "just to see the error" and forgetting about it. A single look at the Hosting environment line the app logs at startup, after each deploy, catches this mistake.

The third is silencing certificate warnings with curl -k or with an "accept every certificate" callback in code. Don't pick up that habit even in the lab; the same line ends up in Production code one day and removes the whole point of HTTPS.

The fourth is leaving cookie options at their defaults. Cookies.Append("x", value) is short and convenient; but if it carries a sensitive value, always write the flags explicitly.

Checklist

The lab is ready if the following are true: the app starts with dotnet run --launch-profile https and the log says Hosting environment: Development; curl -i https://localhost:5103/books returns three books; dotnet dev-certs https --check --trust finds a trusted certificate; when you delete bookstore.db and restart the app, the data comes back from scratch.

When reading HTTP, look at the same four things in every response: what the status code says, which headers give away information about the server, which flags are on the set-cookie line, and what comes back in the body when something fails.

A Small Experiment

★☆☆ Set up the lab and send curl -i requests to /books/2 and /books/abc. Why does the second one return 404? Hint: the {id:int} constraint in the route.

★★☆ Send a request to the /preferences endpoint with the body {"color":"dark"}. What happens when the theme field is missing, and how does the response differ between Development and Production? It is a small preview of why input validation is needed.

★★★ Run the app in Production, send a request to /books/3/summary and find the fail line in the log. Then change the log level in appsettings.json and watch which lines disappear.

In the next post we enter the first category of the series: OWASP A01:2025 Broken Access Control. We will add cookie-based login to the lab, show the IDOR flaw where Alan reads Grace's order and the missing authorization check on an admin endpoint, and then close both with policies and resource-based authorization.