diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go
index b69a042e1..35d181514 100644
--- a/hscontrol/oidc.go
+++ b/hscontrol/oidc.go
@@ -887,6 +887,7 @@ func (a *AuthProviderOIDC) RegisterConfirmGetHandler(
if regData.Hostinfo != nil {
info.OS = regData.Hostinfo.OS
+ info.Tags = regData.Hostinfo.RequestTags
}
content = templates.RegisterConfirm(info).Render()
@@ -1051,7 +1052,7 @@ func (a *AuthProviderOIDC) handleRegistration(
func renderRegistrationSuccessTemplate(
user *types.User,
- _ types.NodeView,
+ node types.NodeView,
reauth bool,
) *bytes.Buffer {
result := templates.AuthSuccessResult{
@@ -1061,6 +1062,10 @@ func renderRegistrationSuccessTemplate(
User: user.Display(),
Message: "You can now close this window.",
}
+ if node.IsTagged() {
+ result.User = strings.Join(node.Tags().AsSlice(), ", ")
+ }
+
if !reauth {
result.Title = "Headscale - Node Registered"
result.Heading = "Node registered"
diff --git a/hscontrol/oidc_test.go b/hscontrol/oidc_test.go
index d61644554..72fed3eb3 100644
--- a/hscontrol/oidc_test.go
+++ b/hscontrol/oidc_test.go
@@ -1028,3 +1028,114 @@ func TestRegisterConfirmShowsExistingNode(t *testing.T) {
assert.Equal(t, tt.nodeCount, b.app.state.ListNodes().Len(), tt.name)
}
}
+
+func TestRegisterConfirmShowsRequestedTags(t *testing.T) {
+ const ownerName = "tag-owner"
+
+ b := newOIDCBrowser(t)
+ _, err := b.app.state.SetPolicy([]byte(`{
+ "tagOwners": {"tag:prod": ["tag-owner@"]},
+ "acls": [{"action":"accept", "src":["*"], "dst":["*:*"]}]
+ }`))
+ require.NoError(t, err)
+
+ machineKey := key.NewMachine().Public()
+ authID := types.MustAuthID()
+ b.app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
+ MachineKey: machineKey,
+ NodeKey: key.NewNode().Public(),
+ Hostname: "tagged-laptop",
+ Hostinfo: &tailcfg.Hostinfo{RequestTags: []string{"tag:prod"}},
+ }))
+ b.idp.QueueUser(&mockoidc.MockUser{Subject: ownerName, PreferredUsername: ownerName})
+
+ status, landed, body := b.get(t, b.publicURL+"/register/"+authID.String())
+ require.Equal(t, http.StatusOK, status)
+
+ registered := regexp.MustCompile(`>Registered to
]*>([^<]*) | `).FindStringSubmatch(body)
+ require.Len(t, registered, 2)
+ assert.Equal(t, "tag:prod", registered[1])
+
+ approved := regexp.MustCompile(`>Approved by]*>([^<]*) | `).FindStringSubmatch(body)
+ if assert.Len(t, approved, 2) {
+ assert.Equal(t, ownerName, approved[1])
+ }
+
+ csrf := csrfInputRe.FindStringSubmatch(body)
+ require.Len(t, csrf, 2)
+
+ //nolint:noctx,bodyclose // test response closed below
+ confirmed, err := b.client.PostForm(landed.String(), url.Values{
+ registerConfirmCSRFCookie: {csrf[1]},
+ })
+ require.NoError(t, err)
+
+ confirmedBody, err := io.ReadAll(confirmed.Body)
+ confirmed.Body.Close()
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, confirmed.StatusCode)
+
+ success := regexp.MustCompile(`Registered as ([^<]*)`).FindSubmatch(confirmedBody)
+ require.Len(t, success, 2)
+ assert.Equal(t, "tag:prod", string(success[1]))
+
+ nodes := b.app.state.ListNodes()
+ require.Equal(t, 1, nodes.Len())
+ require.True(t, nodes.At(0).IsTagged())
+ require.Equal(t, []string{"tag:prod"}, nodes.At(0).Tags().AsSlice())
+
+ reloginID := types.MustAuthID()
+ b.app.state.SetAuthCacheEntry(reloginID, types.NewRegisterAuthRequest(&types.RegistrationData{
+ MachineKey: machineKey,
+ NodeKey: key.NewNode().Public(),
+ Hostname: "different-client-name",
+ }))
+ b.idp.QueueUser(&mockoidc.MockUser{Subject: ownerName, PreferredUsername: ownerName})
+ status, _, body = b.get(t, b.publicURL+"/register/"+reloginID.String())
+ require.Equal(t, http.StatusOK, status)
+
+ existing := regexp.MustCompile(`>Existing node]*>([^<]*) | `).FindStringSubmatch(body)
+ require.Len(t, existing, 2)
+ require.Equal(t, "tagged-laptop", existing[1])
+ require.True(t, b.app.state.ListNodes().At(0).IsTagged(), "viewing confirmation must not change ownership")
+}
+
+func TestRegisterConfirmEscapesAndRejectsUnownedTags(t *testing.T) {
+ for _, tt := range []struct {
+ tag string
+ display string
+ }{
+ {tag: "tag:unowned", display: "tag:unowned"},
+ {tag: "tag:prod", display: "tag:<em>prod</em>"},
+ } {
+ t.Run(tt.tag, func(t *testing.T) {
+ b := newOIDCBrowser(t)
+ authID := types.MustAuthID()
+ b.app.state.SetAuthCacheEntry(authID, types.NewRegisterAuthRequest(&types.RegistrationData{
+ MachineKey: key.NewMachine().Public(),
+ NodeKey: key.NewNode().Public(),
+ Hostname: "unowned-tag-node",
+ Hostinfo: &tailcfg.Hostinfo{RequestTags: []string{tt.tag}},
+ }))
+
+ status, landed, body := b.get(t, b.publicURL+"/register/"+authID.String())
+ require.Equal(t, http.StatusOK, status)
+
+ row := regexp.MustCompile(`>Registered to]*>(.*?) | `).FindStringSubmatch(body)
+ require.Len(t, row, 2)
+ assert.Equal(t, tt.display, row[1])
+
+ csrf := csrfInputRe.FindStringSubmatch(body)
+ require.Len(t, csrf, 2)
+
+ //nolint:noctx,bodyclose // test response closed below
+ confirmed, err := b.client.PostForm(landed.String(), url.Values{
+ registerConfirmCSRFCookie: {csrf[1]},
+ })
+ require.NoError(t, err)
+ confirmed.Body.Close()
+ require.NotEqual(t, http.StatusOK, confirmed.StatusCode)
+ require.Zero(t, b.app.state.ListNodes().Len())
+ })
+ }
+}
diff --git a/hscontrol/templates/register_confirm.go b/hscontrol/templates/register_confirm.go
index d99474fdf..aef27511f 100644
--- a/hscontrol/templates/register_confirm.go
+++ b/hscontrol/templates/register_confirm.go
@@ -2,6 +2,7 @@ package templates
import (
"cmp"
+ "strings"
"github.com/chasefleming/elem-go"
"github.com/chasefleming/elem-go/attrs"
@@ -46,6 +47,9 @@ type RegisterConfirmInfo struct {
// ExistingNode is the stored node name, empty for a new device.
ExistingNode string
+
+ // Tags are the ownership tags requested by the registering client.
+ Tags []string
}
// RegisterConfirm renders an interstitial page that asks the
@@ -55,15 +59,22 @@ type RegisterConfirmInfo struct {
// silently complete a phishing-style registration when the victim's
// IdP allows silent SSO.
func RegisterConfirm(info RegisterConfirmInfo) *elem.Element {
- deviceList := deviceTable(
- []deviceRow{
- {"Hostname", elem.Text(info.Hostname)},
- {"OS", elem.Text(cmp.Or(info.OS, "(unknown)"))},
- {"Machine key", Code(elem.Text(info.MachineKey))},
- {"Existing node", elem.Text(cmp.Or(info.ExistingNode, "None, this is a new device"))},
- {"Registered to", elem.Text(info.User)},
- },
- )
+ rows := []deviceRow{
+ {"Hostname", elem.Text(info.Hostname)},
+ {"OS", elem.Text(cmp.Or(info.OS, "(unknown)"))},
+ {"Machine key", Code(elem.Text(info.MachineKey))},
+ {"Existing node", elem.Text(cmp.Or(info.ExistingNode, "None, this is a new device"))},
+ }
+ if len(info.Tags) > 0 {
+ rows = append(rows,
+ deviceRow{"Registered to", elem.Text(strings.Join(info.Tags, ", "))},
+ deviceRow{"Approved by", elem.Text(info.User)},
+ )
+ } else {
+ rows = append(rows, deviceRow{"Registered to", elem.Text(info.User)})
+ }
+
+ deviceList := deviceTable(rows)
return page(
"Headscale - Confirm node registration",