Skip to content

Answer 404 for a path this server does not serve - #1406

Merged
ppXD merged 1 commit into
mainfrom
fix/no-such-route-says-so
Aug 14, 2026
Merged

ppXD merged 1 commit into
mainfrom
fix/no-such-route-says-so

Conversation

@ppXD

@ppXD ppXD commented Aug 14, 2026

Copy link
Copy Markdown
Owner

Summary

Opening an invite link returned 401, which read as "you need to sign in" — for a person who has no account yet, that is exactly the wrong thing to say, and it sent the investigation after an authorization bug that did not exist. InvitationsController is [AllowAnonymous] and works:

/api/invitations/{token}  → 404 {"code":"invitation_not_usable", …}   ← endpoint fine
/invite/probe             → 401 (empty)                                ← no such route
/nonexistent-route        → 401 (empty)                                ← no such route

The cause is the global FallbackPolicy: it requires an authenticated user for endpoints without an explicit [Authorize] — deliberately — and it also applies when nothing matched. There the 401 is a lie; the request was refused for want of a route.

The immediate trigger was App:PublicBaseUrl naming the API host instead of the SPA, so InviteUrl minted https://<api-host>/invite/{token}. The boot banner now prints where links will point, so that is visible before anyone sends one.

Why a fallback endpoint, not middleware

The first attempt was middleware before UseAuthorization keyed on GetEndpoint() == null. An adversarial review (17 agents, 13 candidate findings, 7 confirmed) caught two regressions in it, both verified by live A/B against this exact pipeline:

  • Hangfire's dashboard would have been unmounted. UseHangfireDashboard is app.Map branch middleware and registers no endpoint, so GetEndpoint() is null for /hangfire and the middleware swallowed it — making UseCodeSpaceHangfire dead code on the API role, with no test able to see it (the E2E factory runs the Worker role, which mounts no dashboard).
  • The boot banner would have crashed Production. It constructed PublicBaseUrlSetting, which throws outside Development when the key is unset. That setting is resolved lazily today, only by the two request-scoped services that mint links. Worse, Main catches it into a Log.Fatal and returns 0, so the container would exit cleanly and every crash-loop restart would look like a healthy stop.

A fallback endpoint has neither problem: routing reaches it only after every real endpoint and every earlier branch has declined, so the special case disappears instead of needing an allowlist that would rot.

Known limitation

A path that matches a route template with the wrong HTTP method still answers 401, not 405 — routing selects the framework's method-rejection endpoint, which carries empty metadata and so meets the FallbackPolicy. Same bug class, rarer, not addressed here.

Test plan

  • UnroutedRequestE2ETests (5) over the real HTTP surface — including that a matched endpoint is still challenged, so the FallbackPolicy is not weakened
  • The_not_found_answer_does_not_swallow_branch_mounted_paths — reverting to the middleware form reds it, confirming it catches the design the review rejected
  • E2E 185/185 · Unit 6510/6510 · Integration 2748/2748

The global FallbackPolicy requires an authenticated user for any endpoint
without an explicit [Authorize] -- deliberately, so a forgotten attribute
cannot mean anonymous access. It also applied to requests that matched
NO endpoint, and there the 401 it produced is a lie: the request was
refused for want of a route, not a session, and adding one would not have
helped.

That cost a real diagnosis. An invite link built from a misconfigured
App:PublicBaseUrl pointed at the API host rather than the SPA, so opening
it asked this server for /invite/{token} -- a path only the SPA has. The
401 sent the investigation hunting a broken authorization rule on an
endpoint that was already [AllowAnonymous] and working.

Implemented as a fallback ENDPOINT rather than middleware before
UseAuthorization, because only the endpoint form composes with branch
middleware. An adversarial review caught the first attempt: Hangfire
mounts its dashboard with app.Map, which registers no endpoint, so a
middleware keyed on "GetEndpoint() == null" swallowed /hangfire whole and
made UseCodeSpaceHangfire dead code on the API role. Routing reaches a
fallback only after every real endpoint and every earlier branch has
declined, so the special case disappears rather than needing an allowlist.

The boot banner also names where invite and reset links will point, read
from the raw configuration key rather than through PublicBaseUrlSetting:
that type throws outside Development when the key is unset and is
resolved lazily today, so constructing it at startup would move the
failure to boot for both roles -- and Main catches it into a Fatal log
and returns 0, so the container would exit cleanly and every restart
would look like a healthy stop.
@ppXD
ppXD merged commit 1f73a99 into main Aug 14, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant