api/v2: limit request bodies and redact schema-validation details

Bound schema, raw, and token-form bodies before parsing. Omit submitted
values from schema errors while preserving policy reasons and test labels.
This commit is contained in:
Kristoffer Dalby committed 2026-11-02 17:46:54 +00:00
1 parent ce67867512
commit 8caa04ba93
11 files changed
+229 -57

No files matched your search

+8
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"strings"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/juanfont/headscale/hscontrol/scope"
@@ -103,6 +104,10 @@ func registerACL(api huma.API, b Backend) {
// The body is an opaque HuJSON document captured raw; skip huma's
// schema validation (which would expect a base64 string).
SkipValidateBody: true,
// huma's own Body defaults, which it never applies to RawBody; also
// what v1 setPolicy accepts.
MaxBodyBytes: 1 << 20,
BodyReadTimeout: 5 * time.Second,
Errors: []int{
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
http.StatusNotFound, http.StatusPreconditionFailed, http.StatusInternalServerError,
@@ -154,6 +159,9 @@ func registerValidateACL(api huma.API, b Backend, aclTags []string) {
Security: security,
DefaultStatus: http.StatusOK,
SkipValidateBody: true,
// As setACL: huma applies no body limit to RawBody.
MaxBodyBytes: 1 << 20,
BodyReadTimeout: 5 * time.Second,
Errors: []int{
http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
http.StatusNotFound, http.StatusInternalServerError,
+5
View File
@@ -45,6 +45,11 @@ type Backend struct {
// or as a Bearer token.
var security = []map[string][]string{{"basicAuth": {}}, {"bearerAuth": {}}}
// maxBodyBytes replaces huma's 1 MiB default on operations whose body huma
// validates: validation records an error per bad element before the handler
// runs, and a real body is a few names, tags or routes.
const maxBodyBytes = 64 << 10
// registrations is populated by each resource file's init(), so adding a
// resource means adding a file rather than editing a shared list.
var registrations []func(huma.API, Backend)
+5
View File
@@ -195,6 +195,7 @@ func registerDevices(api huma.API, b Backend) {
Tags: deviceTags,
Security: security,
DefaultStatus: http.StatusOK,
MaxBodyBytes: maxBodyBytes,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesCore), func(ctx context.Context, in *setAuthorizedInput) (*emptyOutput, error) {
_, err := lookupNode(b, in.DeviceID)
@@ -222,6 +223,7 @@ func registerDevices(api huma.API, b Backend) {
Tags: deviceTags,
Security: security,
DefaultStatus: http.StatusOK,
MaxBodyBytes: maxBodyBytes,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesCore), func(ctx context.Context, in *setNameInput) (*emptyOutput, error) {
node, err := lookupNode(b, in.DeviceID)
@@ -247,6 +249,7 @@ func registerDevices(api huma.API, b Backend) {
Tags: deviceTags,
Security: security,
DefaultStatus: http.StatusOK,
MaxBodyBytes: maxBodyBytes,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesCore), func(ctx context.Context, in *setTagsInput) (*emptyOutput, error) {
node, err := lookupNode(b, in.DeviceID)
@@ -293,6 +296,7 @@ func registerDevices(api huma.API, b Backend) {
Tags: deviceTags,
Security: security,
DefaultStatus: http.StatusOK,
MaxBodyBytes: maxBodyBytes,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesCore), func(ctx context.Context, in *setKeyInput) (*emptyOutput, error) {
node, err := lookupNode(b, in.DeviceID)
@@ -326,6 +330,7 @@ func registerDevices(api huma.API, b Backend) {
Tags: deviceTags,
Security: security,
DefaultStatus: http.StatusOK,
MaxBodyBytes: maxBodyBytes,
Errors: []int{http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound},
}, scope.DevicesRoutes), func(ctx context.Context, in *setSubnetRoutesInput) (*deviceRoutesOutput, error) {
node, err := lookupNode(b, in.DeviceID)
+62 -36
View File
@@ -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)
}
}
+21
View File
@@ -47,6 +47,27 @@ func TestEveryAuthenticatedOperationDeclaresScope(t *testing.T) {
}
}
// TestEveryRequestBodyIsBounded fails an operation whose body has no size or
// read-time limit, or a validated body above maxBodyBytes. huma sets its
// defaults only for a Body input, never for RawBody.
func TestEveryRequestBodyIsBounded(t *testing.T) {
api := NewAPI(chi.NewMux(), Backend{})
for path, item := range api.OpenAPI().Paths {
for method, op := range humaOperations(item) {
if op == nil || op.RequestBody == nil {
continue
}
if op.MaxBodyBytes <= 0 || op.BodyReadTimeout <= 0 ||
(!op.SkipValidateBody && op.MaxBodyBytes > maxBodyBytes) {
t.Errorf("%s %s: MaxBodyBytes=%d BodyReadTimeout=%s",
method, path, op.MaxBodyBytes, op.BodyReadTimeout)
}
}
}
}
func humaOperations(item *huma.PathItem) map[string]*huma.Operation {
return map[string]*huma.Operation{
"GET": item.Get,
+8 -7
View File
@@ -126,13 +126,14 @@ func registerKeys(api huma.API, b Backend) {
keysTags := []string{"Keys", "Tailscale compat"}
huma.Register(api, huma.Operation{
OperationID: "createKey",
Method: http.MethodPost,
Path: "/api/v2/tailnet/{tailnet}/keys",
Summary: "Create an auth key or OAuth client",
Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).",
Tags: keysTags,
Security: security,
OperationID: "createKey",
Method: http.MethodPost,
Path: "/api/v2/tailnet/{tailnet}/keys",
Summary: "Create an auth key or OAuth client",
Description: "Requires the `auth_keys` scope for an auth key, or `oauth_keys` for an OAuth client (an admin API key is all-access).",
Tags: keysTags,
Security: security,
MaxBodyBytes: maxBodyBytes,
Errors: []int{
http.StatusBadRequest,
http.StatusUnauthorized,
+6
View File
@@ -16,6 +16,10 @@ import (
// Tailscale's non-configurable one hour.
const accessTokenTTL = time.Hour
// maxTokenRequestBytes caps the token request form, which is read before the
// secret is checked and whose scope and tag lists are stored on the token.
const maxTokenRequestBytes = 16 << 10
// registerOAuthToken mounts POST /api/v2/oauth/token on the router. It is a plain
// handler, not a Huma operation: it consumes application/x-www-form-urlencoded
// and emits RFC 6749 OAuth2 error bodies ({"error","error_description"}), neither
@@ -38,6 +42,8 @@ type tokenResponse struct {
// short-lived bearer token.
func oauthTokenHandler(b Backend) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxTokenRequestBytes)
err := r.ParseForm()
if err != nil {
writeOAuthError(w, http.StatusBadRequest, "invalid_request", "could not parse request body")
+71
View File
@@ -10,6 +10,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/danielgtaylor/huma/v2/humatest"
@@ -128,6 +129,17 @@ func TestAPIv2ACLSetHuJSONWithComments(t *testing.T) {
assert.Equal(t, policyETagOf(policy), raw.Header().Get("Etag"))
}
// TestAPIv2ACLSetBodyLimited: huma gives a RawBody input no size limit of its
// own, so setACL must declare one.
func TestAPIv2ACLSetBodyLimited(t *testing.T) {
app := createTestApp(t)
api := registerAPIV2(t, app)
set := setACL(t, api, allowAllPolicy+strings.Repeat(" ", 1<<20), "application/json")
assert.Equal(t, http.StatusRequestEntityTooLarge, set.Code)
assert.Empty(t, storedPolicy(t, app))
}
func TestAPIv2ACLETagChangesOnChangeStableOnNoop(t *testing.T) {
app := createTestApp(t)
api := registerAPIV2(t, app)
@@ -233,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.
@@ -291,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")
-8
View File
@@ -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"
)
+43
View File
@@ -13,6 +13,7 @@ import (
"testing"
apiv2 "github.com/juanfont/headscale/hscontrol/api/v2"
"github.com/juanfont/headscale/hscontrol/scope"
"github.com/juanfont/headscale/hscontrol/types/testcapture"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -222,6 +223,48 @@ func TestAPIv2OAuth_TokenEndpoint(t *testing.T) {
assert.Equal(t, "no-store", resp.Header.Get("Cache-Control"))
}
// TestAPIv2OAuth_RequestBodiesBounded: the token form is parsed before the
// secret is checked, and huma validates a whole body before the handler runs.
func TestAPIv2OAuth_RequestBodiesBounded(t *testing.T) {
_, baseURL, admin := newOAuthTestServer(t)
_, secret := createClient(t, baseURL, admin, []string{"users:read"}, nil)
scopes := strings.TrimSpace(strings.Repeat("users:read ", 2000))
status, m := mintToken(t, baseURL, "", secret, scopes, false)
assert.Equalf(t, http.StatusBadRequest, status, "%v", m)
assert.Equal(t, "invalid_request", m["error"])
tok := mintScopedToken(t, baseURL, admin, scope.AuthKeys)
status, _ = apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", tok,
map[string]any{"tags": make([]int, 40000)})
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)
@@ -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"
)