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",