The OData endpoint that returned more than you meant it to
OData gives clients the query. That's the point — and the risk. How an innocent [EnableQuery] leaks columns, relationships, and whole tables you never intended to expose.
The convenience is the vulnerability
OData is wonderful precisely because it hands the query to the caller. $filter, $select, $expand, $orderby — the client composes what it needs and you write almost no controller code. That same property is why an OData surface is the easiest place in a .NET API to leak data without noticing.
The problem is rarely a missing auth check on the endpoint. It's that the endpoint is more powerful than the developer who wrote it realised.
One attribute, a whole object graph
Here's the shape I see most often:
[EnableQuery]
[HttpGet]
public IQueryable<Customer> Get() => _db.Customers;
Reasonable-looking. But [EnableQuery] with no bounds means the caller can:
$select=PasswordHash,InternalRiskScore— any column on the entity, including the ones you only put there for internal use.$expand=Orders($expand=Payments)— walk every navigation property, several levels deep, straight into tables you never meant this endpoint to touch.$orderby=SomeExpensiveComputedor a deep$filter— turn a cheap list into a table scan and a denial-of-service.
Nobody wrote "expose payments here." The relationship exposed itself, because $expand follows whatever the EF model allows.
Where the real boundary belongs
The fix is not to abandon OData. It's to decide, explicitly, what this surface is allowed to do — and to make the query operate on a projection you control, never the raw entity.
[EnableQuery(
MaxExpansionDepth = 1,
AllowedQueryOptions = AllowedQueryOptions.Filter
| AllowedQueryOptions.OrderBy
| AllowedQueryOptions.Top
| AllowedQueryOptions.Skip,
MaxTop = 100)]
[HttpGet]
public IQueryable<CustomerListItem> Get() =>
_db.Customers.Select(c => new CustomerListItem
{
Id = c.Id,
Name = c.Name,
City = c.City,
});
Now the shape is closed. $select can only reach the three columns on CustomerListItem. $expand has nothing to walk. MaxTop caps the page. The model builder should back this up so the metadata document itself never advertises what you don't serve.
What I check on an OData review
- Does the query run over an entity or a DTO? Entity means the whole model is reachable. That's the first thing I flag.
- Is
$expandbounded? Depth, and an allow-list of expandable properties — not "whatever EF knows about." - Is
MaxTopset, and is there a default page size? An unbounded$topis a load-generator waiting for a bored client. - Does
$filterreach columns you'd never index or expose? Filtering is a read too — you can leak a value by asking the right yes/no question even without$select. - Does the
$metadatadocument match your intent? It's a public map of your model. Read it as an attacker would.
An OData endpoint isn't insecure because someone forgot [Authorize]. It's insecure because the query language is doing exactly what it was designed to do, over a model that was never scoped for it. That gap — between what the endpoint appears to return and what it can return — is one of the first things I go looking for.