[Validation] HTTP HEAD requests to Razor component endpoints
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 10h
- Merged PRs (30d)
- 281
Description
**Scenario contact:** @javiercn
## Scenario
Uptime monitors, load balancer probes, link checkers and caches ask a page for its headers with an HTTP `HEAD` request. In .NET 10 Razor component endpoints advertised only `GET` and `POST`, so every one of those requests came back `405 Method Not Allowed`, which showed up as false alerts and broken-link reports. .NET 11 adds `HEAD` to the endpoint's method metadata. A `HEAD` request now runs the page exactly as `GET` does and the server drops the body.
## Minimum build
.NET 11 Preview 7 or later.
## Configurations to cover
* Blazor Web App
* [x] Static SSR
* [x] Interactive Server
* [ ] Interactive WebAssembly
* [ ] Interactive Auto
* [ ] Standalone WebAssembly
* [ ] Hybrid (MAUI)
The response to a `HEAD` request is produced entirely on the server, so the two configurations above are enough. Interactive WebAssembly and Auto reach the same server-side path as Interactive Server.
## Also exercise
* [ ] Published output
* [x] An existing .NET 10 app upgraded to .NET 11
* [ ] Trimming or ahead-of-time compilation
* [ ] More than one server instance, or a proxy in front
* [x] Hot Reload
* [ ] An IDE as well as the command line
* [x] Container
## Setup
You need `curl`, or an HTTP client that can send `HEAD` and show you raw response headers. Browsers cannot send `HEAD` from the address bar, so this scenario is driven from the command line.
## What to build
One Blazor Web App with these routes. Keep them trivial; the point is the response, not the page.
| Route | What it is |
|---|---|
| `/plain` | A page with a heading and nothing else |
| `/item/42` | A page taking a route parameter, rendering the value |
| `/slow` | A page using `@attribute [StreamRendering]` that awaits 2 seconds before showing data |
| `/secure` | A page with `@attribute [Authorize]` |
| `/teapot` | A page that sets `HttpContext.Response.StatusCode = 418` and adds a header `X-Test: hello` |
| `/missing-route` | Nothing. No page declares this route |
| `/logo.png` | Any static file, as a control that never went through the component endpoint |
Add a counter that every page increments when it renders, so you can tell whether a request executed the page or merely returned headers:
```csharp
// Program.cs
builder.Services.AddSingleton();
...
app.MapGet("/hits", (HitCounter c) => c.Count);
public class HitCounter
{
private int _count;
public int Count => _count;
public void Increment() => Interlocked.Increment(ref _count);
}
```
Inject it into each page and call `Counter.Increment()` from `OnInitialized`.
## Things to try
Send each route both ways and compare. `curl -I` sends `HEAD`; `curl -s -o /dev/null -D -` sends `GET` and prints only the headers, so the two outputs are directly comparable:
```bash
curl -I http://localhost:5000/plain # HEAD
curl -s -o /dev/null -D - http://localhost:5000/plain # GET, headers only
```
Use `curl -I` rather than `curl -X HEAD`. The latter sends the right method but then waits for a body that never arrives, so it hangs until it times out.
Work through the table above with both commands, then:
* Read `/hits` before and after a `HEAD` request to `/plain`, to see whether the page ran.
* Time the `HEAD` request to `/slow` with `curl -o /dev/null -w '%{time_total}\n' -I http://localhost:5000/slow` and compare it with the same `GET`.
* Repeat the `/plain` and `/secure` requests against the app running on .NET 10, where both return `405`.
* With the app running under Hot Reload, add a new `@page` route and change an existing one, then send `HEAD` to both without restarting.
## Expected behavior
A `HEAD` request is answered exactly as the matching `GET` would be, minus the response body.
### Must hold
* `HEAD /plain` returns `200`, `Content-Type: text/html; charset=utf-8`, and an empty body.
* For every route in the table, the status code returned by `HEAD` matches the status code returned by `GET`: `200` for `/plain`, `/item/42` and `/slow`, `418` for `/teapot`, and whatever `/secure` returns when signed out, which is normally a `302` redirect to the login page.
* `HEAD /teapot` carries the `X-Test: hello` header, so a page's own headers survive.
* `HEAD /missing-route`, which matches no page, returns `404` rather than `405`.
* `/hits` increases by one after a `HEAD` request, confirming the page executed rather than being short-circuited.
* ~~`HEAD /slow` takes about as long as `GET /slow`, since streaming rendering still runs.~~
* No response to a `HEAD` request carries a body, and no `HEAD` request produces a `405`.
* Nothing appears in the server log for a `HEAD` request that does not also appear for the matching `GET`.
### Expected differences between configurations
* None between Static SSR and Interactive Server. If a status code or header differs between them for the same route, that is a finding.
* On .NET 10 every one of these returns `405`. That is the behavior being fixed, not a failure.
## Evidence to capture
For each route, the full `HEAD` and `GET` header output pasted as text, so the two can be compared line by line. Include the `/hits` values read before and after.
## Documentation to use
* [ASP.NET Core Blazor routing](https://learn.microsoft.com/aspnet/core/blazor/fundamentals/routing?view=aspnetcore-11.0)
* [HTTP HEAD on MDN](https://developer.mozilla.org/docs/Web/HTTP/Reference/Methods/HEAD)
## What to report
Report results using the format described in the [validation testing manual](https://github.com/dotnet/aspnetcore/issues/68479). Include link to a repository with the test app.
Contributor guide
Assessment
This issue has not been assessed yet.