From 6360b4958c1e54b8707ac2066f95341daf8b5798 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Thu, 22 Oct 2026 00:48:50 +0000 Subject: [PATCH] api/v2: ignore unknown properties in request bodies Tailscale's API ignores them. --- hscontrol/api/v2/api.go | 9 +++++ hscontrol/api/v2/devices.go | 13 +++++- hscontrol/api/v2/keys.go | 9 +++++ hscontrol/apiv2_compat_test.go | 6 +-- hscontrol/apiv2_oauth_test.go | 73 +++++++++++++++++++++++++++------- 5 files changed, 90 insertions(+), 20 deletions(-) diff --git a/hscontrol/api/v2/api.go b/hscontrol/api/v2/api.go index 122bcf7bc..ecc3ea4f0 100644 --- a/hscontrol/api/v2/api.go +++ b/hscontrol/api/v2/api.go @@ -132,6 +132,15 @@ func Spec() ([]byte, error) { func Spec30() ([]byte, error) { api := NewAPI(chi.NewMux(), Backend{}) + // additionalProperties:true is the default, and spelled out it makes + // oapi-codegen write marshalers that drop a nil required list, sending + // {} where the server needs "tags":null. + for _, s := range api.OpenAPI().Components.Schemas.Map() { + if s.AdditionalProperties == true { + s.AdditionalProperties = nil + } + } + return api.OpenAPI().DowngradeYAML() } diff --git a/hscontrol/api/v2/devices.go b/hscontrol/api/v2/devices.go index febff5b72..f3498fb19 100644 --- a/hscontrol/api/v2/devices.go +++ b/hscontrol/api/v2/devices.go @@ -53,24 +53,35 @@ type DeviceRoutes struct { Enabled []string `json:"enabledRoutes" nullable:"false"` } -// Request bodies match the Tailscale SDK wire shapes. +// Request bodies match the Tailscale SDK wire shapes and, like Tailscale's, +// ignore properties they do not define. type ( setAuthorizedRequest struct { + _ struct{} `additionalProperties:"true"` + Authorized bool `json:"authorized"` } setNameRequest struct { + _ struct{} `additionalProperties:"true"` + Name string `json:"name"` } // setTagsRequest.Tags is intentionally not nullable:"false": the SDK sends // "tags":null for "make untagged", which the handler accepts as a no-op // rather than failing to decode (Headscale cannot untag a node). setTagsRequest struct { + _ struct{} `additionalProperties:"true"` + Tags []string `json:"tags"` } setKeyRequest struct { + _ struct{} `additionalProperties:"true"` + KeyExpiryDisabled bool `json:"keyExpiryDisabled"` } setSubnetRoutesRequest struct { + _ struct{} `additionalProperties:"true"` + Routes []string `json:"routes" nullable:"false"` } ) diff --git a/hscontrol/api/v2/keys.go b/hscontrol/api/v2/keys.go index 2cfbb8f13..e14376f9b 100644 --- a/hscontrol/api/v2/keys.go +++ b/hscontrol/api/v2/keys.go @@ -33,14 +33,20 @@ const ( // populates only devices.create (auth keys); the named types (vs Tailscale's // anonymous nesting) give Huma stable schema names. type KeyCapabilities struct { + _ struct{} `additionalProperties:"true"` + Devices KeyDeviceCapabilities `json:"devices"` } type KeyDeviceCapabilities struct { + _ struct{} `additionalProperties:"true"` + Create KeyDeviceCreateCapabilities `json:"create"` } type KeyDeviceCreateCapabilities struct { + _ struct{} `additionalProperties:"true"` + Reusable bool `json:"reusable"` Ephemeral bool `json:"ephemeral"` Preauthorized bool `json:"preauthorized"` @@ -55,6 +61,9 @@ type KeyDeviceCreateCapabilities struct { // OAuth client from the top-level Scopes and Tags. expirySeconds is plain // seconds (unlike the response, see Key). type CreateKeyRequest struct { + // Unknown properties are ignored, as Tailscale's API does. + _ struct{} `additionalProperties:"true"` + KeyType string `doc:"Key kind: \"auth\" (default) or \"client\" (OAuth client)." json:"keyType,omitempty"` // Capabilities is optional: an auth key carries device-create capabilities, // but an OAuth client (keyType:"client") has none and the Tailscale clients diff --git a/hscontrol/apiv2_compat_test.go b/hscontrol/apiv2_compat_test.go index 44803ab7f..f0b60dd28 100644 --- a/hscontrol/apiv2_compat_test.go +++ b/hscontrol/apiv2_compat_test.go @@ -71,10 +71,6 @@ var apiv2CompatSkipReasons = map[string]string{ "key-list/setup-token-keys/4/tags": mintTagsNotEchoed, "key-list/setup-token-keys/6/tags": mintTagsNotEchoed, - "oauth/K1/0/status": unknownPropertyRefused, - "oauth/K1/0/outcome": unknownPropertyRefused, - "oauth/K1/1/status": unknownPropertyRefused, - "oauth/K1/1/outcome": unknownPropertyRefused, "oauth/K2/0/status": unknownKeyType, "oauth/K2/0/outcome": unknownKeyType, "oauth/K5/0/status": unknownKeyType, @@ -119,6 +115,7 @@ var apiv2CompatSkipReasons = map[string]string{ "oauth/key:K-ci/creator-user-id": taggedKeyNoUserID, "oauth/key:K-mixed/creator-user-id": taggedKeyNoUserID, "oauth/key:K-prod/creator-user-id": taggedKeyNoUserID, + "oauth/key:K1/creator-user-id": taggedKeyNoUserID, "policy-api/key:J-server/creator-user-id": taggedKeyNoUserID, "key-list/key:A-db/creator-user-id": taggedKeyNoUserID, @@ -217,7 +214,6 @@ const ( const ( mintScopeRefusalStatus = "TOKEN_SCOPE_REFUSAL_STATUS: SaaS refuses a token scope the client lacks with 403 and a message; headscale with 400 invalid_scope" mintTagsNotEchoed = "TOKEN_TAGS_NOT_ECHOED: SaaS's token answer names the token's tags; headscale's has no tags" - unknownPropertyRefused = "UNKNOWN_PROPERTY_REFUSED: SaaS ignores a createKey property it does not define; headscale refuses the body with 422, so the key is never made" unknownKeyType = "UNKNOWN_KEY_TYPE: SaaS refuses an unknown keyType with 400 before it checks scopes; headscale makes an auth key from it" tagRefusalStatus = "TAG_REFUSAL_STATUS: SaaS refuses a tag outside the token's grant with 400 requested tags are invalid or not permitted; headscale with 403" keyListWithoutAll = "KEY_LIST_WITHOUT_ALL: without all=true SaaS lists an OAuth token only the auth keys OAuth tokens made, whatever its tags and theirs; headscale lists every key" diff --git a/hscontrol/apiv2_oauth_test.go b/hscontrol/apiv2_oauth_test.go index f3dbc9434..1f04a2975 100644 --- a/hscontrol/apiv2_oauth_test.go +++ b/hscontrol/apiv2_oauth_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + clientv2 "github.com/juanfont/headscale/gen/client/v2" apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" "github.com/juanfont/headscale/hscontrol/scope" "github.com/juanfont/headscale/hscontrol/types/testcapture" @@ -245,29 +246,73 @@ 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. +// TestAPIv2OAuth_ValidationErrorsBounded: huma reports one error per bad +// element of the body, and each carries the element 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 + tags := make([]map[string]string, 1000) + for i := range tags { + tags[i] = map[string]string{"pad": fmt.Sprintf("%040d", i)} } - 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[") + body := map[string]any{"capabilities": map[string]any{"devices": map[string]any{ + "create": map[string]any{"reusable": true, "ephemeral": false, "preauthorized": true, "tags": tags}, + }}} - var resp struct { + status, resp := apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", tok, body) + require.Equal(t, http.StatusUnprocessableEntity, status) + require.Less(t, len(resp), 16<<10, "the error answer grows with the request") + assert.NotContains(t, string(resp), "map[") + + var out 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]) + require.NoError(t, json.Unmarshal(resp, &out)) + require.Len(t, out.Data, 1) + require.Len(t, out.Data[0].Errors, 21) + assert.Equal(t, "and 980 more", out.Data[0].Errors[20]) +} + +// TestAPIv2OAuth_UnknownPropertiesIgnored: a body property the API does not +// define is ignored, at the top and nested, as Tailscale's API does. +func TestAPIv2OAuth_UnknownPropertiesIgnored(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + status, body := apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", admin, map[string]any{ + "deviceDescription": "x", + "capabilities": map[string]any{"devices": map[string]any{ + "create": map[string]any{ + "reusable": true, "ephemeral": false, "preauthorized": true, + "tags": []string{"tag:ci"}, "unknown": true, + }, + }}, + }) + require.Equalf(t, http.StatusOK, status, "body: %s", body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(body, &key)) + assert.Equal(t, []string{"tag:ci"}, key.Capabilities.Devices.Create.Tags) +} + +// TestAPIv2OAuth_GeneratedClientNilTags: the generated client's request +// types send a nil tags list as null, which the server takes as no tags. +func TestAPIv2OAuth_GeneratedClientNilTags(t *testing.T) { + e := newDeviceTestEnv(t) + + resp := e.api.Post("/api/v2/device/"+e.deviceID+"/tags", clientv2.SetTagsRequest{}) + require.Equalf(t, http.StatusOK, resp.Code, "body: %s", resp.Body) + + app, baseURL, _ := newOAuthTestServer(t) + owner := app.state.CreateUserForTest("odin") + ownerKey, apiKey, err := app.state.CreateAPIKey(nil) + require.NoError(t, err) + require.NoError(t, app.state.SetAPIKeyUser(apiKey.ID, *owner.TypedID())) + + status, body := apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", ownerKey, + clientv2.CreateKeyRequest{Capabilities: &clientv2.KeyCapabilities{}}) + require.Equalf(t, http.StatusOK, status, "body: %s", body) } func TestAPIv2OAuth_ScopeEnforcement(t *testing.T) {