handlers: set verify Content-Type before writing the body

This commit is contained in:
Kristoffer Dalby
2026-06-16 08:29:20 +00:00
parent 4b693a1320
commit e4cd846075
2 changed files with 76 additions and 2 deletions
+7 -2
View File
@@ -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
+69
View File
@@ -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) {