From 197895be2c159030d18ba6d3bb25739cf1e6775b Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Sat, 31 Oct 2026 14:16:49 +0000 Subject: [PATCH] auth: refuse requested tags with SaaS's bare text Clients show it as it is, and it carried an internal prefix. --- hscontrol/auth.go | 5 +++++ hscontrol/servertest/reauth_compat_test.go | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/hscontrol/auth.go b/hscontrol/auth.go index d30bc88a6..d2d5304d8 100644 --- a/hscontrol/auth.go +++ b/hscontrol/auth.go @@ -495,6 +495,11 @@ func (h *Headscale) handleRegisterWithAuthKey( return nil, NewHTTPError(http.StatusUnauthorized, perr.Error(), nil) } + // The client shows this text to the user as it is. + if errors.Is(err, state.ErrRequestedTagsInvalidOrNotPermitted) { + return nil, NewHTTPError(http.StatusBadRequest, err.Error(), nil) + } + return nil, err } diff --git a/hscontrol/servertest/reauth_compat_test.go b/hscontrol/servertest/reauth_compat_test.go index 143b0b449..098cf9813 100644 --- a/hscontrol/servertest/reauth_compat_test.go +++ b/hscontrol/servertest/reauth_compat_test.go @@ -349,6 +349,9 @@ func (run *reauthRun) cellChecks( check("outcome", registerOutcome(step.Register[login.Node]), func() string { return registerOutcome(r.clients[cell].RegisterAttempts()[prev.attempts[cell]:]) }) + check("tag-refusal", tagRefusal(step.Register[login.Node]), func() string { + return tagRefusal(r.clients[cell].RegisterAttempts()[prev.attempts[cell]:]) + }) } if slices.ContainsFunc(want, func(v deviceView) bool { _, ok := run.saasPrev[v.id]; return ok }) { @@ -464,6 +467,18 @@ func loginReplayable(step testcapture.Step, a testcapture.Action, keyTags map[st strings.HasSuffix(registerOutcome(step.Register[a.Node]), "accepted") } +// tagRefusal is the text of a login's refused-tags answer: a client shows it as +// it is, so unlike other refusals its words are compared. +func tagRefusal(attempts []testcapture.RegisterAttempt) string { + for _, a := range attempts { + if strings.Contains(a.Err, "requested tags") { + return a.Err + } + } + + return "" +} + // registerOutcome reads a login's register exchanges up to the answer that // ends it: "node-key-expired" for each answer asking for a new node key, // then "accepted", "refused" or "auth-url". Errors of a login the client