The Policy That Was Never Applied
Trill's gateway declares an authenticatedUser authorisation policy exactly once and attaches it to nothing - and its identity middleware returns early when the Authorization header is absent, so a caller with no token at all can publish commands onto the estate's message bus.
Declaring a policy is not applying one. Every ASP.NET Core codebase I have audited has at least one policy in AddAuthorization that no endpoint references, and the reason it survives review is that policy declaration looks like enforcement — it is imperative, it names a requirement, and it sits in ConfigureServices next to things that do take effect. The only way to know whether it does anything is to search for its name.
Part 7 traced how Trill's gateway turns a validated JWT into a userId field in the request body. This part is about the requests that never reach that code, and what happens to them instead.
One policy, one occurrence
services.AddAuthorization(options =>
{
options.AddPolicy("authenticatedUser", policy =>
policy.RequireAuthenticatedUser());
});
Trill.APIGateway/src/Trill.APIGateway/Startup.cs:53-57.
The string "authenticatedUser" appears exactly once in the repository. There is no RequireAuthorization("authenticatedUser") on any endpoint, no [Authorize(Policy = ...)] anywhere — the gateway has no controllers to attribute — and no route-level authorisation in the YARP configuration, whose five routes carry only a match and a transforms block. The policy is declared, registered, resolvable, and attached to nothing.
app.UseAuthorization() is called, on line 88. With no endpoint carrying authorisation metadata, it is a no-op middleware that inspects each request's endpoint, finds no policy attached, and passes it along.
The early return that matters
On its own, an unused policy is untidy rather than dangerous — the identity middleware is doing the work. Except it has a gate of its own, at UserMiddleware.cs:28-32:
if (!request.Headers.ContainsKey("authorization"))
{
await next(context);
return;
}
Read that carefully against what the middleware does afterwards. If the header is present but invalid, AuthenticateAsync fails and the middleware returns 401. If the header is absent, the request continues, unauthenticated, with its body untouched.
That is a defensible choice for a proxy that fronts a mixture of public and private endpoints — GETs to /stories should not need a token, and the middleware already skips all methods that are not POST, PUT or PATCH. The problem is what sits immediately downstream of it:
app.UseMiddleware<UserMiddleware>();
app.UseMiddleware<MessagingMiddleware>();
Startup.cs:85-86. And MessagingMiddleware, as part 5 established, does not check identity at all. It matches the method and path against its endpoint table, reads the body, and publishes.
So a POST /stories-service/stories/async with no Authorization header takes this path: UserMiddleware sees no header and calls next; MessagingMiddleware matches stories-service/stories/async, reads the raw JSON body, publishes it to the stories exchange with routing key send_story, and returns 202 Accepted. The Stories service consumes it as a SendStory command whose UserId is whatever the caller put there.
An unauthenticated caller can inject commands onto the estate's message bus with an arbitrary userId of their choosing. No token, no signature, no claim — one curl with a JSON body. And the policy that would have stopped it is declared four lines above, in the same file.
The synchronous path is not much better, but it fails differently: POST /stories-service/stories with no header also skips the enricher, so the body reaches Stories with whatever userId the caller supplied and no gateway-set field. The estate's whole identity model, from part 7, is “the gateway sets userId and downstream trusts it”. Skipping the gateway's enricher does not make downstream stop trusting it.
The middleware order that makes it invisible
There is a second reason nothing catches this, and it is one of those ASP.NET Core ordering rules that produces no error when you break it. Here is Configure, in order, from Startup.cs:77-88:
app.UseMiddleware<LogContextMiddleware>();
app.UseCors("cors");
app.UseConvey();
app.UseJaeger();
app.UsePrometheus();
app.UseAccessTokenValidator();
app.UseAuthentication();
app.UseRabbitMq();
app.UseMiddleware<UserMiddleware>();
app.UseMiddleware<MessagingMiddleware>();
app.UseRouting();
app.UseAuthorization();
UseAuthentication() runs before UseRouting(). The documented order is routing, then authentication, then authorisation, and the reason is mechanical: UseRouting is what selects the endpoint and attaches its metadata to the request. Before it runs there is no Endpoint on the context, so authentication cannot see per-endpoint scheme selection and UseAuthorization — which does run after routing here — has nothing to inspect, because the only endpoints are the reverse-proxy catch-alls and the app-name MapGet, and none of them carries an authorisation requirement.
The estate gets away with it precisely because nothing depends on endpoint-scoped authorisation. The inverted order and the unapplied policy are the same omission seen from two sides: there is no endpoint in this gateway that authorisation is expected to protect, so the pipeline that would protect it was never exercised.
CORS, broken in the opposite direction
While in the same file, one more:
services.AddCors(cors =>
{
cors.AddPolicy("cors", x =>
{
x.WithOrigins("*")
.WithMethods("POST", "PUT", "DELETE")
.WithHeaders("Content-Type", "Authorization");
});
});
Startup.cs:59-67. The allowed methods are POST, PUT and DELETE. GET is absent.
This is subtler than it looks, and it is worth walking through because the failure is asymmetric. A plain GET with no unusual headers is a simple request under the CORS specification — the browser does not preflight it, so the missing method in the policy costs nothing and reads mostly work. But a GET that carries an Authorization header is no longer simple; the browser sends an OPTIONS preflight naming GET in Access-Control-Request-Method, and the policy rejects it.
So the Blazor client's anonymous browsing works and its authenticated reads do not. Trill's authenticated read path is CORS-broken through the gateway, which — combined with the fact that the Blazor client connects to the Pusher directly on port 5010 rather than through the gateway — means the “single entry point” has two entry points and the one that is single is broken for half its traffic.
To be fair: WithOrigins("*") combined with a fixed method list is a very common shape, and the omission of GET is exactly the kind of thing you get when you enumerate the write verbs you were debugging at the time. It is a one-word fix.
What earns the criticism, and what does not
I have been hard on fifty lines of a teaching repository, so let me be precise about what I think is actually wrong here, because it is not “the sample has a security hole”.
It is a sample. The bus is guest/guest on localhost, the signing certificate expired in March 2021, and nobody's data is behind any of it. Naming this as a vulnerability would be theatre.
What earns the criticism is the shape, because the shape is extremely common in real estates and it is genuinely hard to see. Three things had to be true simultaneously: a policy that declares intent without enforcing it, an identity middleware whose fail-open branch is the absence of a credential rather than its invalidity, and a second middleware downstream that publishes without checking. Any one of the three alone is fine. Together they produce an unauthenticated write path into the estate's core, and no single file in the repository is wrong. You cannot find this by reviewing UserMiddleware; you cannot find it by reviewing Startup; you find it only by tracing a request that lacks a header through both.
Which gives the rule I would take away. Fail-open on a missing credential is only safe if every downstream branch also treats “no identity” as a first-class state. If the pipeline continues without a principal, then something further along must decide what to do about that — and in Trill, the thing further along is a component that turns HTTP bodies into bus messages and has never heard of identity.
The practical fix here is one line, and it is the line the codebase already wrote: apply "authenticatedUser" to the async routes. YARP supports per-route AuthorizationPolicy in configuration, so the two messaging.endpoints paths could carry it without a recompile — which is exactly the extensibility part 5 admired, pointed at the problem it created.
That is the gateway. Next the estate's real-time half, and its single best teaching artefact: a channel is not a topic.