carlos / movement Public

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>
Paul Campbell 3a5bf5a020f93e564f72b57a34edbb7d8aef3945 parent 1338bbc
2 files changed, +52 −0
  • internal/movement/app.go +6 −0
  • internal/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)
+ }
+}