Skip to content

Commit 30f97ec

Browse files
arpitjain099kradalby
authored andcommitted
auth: check machine key on the followup registration path
waitForFollowup returns nodeToRegisterResponse for a completed registration without checking that the Noise session polling for the result was started with the machine key that opened the registration. That response carries the registering user's User and Login, so the auth ID in the followup URL is the only thing protecting it. handleRegister and handleLogout both call machineKeyMismatch before handing back a node, so this is the one path of the three that does not. The key is already available: HandleNodeFromAuthPath resolves the node from the MachineKey cached in RegistrationData, so on the normal path the node and the session agree and the check is a no-op. The auth ID is 96 bits of randomness and is not guessable, so this is not reachable by brute force. It is logged at info level when a registration is created, which makes log access the realistic way to obtain one. The existing followup_registration_success case built its node with CreateNodeForTest, which picks a random machine key that no real registration would produce. Set the registering machine key so the fixture matches the production path. Signed-off-by: Arpit Jain <arpitjain099@gmail.com>
1 parent 7a1ee34 commit 30f97ec

2 files changed

Lines changed: 94 additions & 0 deletions

File tree

hscontrol/auth.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,18 @@ func (h *Headscale) waitForFollowup(
311311
return h.reqToNewRegisterResponse(req, machineKey)
312312
}
313313

314+
// The followup poll is only authenticated by the auth ID in the
315+
// URL, so fail closed unless the Noise session asking for the
316+
// result was started with the same machine key that opened the
317+
// registration. [State.HandleNodeFromAuthPath] resolves the node
318+
// from the cached [types.RegistrationData.MachineKey], so the two
319+
// match on the normal path. [Headscale.handleRegister] and
320+
// [Headscale.handleLogout] apply the same check.
321+
err := machineKeyMismatch(verdict.Node, machineKey)
322+
if err != nil {
323+
return nil, err
324+
}
325+
314326
return nodeToRegisterResponse(verdict.Node), nil
315327
}
316328
}

hscontrol/auth_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"net/http"
78
"net/url"
89
"strings"
910
"testing"
@@ -692,6 +693,11 @@ func TestAuthenticationFlows(t *testing.T) {
692693
user := app.state.CreateUserForTest("followup-user")
693694

694695
node := app.state.CreateNodeForTest(user, "followup-success-node")
696+
// [State.HandleNodeFromAuthPath] resolves the node from the
697+
// machine key cached when the registration was opened, so on
698+
// the real path the node carries the polling session's
699+
// machine key. CreateNodeForTest picks a random one.
700+
node.MachineKey = machineKey1.Public()
695701
nodeToRegister.FinishAuth(types.AuthVerdict{Node: node.View()})
696702
}()
697703

@@ -4101,3 +4107,79 @@ func TestHandleNodeFromAuthPath_OldUserNil_NoPanic(t *testing.T) {
41014107
assert.NotEqual(t, types.NodeID(99002), node.ID(), "new node, not orphan")
41024108
assert.Equal(t, userB.ID, node.UserID().Get(), "new node belongs to userB")
41034109
}
4110+
4111+
// TestWaitForFollowupMachineKeyMismatch covers the followup poll in
4112+
// [Headscale.waitForFollowup]. That poll is authenticated only by the auth ID
4113+
// embedded in the followup URL, so without a machine-key check anyone who
4114+
// learns an ID gets the registering user's User/Login back in the
4115+
// [tailcfg.RegisterResponse].
4116+
//
4117+
// [Headscale.handleRegister] and [Headscale.handleLogout] already fail closed
4118+
// here; see the "existing_node_machine_key_mismatch" case in
4119+
// [TestAuthenticationFlows] for the equivalent assertion on that path.
4120+
//
4121+
// The nodes are given the registering session's machine key because that is
4122+
// what production produces: [State.HandleNodeFromAuthPath] resolves the node
4123+
// from the machine key cached in [types.RegistrationData] when the
4124+
// registration was opened.
4125+
func TestWaitForFollowupMachineKeyMismatch(t *testing.T) {
4126+
app := createTestApp(t)
4127+
4128+
victimMachineKey := key.NewMachine()
4129+
attackerMachineKey := key.NewMachine()
4130+
4131+
// Park a completed registration in the auth cache, as a node that is
4132+
// already polling for its verdict would see it.
4133+
newPendingFollowup := func(hostname string) string {
4134+
authID := types.MustAuthID()
4135+
regEntry := types.NewRegisterAuthRequest(&types.RegistrationData{
4136+
MachineKey: victimMachineKey.Public(),
4137+
NodeKey: key.NewNode().Public(),
4138+
Hostname: hostname,
4139+
})
4140+
app.state.SetAuthCacheEntry(authID, regEntry)
4141+
4142+
user := app.state.CreateUserForTest(hostname + "-user")
4143+
node := app.state.CreateNodeForTest(user, hostname)
4144+
node.MachineKey = victimMachineKey.Public()
4145+
// CreateNodeForTest only sets UserID, but nodeToRegisterResponse reads
4146+
// the owner, and the owner's identity is exactly what must not leak.
4147+
node.User = user
4148+
regEntry.FinishAuth(types.AuthVerdict{Node: node.View()})
4149+
4150+
return fmt.Sprintf("http://localhost:8080/register/%s", authID)
4151+
}
4152+
4153+
followup := func(url string, machineKey key.MachinePublic) (*tailcfg.RegisterResponse, error) {
4154+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
4155+
defer cancel()
4156+
4157+
return app.handleRegister(ctx, tailcfg.RegisterRequest{
4158+
Followup: url,
4159+
NodeKey: key.NewNode().Public(),
4160+
}, machineKey)
4161+
}
4162+
4163+
t.Run("mismatched machine key is rejected", func(t *testing.T) {
4164+
resp, err := followup(newPendingFollowup("followup-mismatch"), attackerMachineKey.Public())
4165+
4166+
require.Error(t, err, "followup with a foreign machine key must not succeed")
4167+
assert.Nil(t, resp, "no registration details should be returned")
4168+
4169+
var httpErr HTTPError
4170+
require.ErrorAs(t, err, &httpErr)
4171+
assert.Equal(t, http.StatusUnauthorized, httpErr.Code)
4172+
})
4173+
4174+
// Positive control. Without it a regression that stops the poll from
4175+
// finding the cache entry at all would still pass the case above, because
4176+
// waitForFollowup falls back to handing out a fresh AuthURL.
4177+
t.Run("matching machine key still completes", func(t *testing.T) {
4178+
resp, err := followup(newPendingFollowup("followup-match"), victimMachineKey.Public())
4179+
4180+
require.NoError(t, err)
4181+
require.NotNil(t, resp)
4182+
assert.True(t, resp.MachineAuthorized)
4183+
assert.NotEmpty(t, resp.User.DisplayName, "the owner's identity is returned on the legitimate path")
4184+
})
4185+
}

0 commit comments

Comments
 (0)