From bae4f5721584c867a0d1c7cbc53445daa00f9279 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 17 Oct 2026 15:46:23 +0000 Subject: [PATCH] api/v2: stop echoing request values in error responses huma sets an unexpected property's value to the whole request object. A refused policy, or another 400, now carries its reason in message, and a failed test run one data entry per failing test naming its src. --- hscontrol/api/v2/errors.go | 98 ++++++++++++++--------- hscontrol/apiv2_acl_test.go | 59 ++++++++++++++ hscontrol/apiv2_compat_test.go | 8 -- hscontrol/apiv2_oauth_test.go | 25 ++++++ hscontrol/servertest/names_compat_test.go | 6 -- 5 files changed, 146 insertions(+), 50 deletions(-) diff --git a/hscontrol/api/v2/errors.go b/hscontrol/api/v2/errors.go index 1ddfc532d..00e28f71a 100644 --- a/hscontrol/api/v2/errors.go +++ b/hscontrol/api/v2/errors.go @@ -2,6 +2,7 @@ package apiv2 import ( "errors" + "net/http" "strconv" "strings" @@ -30,7 +31,8 @@ type apiErrorData struct { Errors []string `json:"errors,omitempty"` } -// maxErrorDetails caps the failing tests one answer lists. +// maxErrorDetails caps the errors, or failing tests, one response lists. huma +// reports one error per bad element of the body, so the caller picks how many. const maxErrorDetails = 20 // tailscaleErrorTransformer rewrites Huma's RFC 9457 error model into the @@ -50,50 +52,38 @@ func tailscaleErrorTransformer(_ huma.Context, _ string, v any) (any, error) { out := apiError{Message: message, Status: em.Status} - if len(em.Errors) > 0 { - details := make([]string, 0, len(em.Errors)) - for _, d := range em.Errors { - details = append(details, d.Error()) + listed := em.Errors[:min(len(em.Errors), maxErrorDetails)] + + details := make([]string, 0, len(listed)+1) + for _, d := range listed { + // huma stores a nil detail for a nil error argument. + if d == nil { + continue } + // The value is not echoed: for an unexpected property it is the whole + // request object, once per property. + if d.Location == "" { + details = append(details, d.Message) + } else { + details = append(details, d.Message+" ("+d.Location+")") + } + } + + if more := len(em.Errors) - len(listed); more > 0 { + details = append(details, "and "+strconv.Itoa(more)+" more") + } + + if len(details) > 0 { out.Data = []apiErrorData{{Errors: details}} } return out, nil } -// mapError translates a state/db-layer error into a Huma HTTP error -// (not-found→404, invalid input→400, everything else→500). The transformer -// then reshapes it into the Tailscale body. -func mapError(msg string, err error) error { - if err == nil { - return nil - } +func (e *apiError) Error() string { return e.Message } - switch { - case errors.Is(err, gorm.ErrRecordNotFound), - errors.Is(err, db.ErrPreAuthKeyNotFound), - errors.Is(err, db.ErrUserNotFound), - errors.Is(err, state.ErrNodeNotFound): - return huma.Error404NotFound(msg, err) - - case errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned), - errors.Is(err, db.ErrPreAuthKeyACLTagInvalid), - errors.Is(err, db.ErrOAuthClientScopeInvalid), - errors.Is(err, state.ErrGivenNameInvalid), - errors.Is(err, state.ErrGivenNameTaken), - errors.Is(err, state.ErrNodeNameNotUnique), - errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted): - return huma.Error400BadRequest(msg, err) - - // A refused policy is answered with its own reason as the message. - case errors.Is(err, state.ErrInvalidPolicy): - return huma.Error400BadRequest(err.Error(), err) - - default: - return huma.Error500InternalServerError(msg, err) - } -} +func (e *apiError) GetStatus() int { return e.Status } // policyRefusal is a refused policy as Tailscale reports it: the reason as // message and, when tests failed, one data entry per failing test naming its @@ -129,3 +119,39 @@ func policyRefusal(err error) (string, []apiErrorData) { return "test(s) failed", data } + +// mapError translates a state/db-layer error into a Huma HTTP error +// (not-found→404, invalid input→400, everything else→500). The transformer +// then reshapes it into the Tailscale body. +func mapError(msg string, err error) error { + if err == nil { + return nil + } + + switch { + case errors.Is(err, gorm.ErrRecordNotFound), + errors.Is(err, db.ErrPreAuthKeyNotFound), + errors.Is(err, db.ErrUserNotFound), + errors.Is(err, state.ErrNodeNotFound): + return huma.Error404NotFound(msg, err) + + case errors.Is(err, db.ErrPreAuthKeyNotTaggedOrOwned), + errors.Is(err, db.ErrPreAuthKeyACLTagInvalid), + errors.Is(err, db.ErrOAuthClientScopeInvalid), + errors.Is(err, state.ErrGivenNameInvalid), + errors.Is(err, state.ErrGivenNameTaken), + errors.Is(err, state.ErrNodeNameNotUnique), + errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted): + // As on Tailscale SaaS, the reason is the message. + return huma.Error400BadRequest(err.Error(), err) + + // A refused policy is answered with its own reason as the message. + case errors.Is(err, state.ErrInvalidPolicy): + message, data := policyRefusal(err) + + return &apiError{Message: message, Status: http.StatusBadRequest, Data: data} + + default: + return huma.Error500InternalServerError(msg, err) + } +} diff --git a/hscontrol/apiv2_acl_test.go b/hscontrol/apiv2_acl_test.go index 1ccf4d4e0..b7956ff54 100644 --- a/hscontrol/apiv2_acl_test.go +++ b/hscontrol/apiv2_acl_test.go @@ -245,6 +245,53 @@ func TestAPIv2ACLPersistFailureKeepsLivePolicy(t *testing.T) { assert.Equal(t, before, after, "an unstored policy must not be enforced") } +// TestAPIv2ACLRefusalEnvelope: a refused policy answers with the reason in +// message and, when tests fail, one data entry per failing test naming its +// src. +func TestAPIv2ACLRefusalEnvelope(t *testing.T) { + app := createTestApp(t) + app.state.CreateUserForTest("alice") + api := registerAPIV2(t, app) + + type refusal struct { + Message string `json:"message"` + Data []struct { + User string `json:"user"` + Errors []string `json:"errors"` + } `json:"data"` + } + + decode := func(resp *httptest.ResponseRecorder) refusal { + t.Helper() + require.Equalf(t, http.StatusBadRequest, resp.Code, "body: %s", resp.Body) + + var r refusal + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &r)) + + return r + } + + parse := decode(setACL(t, api, `{ this is not valid`, "application/json")) + assert.NotEmpty(t, parse.Message) + assert.NotEqual(t, "setting policy", parse.Message, "the reason is the message") + assert.Empty(t, parse.Data) + + tests := decode(setACL(t, api, `{ + "acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}], + "tests": [ + {"src": "alice@", "deny": ["100.64.0.2:22"]}, + {"src": "100.64.0.1", "accept": ["100.64.0.2:22"]}, + {"src": "100.64.0.3", "deny": ["100.64.0.2:22", "100.64.0.4:22"]} + ] + }`, "application/json")) + assert.Equal(t, "test(s) failed", tests.Message) + require.Len(t, tests.Data, 2) + assert.Equal(t, "alice", tests.Data[0].User) + assert.Len(t, tests.Data[0].Errors, 1) + assert.Equal(t, "100.64.0.3", tests.Data[1].User) + assert.Len(t, tests.Data[1].Errors, 2) +} + // TestAPIv2ACLValidate: validate stores nothing and answers a refused policy // with 200, its reason as message and one data entry per failing test; only // a body with no policy at all is a 400. @@ -303,6 +350,18 @@ func TestAPIv2ACLNonDefaultTailnet404(t *testing.T) { api.Post("/api/v2/tailnet/example.com/acl", bytes.NewReader([]byte(allowAllPolicy))).Code) } +// TestAPIv2ACLUnknownPolicyMode: the handler's 500 carries a nil error detail, +// which the error transformer must not dereference. +func TestAPIv2ACLUnknownPolicyMode(t *testing.T) { + cfg := &types.Config{Policy: types.PolicyConfig{Mode: "unknown"}} + _, api := humatest.New(t, apiv2.Config()) + apiv2.Register(api, apiv2.Backend{Cfg: cfg}) + + get := api.Get("/api/v2/tailnet/-/acl") + assert.Equal(t, http.StatusInternalServerError, get.Code) + assert.Contains(t, get.Body.String(), "unsupported policy mode") +} + func TestAPIv2ACLFileModeReadOnly(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "acl.hujson") diff --git a/hscontrol/apiv2_compat_test.go b/hscontrol/apiv2_compat_test.go index d6c9eaa84..b3e2915e1 100644 --- a/hscontrol/apiv2_compat_test.go +++ b/hscontrol/apiv2_compat_test.go @@ -80,9 +80,6 @@ var apiv2CompatSkipReasons = map[string]string{ "oauth/K2/0/outcome": unknownKeyType, "oauth/K5/0/status": unknownKeyType, - "oauth/J3/1/detail": refusalDetail, - "oauth/J4/0/detail": refusalDetail, - "oauth/E4/0/status": tagRefusalStatus, "oauth/E5/0/status": tagRefusalStatus, "oauth/T2/1/status": tagRefusalStatus, @@ -168,10 +165,6 @@ var apiv2CompatSkipReasons = map[string]string{ "oauth/device:mixed/empty-route-lists": emptyRouteListsOmitted, "oauth/device:prod-db/empty-route-lists": emptyRouteListsOmitted, - "policy-api/P3/1/detail": refusalDetail, - "policy-api/P5-R1/1/detail": refusalDetail, - "policy-api/P5-R2/1/detail": refusalDetail, - "policy-api/P7/0/body": hujsonFormatted, "policy-api/P7/1/body": hujsonFormatted, } @@ -227,7 +220,6 @@ const ( deviceOAuthClientID = "DEVICE_OAUTH_CLIENT_ID: SaaS device bodies carry oauthClientId, naming the join key or the OAuth client that made it; headscale's have no such field" taggedDeviceUser = "TAGGED_DEVICE_USER: SaaS shows a tagged device's user as the login that made its key, empty for a key a token made; headscale shows tagged-devices" emptyRouteListsOmitted = "EMPTY_ROUTE_LISTS_OMITTED: SaaS sends advertisedRoutes and enabledRoutes as [] for a device without routes under fields=all; headscale leaves both out" - refusalDetail = "REFUSAL_DETAIL: SaaS puts a refused policy's reason in message, with one detail per failing test naming the user; headscale answers setting policy with the reason as one detail without a user" hujsonFormatted = "HUJSON_FORMATTED: SaaS stores a HuJSON policy formatted, comments kept; headscale stores the bytes as sent" ) diff --git a/hscontrol/apiv2_oauth_test.go b/hscontrol/apiv2_oauth_test.go index f585f13f2..39db71454 100644 --- a/hscontrol/apiv2_oauth_test.go +++ b/hscontrol/apiv2_oauth_test.go @@ -240,6 +240,31 @@ func TestAPIv2OAuth_RequestBodiesBounded(t *testing.T) { assert.Equal(t, http.StatusRequestEntityTooLarge, status) } +// TestAPIv2OAuth_ValidationErrorsBounded: huma reports one error per unexpected +// property, and each carries the whole request object as its value. +func TestAPIv2OAuth_ValidationErrorsBounded(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + tok := mintScopedToken(t, baseURL, admin, scope.AuthKeys) + + props := map[string]int{} + for i := range 1000 { + props[fmt.Sprintf("%040d", i)] = 0 + } + + status, body := apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", tok, props) + require.Equal(t, http.StatusUnprocessableEntity, status) + require.Less(t, len(body), 16<<10, "the error answer grows with the request") + assert.NotContains(t, string(body), "map[") + + var resp struct { + Data []struct{ Errors []string } + } + require.NoError(t, json.Unmarshal(body, &resp)) + require.Len(t, resp.Data, 1) + require.Len(t, resp.Data[0].Errors, 21) + assert.Equal(t, "and 980 more", resp.Data[0].Errors[20]) +} + func TestAPIv2OAuth_ScopeEnforcement(t *testing.T) { _, baseURL, admin := newOAuthTestServer(t) diff --git a/hscontrol/servertest/names_compat_test.go b/hscontrol/servertest/names_compat_test.go index da3bf98b8..d7846a504 100644 --- a/hscontrol/servertest/names_compat_test.go +++ b/hscontrol/servertest/names_compat_test.go @@ -60,16 +60,10 @@ var nameSkipReasons = map[string]string{ "names/N12a/pdb/label": adminNameFollowsHostname, "names/N12b/pdb/label": adminNameFollowsHostname, "names/N13/pdb/label": adminNameFollowsHostname, - - "names/N9a/0/message": nameTakenText, - "names/N9b/0/message": nameTakenText, - - "names-svc/S5/0/message": nameTakenText, } const ( adminNameFollowsHostname = "ADMIN_NAME_FOLLOWS_HOSTNAME: an admin name equal to the hostname's label counts as derived from it, so a hostname change replaces it; SaaS keeps an admin name across hostname changes" - nameTakenText = "NAME_TAKEN_TEXT: headscale's v2 message for a rename refused as taken is the operation, with the reason only in data; SaaS's message says the name is already taken" v2NameBare = "V2_NAME_BARE: headscale's v2 device name is the bare label; SaaS's is the FQDN under the tailnet domain" )