dotnet / dotnet/aspnetcore

[Validation] HTTP HEAD requests to Razor component endpoints

Open
#68,515 1 comment 0 reactions 1 assignee Claimed by @surya3655 View on GitHub
area-blazor Validation validation-scenario
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.