From e4cd846075bc6e41d262e3f0e914751eba715ac8 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 08:29:20 +0000 Subject: [PATCH] handlers: set verify Content-Type before writing the body --- hscontrol/handlers.go | 9 +++-- hscontrol/handlers_test.go | 69 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/hscontrol/handlers.go b/hscontrol/handlers.go index ccae0983..242673b3 100644 --- a/hscontrol/handlers.go +++ b/hscontrol/handlers.go @@ -172,13 +172,18 @@ func (h *Headscale) VerifyHandler( req.Body = http.MaxBytesReader(writer, req.Body, verifyBodyLimit) + // Set the Content-Type before any body byte is written. The first + // Write in handleVerifyRequest triggers an implicit WriteHeader that + // snapshots the header map, so setting it afterwards is a no-op. The + // error path resets the Content-Type via http.Error, so error + // responses remain text/plain. + writer.Header().Set("Content-Type", "application/json") + err := h.handleVerifyRequest(req, writer) if err != nil { httpError(writer, err) return } - - writer.Header().Set("Content-Type", "application/json") } // KeyHandler provides the Headscale pub key diff --git a/hscontrol/handlers_test.go b/hscontrol/handlers_test.go index c1a4a094..9fe315c2 100644 --- a/hscontrol/handlers_test.go +++ b/hscontrol/handlers_test.go @@ -3,13 +3,19 @@ package hscontrol import ( "bytes" "context" + "encoding/json" "errors" "net/http" "net/http/httptest" + "net/netip" "strings" "testing" + "github.com/juanfont/headscale/hscontrol/types" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + "tailscale.com/types/key" ) var errTestUnexpected = errors.New("unexpected failure") @@ -47,6 +53,69 @@ func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) { "oversized body must surface 413") } +// TestVerifyHandler_SuccessSetsJSONContentType verifies that a successful +// POST to /verify advertises Content-Type: application/json. The header +// must be set before the JSON body is written, otherwise the implicit +// WriteHeader on first Write locks in a sniffed content type and the +// later Header().Set becomes a no-op. +func TestVerifyHandler_SuccessSetsJSONContentType(t *testing.T) { + tmpDir := t.TempDir() + + prefixV4 := netip.MustParsePrefix("100.64.0.0/10") + prefixV6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48") + + cfg := &types.Config{ + ServerURL: "http://localhost:0", + NoisePrivateKeyPath: tmpDir + "/noise_private.key", + PrefixV4: &prefixV4, + PrefixV6: &prefixV6, + IPAllocation: types.IPAllocationStrategySequential, + Database: types.DatabaseConfig{ + Type: "sqlite3", + Sqlite: types.SqliteConfig{ + Path: tmpDir + "/headscale_test.db", + }, + }, + Policy: types.PolicyConfig{ + Mode: types.PolicyModeDB, + }, + } + + h, err := NewHeadscale(cfg) + require.NoError(t, err) + + reqBody, err := json.Marshal(tailcfg.DERPAdmitClientRequest{ + NodePublic: key.NewNode().Public(), + }) + require.NoError(t, err) + + // A real HTTP server is required to observe the bug: the first body + // Write triggers an implicit WriteHeader that snapshots the header + // map, so a Content-Type set afterwards never reaches the wire. + // An httptest.ResponseRecorder does not snapshot, so it would hide + // the defect. + srv := httptest.NewServer(http.HandlerFunc(h.VerifyHandler)) + defer srv.Close() + + httpReq, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + srv.URL+"/verify", + bytes.NewReader(reqBody), + ) + require.NoError(t, err) + httpReq.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(httpReq) + require.NoError(t, err) + + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "application/json", resp.Header.Get("Content-Type"), + "successful /verify response must advertise application/json") +} + // errorAsHTTPError is a small local helper that unwraps an [HTTPError] // from an error chain. func errorAsHTTPError(err error) (HTTPError, bool) {