mirror of
https://github.com/juanfont/headscale.git
synced 2026-09-10 02:31:59 +09:00
2c76d5c5b7
Go 1.27 allows setting promoted fields of embedded structs directly in composite literals, so gorm.Model wrappers go away. Plus strings.Cut, errors.AsType, reflect.TypeAssert and one gofumpt nit.
246 lines
7.4 KiB
Go
246 lines
7.4 KiB
Go
package hscontrol
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/juanfont/headscale/hscontrol/capver"
|
|
"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")
|
|
|
|
// TestHandleVerifyRequest_OversizedBodyRejected verifies that the
|
|
// /verify handler refuses POST bodies larger than [verifyBodyLimit].
|
|
// The [http.MaxBytesReader] is applied in [Headscale.VerifyHandler], so we simulate
|
|
// the same wrapping here.
|
|
func TestHandleVerifyRequest_OversizedBodyRejected(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
body := strings.Repeat("x", int(verifyBodyLimit)+128)
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodPost,
|
|
"/verify",
|
|
bytes.NewReader([]byte(body)),
|
|
)
|
|
req.Body = http.MaxBytesReader(rec, req.Body, verifyBodyLimit)
|
|
|
|
h := &Headscale{}
|
|
|
|
err := h.handleVerifyRequest(req, &bytes.Buffer{})
|
|
if err == nil {
|
|
t.Fatal("oversized verify body must be rejected")
|
|
}
|
|
|
|
httpErr, ok := errorAsHTTPError(err)
|
|
if !ok {
|
|
t.Fatalf("error must be an HTTPError, got: %T (%v)", err, err)
|
|
}
|
|
|
|
assert.Equal(t, http.StatusRequestEntityTooLarge, httpErr.Code,
|
|
"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")
|
|
}
|
|
|
|
// TestKeyHandler_UnsupportedCapVerDoesNotLeakKey reproduces
|
|
// https://github.com/juanfont/headscale/issues/3380. The /key handler
|
|
// must gate key disclosure on the same floor the Noise handshake
|
|
// enforces (capver.MinSupportedCapabilityVersion). A capability version
|
|
// below that floor can never complete a handshake, so it must be
|
|
// rejected rather than handed the server's Noise public key, which would
|
|
// otherwise serve only as a fingerprint / version-boundary oracle.
|
|
func TestKeyHandler_UnsupportedCapVerDoesNotLeakKey(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
noise := key.NewMachine()
|
|
h := &Headscale{noisePrivateKey: &noise}
|
|
|
|
unsupported := capver.MinSupportedCapabilityVersion - 1
|
|
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet,
|
|
fmt.Sprintf("/key?v=%d", unsupported),
|
|
nil,
|
|
)
|
|
|
|
h.KeyHandler(rec, req)
|
|
|
|
assert.Equal(t, http.StatusBadRequest, rec.Code,
|
|
"a client below the supported floor must be rejected")
|
|
assert.NotContains(t, rec.Body.String(), noise.Public().String(),
|
|
"must not disclose Noise public key to a client below the supported floor")
|
|
|
|
// A supported client still receives the key.
|
|
recOK := httptest.NewRecorder()
|
|
reqOK := httptest.NewRequestWithContext(
|
|
context.Background(),
|
|
http.MethodGet,
|
|
fmt.Sprintf("/key?v=%d", capver.MinSupportedCapabilityVersion),
|
|
nil,
|
|
)
|
|
|
|
h.KeyHandler(recOK, reqOK)
|
|
|
|
assert.Equal(t, http.StatusOK, recOK.Code)
|
|
assert.Contains(t, recOK.Body.String(), noise.Public().String(),
|
|
"a supported client must receive the Noise public key")
|
|
}
|
|
|
|
// errorAsHTTPError is a small local helper that unwraps an [HTTPError]
|
|
// from an error chain.
|
|
func errorAsHTTPError(err error) (HTTPError, bool) {
|
|
if h, ok := errors.AsType[HTTPError](err); ok {
|
|
return h, true
|
|
}
|
|
|
|
return HTTPError{}, false
|
|
}
|
|
|
|
func TestHttpUserError(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
wantCode int
|
|
wantContains string
|
|
wantNotContain string
|
|
}{
|
|
{
|
|
name: "forbidden_renders_authorization_message",
|
|
err: NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil),
|
|
wantCode: http.StatusForbidden,
|
|
wantContains: "You are not authorized. Please contact your administrator.",
|
|
wantNotContain: "csrf token mismatch",
|
|
},
|
|
{
|
|
name: "unauthorized_renders_authorization_message",
|
|
err: NewHTTPError(http.StatusUnauthorized, "unauthorised domain", nil),
|
|
wantCode: http.StatusUnauthorized,
|
|
wantContains: "You are not authorized. Please contact your administrator.",
|
|
wantNotContain: "unauthorised domain",
|
|
},
|
|
{
|
|
name: "gone_renders_session_expired",
|
|
err: NewHTTPError(http.StatusGone, "login session expired, try again", nil),
|
|
wantCode: http.StatusGone,
|
|
wantContains: "Your session has expired. Please try again.",
|
|
wantNotContain: "login session expired",
|
|
},
|
|
{
|
|
name: "bad_request_renders_generic_retry",
|
|
err: NewHTTPError(http.StatusBadRequest, "state not found", nil),
|
|
wantCode: http.StatusBadRequest,
|
|
wantContains: "The request could not be processed. Please try again.",
|
|
wantNotContain: "state not found",
|
|
},
|
|
{
|
|
name: "plain_error_renders_500",
|
|
err: errTestUnexpected,
|
|
wantCode: http.StatusInternalServerError,
|
|
wantContains: "Something went wrong. Please try again later.",
|
|
},
|
|
{
|
|
name: "html_structure_present",
|
|
err: NewHTTPError(http.StatusGone, "session expired", nil),
|
|
wantCode: http.StatusGone,
|
|
wantContains: "<!DOCTYPE html>",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rec := httptest.NewRecorder()
|
|
httpUserError(rec, tt.err)
|
|
|
|
assert.Equal(t, tt.wantCode, rec.Code)
|
|
assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
|
|
assert.Contains(t, rec.Body.String(), tt.wantContains)
|
|
|
|
if tt.wantNotContain != "" {
|
|
assert.NotContains(t, rec.Body.String(), tt.wantNotContain)
|
|
}
|
|
})
|
|
}
|
|
}
|