HEAD requests answered, found by deploying rather than by a test
chi routes HEAD only where a handler is registered for it, so every page answered 405 to the HEAD that uptime checks, link unfurlers and crawlers send first. curl -I against the live host caught it; nothing in the suite did, because every test asked for GET. middleware.GetHead fixes it, and two tests now cover it: the public pages answer HEAD, and /signup still refuses everything but POST so HEAD cannot become a way to spend a nonce.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+52
−0
internal/movement/app.go+6 −0internal/movementtest/head_test.go+46 −0
diff --git a/internal/movement/app.go b/internal/movement/app.go| index 61a5114..8293fd3 100644 |
| --- a/internal/movement/app.go |
| +++ b/internal/movement/app.go |
| @@ -5,6 +5,7 @@ import ( |
| "net/http" |
| "github.com/go-chi/chi/v5" |
| + "github.com/go-chi/chi/v5/middleware" |
| "github.com/carlosframework/rastrillo/csrf" |
| "github.com/carlosframework/rastrillo/db" |
| @@ -74,6 +75,11 @@ func App(d *db.DB, env Env) (*http.ServeMux, error) { |
| // App-wide, from day one: every state-changing route added below |
| // is born covered. Origin-checking, not tokens — nothing to mint |
| // or forget in a form. |
| + // chi routes HEAD only if a handler is registered for it, so without this |
| + // every GET route answers 405 to a HEAD request — which is what uptime |
| + // checks, link unfurlers and a good deal of crawler traffic send. Found by |
| + // curl -I against the deployed host, not by any test. |
| + r.Use(middleware.GetHead) |
| r.Use(csrf.Protect(env.Origin)) |
| r.Get("/", a.index) |
| // One route per reviewed slug rather than a wildcard, so an unknown path |
diff --git a/internal/movementtest/head_test.go b/internal/movementtest/head_test.go| new file mode 100644 |
| index 0000000..159af4a |
| --- /dev/null |
| +++ b/internal/movementtest/head_test.go |
| @@ -0,0 +1,46 @@ |
| +package movementtest |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| +) |
| + |
| +// Every public page must answer HEAD, not 405. |
| +// |
| +// chi routes HEAD only where a handler is registered for it, so a router built |
| +// from r.Get alone refuses every HEAD request — and HEAD is what uptime |
| +// checks, link unfurlers and a fair amount of crawler traffic send before |
| +// anything else. This was found with curl -I against the deployed host after |
| +// the first deploy, which is later than it should have been. |
| +func TestPublicPagesAnswerHead(t *testing.T) { |
| + h := newApp(t) |
| + |
| + for _, path := range []string{"/", "/no-ai", "/uneasy", "/all-in"} { |
| + req := httptest.NewRequest(http.MethodHead, path, nil) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + |
| + if rec.Code != http.StatusOK { |
| + t.Errorf("HEAD %s: status %d, want 200", path, rec.Code) |
| + } |
| + } |
| +} |
| + |
| +// HEAD must not become a way to make the app do work a GET would refuse. |
| +// csrf.Protect only guards state-changing methods, so the check that matters |
| +// is that the signup endpoint still refuses anything but POST. |
| +func TestSignupIgnoresHead(t *testing.T) { |
| + h := newApp(t) |
| + |
| + req := httptest.NewRequest(http.MethodHead, "/signup", nil) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + |
| + if rec.Code == http.StatusOK { |
| + t.Error("HEAD /signup answered 200; only POST should reach the handler") |
| + } |
| + if got := countNonces(t, h); got != 0 { |
| + t.Errorf("a HEAD request spent %d nonces", got) |
| + } |
| +} |