Read the user's ID, tenant and role from the ClaimsPrincipal your authentication scheme builds, never from the request, and scope every invoice query with tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin). Detail, list and export then return only rows the caller may read; answer 404 otherwise. In a resource-based AuthorizationHandler, check the tenant before the admin role.
- List every endpoint that reads invoices, including list, search, export, download and background services.
- Require a signed-in user on the whole group (app.MapGroup("/invoices").RequireAuthorization()) and take ClaimsPrincipal as a handler parameter. Remove parameters or headers that carry a user ID, tenant ID or role.
- Pass the ClaimsPrincipal to the data layer and add tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin) to every query, binding the claim values with Parameters.AddWithValue. With EF Core, a global query filter on TenantId adds the tenant part to every LINQ query on the entity.
- Return Results.NotFound() when the scoped query finds nothing, so a missing invoice and someone else's look the same.
- For bulk endpoints, compare the rows the scoped query returned with the IDs that were asked for, and refuse the whole request if any are missing.
- Add WebApplicationFactory tests with a test authentication handler, and sign in as a colleague, as a member of another tenant and as a billing admin of another tenant.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
public Task<Invoice?> FindAsync(ClaimsPrincipal user, long id) =>
Task.FromResult(_db.Query(command =>
{
command.CommandText = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id = $id
""";
command.Parameters.AddWithValue("$id", id);
}).SingleOrDefault());
public Task<IReadOnlyList<Invoice>> ListAsync(ClaimsPrincipal user) =>
Task.FromResult<IReadOnlyList<Invoice>>(_db.Query(command =>
{
command.CommandText = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE tenant_id = $tenant
ORDER BY id
""";
command.Parameters.AddWithValue("$tenant", user.FindFirstValue(InvoiceClaims.Tenant));
}));
public Task<IReadOnlyList<Invoice>> ExportAsync(ClaimsPrincipal user, IReadOnlyList<long> ids) =>
Task.FromResult<IReadOnlyList<Invoice>>(_db.Query(command =>
{
command.CommandText = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id IN (SELECT value FROM json_each($ids))
ORDER BY id
""";
command.Parameters.AddWithValue("$ids", JsonSerializer.Serialize(ids));
}));With IMPL=vulnerable, 6 of 15 tests fail (6 exploit tests).
The fix
// Members read their own invoices; billing admins read every invoice in their own
// tenant. The tenant test sits outside the parentheses, so it binds admins too:
// tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin)
private static void BindScope(SqliteCommand command, ClaimsPrincipal user)
{
command.Parameters.AddWithValue("$tenant",
user.FindFirstValue(InvoiceClaims.Tenant) ?? throw new InvalidOperationException("no tenant claim"));
command.Parameters.AddWithValue("$user",
user.FindFirstValue(ClaimTypes.NameIdentifier) ?? throw new InvalidOperationException("no user id claim"));
command.Parameters.AddWithValue("$isBillingAdmin", user.IsInRole(InvoiceClaims.BillingAdmin));
}
public Task<Invoice?> FindAsync(ClaimsPrincipal user, long id) =>
Task.FromResult(_db.Query(command =>
{
command.CommandText = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id = $id AND tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin)
""";
command.Parameters.AddWithValue("$id", id);
BindScope(command, user);
}).SingleOrDefault());
public Task<IReadOnlyList<Invoice>> ListAsync(ClaimsPrincipal user) =>
Task.FromResult<IReadOnlyList<Invoice>>(_db.Query(command =>
{
command.CommandText = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin)
ORDER BY id
""";
BindScope(command, user);
}));
public Task<IReadOnlyList<Invoice>> ExportAsync(ClaimsPrincipal user, IReadOnlyList<long> ids) =>
Task.FromResult<IReadOnlyList<Invoice>>(_db.Query(command =>
{
command.CommandText = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id IN (SELECT value FROM json_each($ids))
AND tenant_id = $tenant AND (owner_id = $user OR $isBillingAdmin)
ORDER BY id
""";
command.Parameters.AddWithValue("$ids", JsonSerializer.Serialize(ids));
BindScope(command, user);
}));With IMPL=fixed, 15 of 15 tests pass.
A fix that looks safe but is not
private static readonly OperationAuthorizationRequirement Read = new() { Name = nameof(Read) };
/// <summary>Owners may read their invoices; billing admins may read any invoice.</summary>
public sealed class ReadHandler : AuthorizationHandler<OperationAuthorizationRequirement, Invoice>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, OperationAuthorizationRequirement requirement, Invoice invoice)
{
if (invoice.OwnerId == context.User.FindFirstValue(ClaimTypes.NameIdentifier)
|| context.User.IsInRole(InvoiceClaims.BillingAdmin))
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
public async Task<Invoice?> FindAsync(ClaimsPrincipal user, long id)
{
var invoice = _db.Query(command =>
{
command.CommandText = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id = $id
""";
command.Parameters.AddWithValue("$id", id);
}).SingleOrDefault();
if (invoice is null)
{
return null;
}
var result = await _authorization.AuthorizeAsync(user, invoice, Read);
return result.Succeeded ? invoice : null;
}
public Task<IReadOnlyList<Invoice>> ListAsync(ClaimsPrincipal user) =>
ReadableAsync(user, _db.Query(command =>
{
command.CommandText = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
ORDER BY id
""";
}));
public Task<IReadOnlyList<Invoice>> ExportAsync(ClaimsPrincipal user, IReadOnlyList<long> ids) =>
ReadableAsync(user, _db.Query(command =>
{
command.CommandText = """
SELECT id, tenant_id, owner_id, number, amount_cents, status FROM invoices
WHERE id IN (SELECT value FROM json_each($ids))
ORDER BY id
""";
command.Parameters.AddWithValue("$ids", JsonSerializer.Serialize(ids));
}));
private async Task<IReadOnlyList<Invoice>> ReadableAsync(ClaimsPrincipal user, List<Invoice> invoices)
{
var readable = new List<Invoice>();
foreach (var invoice in invoices)
{
if ((await _authorization.AuthorizeAsync(user, invoice, Read)).Succeeded)
{
readable.Add(invoice);
}
}
return readable;
}With IMPL=wrong, 3 of 15 tests fail (3 exploit tests).
Routes: the user comes from authentication, not the request
// Every route needs a signed-in user. The ClaimsPrincipal comes from the authentication
// middleware; nothing here reads a user, tenant or role from the request.
var invoices = app.MapGroup("/invoices").RequireAuthorization();
invoices.MapGet("/", async (ClaimsPrincipal user, IInvoiceService service) =>
Results.Ok(await service.ListAsync(user)));
// Missing and not yours get the same answer, so IDs cannot be probed.
invoices.MapGet("/{id:long}", async (long id, ClaimsPrincipal user, IInvoiceService service) =>
await service.FindAsync(user, id) is { } invoice ? Results.Ok(invoice) : Results.NotFound());
invoices.MapGet("/export", async (string ids, ClaimsPrincipal user, IInvoiceService service) =>
{
if (!InvoiceIds.TryParse(ids, out var requested))
{
return Results.BadRequest(new { error = $"ids must be 1 to {InvoiceIds.Max} invoice IDs, comma-separated" });
}
var rows = await service.ExportAsync(user, requested);
// All or nothing: one missing or unreadable ID refuses the whole export, so a
// partial file is never mistaken for the full one.
return rows.Count == requested.Count
? Results.Text(InvoiceCsv.Format(rows), "text/csv", Encoding.UTF8)
: Results.NotFound();
});The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
/// <summary>Exploit: other users, other tenants and other tenants' admins must get nothing.</summary>
public sealed class ExploitTests(InvoicesApiFactory factory) : InvoicesApiTests(factory)
{
// Denied looks exactly like missing: 404 and none of the invoice's data.
private async Task AssertHidden(string user, string path, string number)
{
var (status, body) = await Get(user, path);
Assert.True(status == HttpStatusCode.NotFound, $"expected 404, got {(int)status}: {body}");
Assert.DoesNotContain(number, body);
}
[Fact]
public Task MemberCannotReadAColleaguesInvoiceInTheSameTenant() =>
AssertHidden("alice", "/invoices/502", "INV-502");
[Fact]
public Task MemberOfAnotherTenantCannotReadTheInvoice() =>
AssertHidden("erin", "/invoices/501", "INV-501");
[Fact]
public Task BillingAdminCannotReadAnInvoiceInAnotherTenant() =>
AssertHidden("cara", "/invoices/601", "INV-601");
[Fact]
public async Task MembersListLeavesOutColleaguesInvoices() =>
Assert.Equal(new long[] { 501, 503 }, await ListedIds("alice"));
[Fact]
public async Task BillingAdminsListStaysInsideTheirTenant() =>
Assert.Equal(new long[] { 501, 502, 503 }, await ListedIds("cara"));
[Fact]
public Task ExportThatIncludesAColleaguesInvoiceIsRefusedWhole() =>
AssertHidden("alice", "/invoices/export?ids=501,502", "INV-502");
[Fact]
public Task BillingAdminsExportIntoAnotherTenantIsRefusedWhole() =>
AssertHidden("cara", "/invoices/export?ids=502,601", "INV-601");
}- An AuthorizationHandler that calls context.Succeed when context.User.IsInRole("billing_admin"), without comparing the invoice's tenant with the user's tenant claim, is this card's wrong implementation. Microsoft's Contact Manager tutorial has the same shape ("Administrators can do anything."), which is fine in a single-tenant app and wrong in a multi-tenant one.
- [Authorize] and RequireAuthorization() only establish who the caller is or which role they hold. Microsoft's docs note that attribute evaluation occurs before data binding, so object checks need imperative code or a scoped query.
- Resource-based authorization works on one loaded object. Loading every row and calling AuthorizeAsync in a loop brings other tenants' rows into memory and breaks paging; scope the query for lists.
- EF Core: a global query filter (HasQueryFilter(i => i.TenantId == tenantId)) covers LINQ queries on that entity, but IgnoreQueryFilters() switches it off and ADO.NET or Dapper queries never see it. The owner rule still needs its own condition.
- A tenant ID from a header, a subdomain or a route value is only a selector. If you accept one, check that the signed-in user belongs to that tenant before using it.
- Random IDs such as Guid values make guessing harder, but leaked links, logs and browser history still expose them; the check is still required.
- Microsoft.Data.Sqlite does not expand a list parameter. This card binds a JSON array and uses IN (SELECT value FROM json_each($ids)).
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs missing-authorization/csharp-aspnetcore does this for you).
- Against your own API, sign in as two members of one tenant and as a billing admin of another tenant, then replay each other's invoice IDs on the detail, list and export routes. Every request that crosses a boundary must get a 404 or only in-scope rows.
- Do not expect a scanner to find this: Semgrep's missing-or-broken-authorization rule only matches MVC controller classes without [Authorize], CodeQL's cs/web/insecure-direct-object-reference only looks at MVC and Web Forms edit and delete actions, and Semgrep's OWASP Top 10 pack reported nothing on the vulnerable code.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | csharp.dotnet.security.audit.missing-or-broken-authorization.missing-or-broken-authorization | No findings on src/: the rule only matches classes that derive from Controller and lack [Authorize], [Authorize(Roles = ...)], [Authorize(Policy = ...)] or [AllowAnonymous], and this card uses minimal-API endpoints. On a probe MVC controller it flagged the class without [Authorize] and stayed silent once [Authorize] was added, although that attribute does nothing for object-level access.Checked: Semgrep 1.180.0 (CE) |
| Semgrep | p/owasp-top-ten | No findings on src/ (the pack includes the rule above): the vulnerable, fixed and wrong code look the same to every rule in these packs.Checked: Semgrep 1.180.0 (CE) |
| CodeQL | cs/web/insecure-direct-object-reference | Not expected to flag this card. The query only considers MVC controller actions and Web Forms handlers whose name, class or path mentions edit, delete, modify or change and that take an ID parameter; it skips reads, admin actions, minimal-API endpoints, actions with an [Authorize] attribute and actions that call any method whose name contains 'user' or 'session' (semmle/code/csharp/security/auth/InsecureDirectObjectReferenceQuery.qll).Checked: github/codeql main at b5b8165; not executed |
| CodeQL | cs/web/missing-function-level-access-control | Not expected to flag this card. It is a function-level check: it flags MVC and Web Forms edit, delete and admin actions that show no sign of an authorization check ([Authorize], IsInRole, IsAuthenticated, a call whose name contains 'auth', or web.config rules). It never compares a resource with the user.Checked: github/codeql main at b5b8165; not executed |
In semgrep-rules develop at 9bbf021, missing-or-broken-authorization is the only C# rule tagged CWE-639, CWE-862 or CWE-863. Neither it nor the CodeQL queries can see a check that ignores the tenant.
| Component | Version |
|---|---|
| Language | C# 14 |
| Runtime | .NET 10.0.12 (SDK 10.0.112) |
| Framework | ASP.NET Core 10 minimal API |
| Microsoft.Data.Sqlite | 10.0.12 (SQLite 3.53.3 via SQLitePCLRaw 2.1.12) |
| Microsoft.AspNetCore.Mvc.Testing | 10.0.12 |
| xunit.v3 | 4.0.1 (Microsoft Testing Platform 2.4.0) |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the missing-authorization/csharp-aspnetcorefolder.
dotnet restore tests/Invoices.Tests --locked-mode# Expected: every test passes
IMPL=fixed dotnet test --project tests/Invoices.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: an exploit test fails
IMPL=vulnerable dotnet test --project tests/Invoices.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xml
# Expected: a test fails
IMPL=wrong dotnet test --project tests/Invoices.Tests --no-restore --results-directory reports --report-xunit-junit --report-xunit-junit-filename junit.xmlEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs missing-authorization/csharp-aspnetcore runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (arm64)
- Toolchain
- dotnetSdk 10.0.112
- dotnetRuntime 10.0.12
- sqlite 3.53.3
- Matrix
- fixed: 15 of 15 tests pass; vulnerable: 6 of 15 tests fail (6 exploit tests); wrong: 3 of 15 tests fail (3 exploit tests)
- Test results
- IMPL=fixed 15/15 pass; IMPL=vulnerable 6/15 fail (6 exploit); IMPL=wrong 3/15 fail (3 exploit); run offline in a no-network sandbox, after installs with warm and with empty package caches
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- OWASP API Security Top 10 2023: API1 Broken Object Level Authorization
- OWASP Insecure Direct Object Reference Prevention Cheat Sheet
- OWASP Authorization Cheat Sheet
- OWASP Multi-Tenant Security Cheat Sheet
- ASP.NET Core: Resource-based authorization
- ASP.NET Core: Create an app with user data protected by authorization (Contact Manager)
- ASP.NET Core: Authentication and authorization in minimal APIs
- ASP.NET Core: Integration tests, mock authentication
- EF Core: Global query filters (multi-tenancy)
- CodeQL query help: Insecure Direct Object Reference (cs/web/insecure-direct-object-reference)