api/v2: ignore unknown properties in request bodies

Tailscale's API ignores them.
This commit is contained in:
Kristoffer Dalby committed 2026-10-30 14:55:37 +00:00
1 parent d84dabcbda
commit e13ed03e70
5 files changed
+90 -20

No files matched your search

+9
View File
@@ -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()
}
+12 -1
View File
@@ -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"`
}
)
+9
View File
@@ -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
+1 -5
View File
@@ -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,
@@ -205,7 +202,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"
+59 -14
View File
@@ -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) {