diff --git a/hscontrol/policy/v2/tailnet_state_caps.go b/hscontrol/policy/v2/tailnet_state_caps.go index 411b5ffb3..97cf18441 100644 --- a/hscontrol/policy/v2/tailnet_state_caps.go +++ b/hscontrol/policy/v2/tailnet_state_caps.go @@ -141,11 +141,6 @@ var unmodelledTailnetStateCaps = []nodecap.Cap{ // server (no su, in-process SFTP). Internal tuning; the embedded // server picks Tailscale-vendored defaults without the cap. nodecap.SSHBehaviorV1, - - // [tailcfg.NodeAttrSSHEnvironmentVariables]: gates SendEnv - // forwarding in the embedded SSH server. Internal; default chosen - // by the server. - nodecap.SSHEnvironmentVariables, } // strippedCapPrefixes lists URL/string prefixes for parameterized or diff --git a/hscontrol/servertest/nodeattrs_test.go b/hscontrol/servertest/nodeattrs_test.go index 50d4e8540..2df18dcf2 100644 --- a/hscontrol/servertest/nodeattrs_test.go +++ b/hscontrol/servertest/nodeattrs_test.go @@ -168,9 +168,9 @@ func TestNodeAttrsRevokesWhenRemoved(t *testing.T) { } // TestNodeAttrsBaselineCapsAlwaysOn verifies that the baseline caps -// (Admin, SSH, FileSharing, DefaultAutoUpdate) are emitted on every -// node regardless of whether the policy mentions them. Taildrive -// (drive:share / drive:access) is policy-driven and is verified +// (Admin, SSH, SSHEnvironmentVariables, FileSharing, DefaultAutoUpdate) +// are emitted on every node regardless of whether the policy mentions them. +// Taildrive (drive:share / drive:access) is policy-driven and is verified // through TestNodeAttrsAddsToBaseline and the integration // TestGrantCapDrive flow. func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) { @@ -189,6 +189,7 @@ func TestNodeAttrsBaselineCapsAlwaysOn(t *testing.T) { for _, w := range []nodecap.Cap{ nodecap.Admin, nodecap.SSH, + nodecap.SSHEnvironmentVariables, nodecap.FileSharing, nodecap.DefaultAutoUpdate, } { diff --git a/integration/ssh_test.go b/integration/ssh_test.go index 622b49e42..ca6085381 100644 --- a/integration/ssh_test.go +++ b/integration/ssh_test.go @@ -101,7 +101,8 @@ func TestSSHOneUserToAll(t *testing.T) { new(policyv2.AutoGroupMember), new(policyv2.AutoGroupTagged), }, - Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")}, + Users: []policyv2.SSHUser{policyv2.SSHUser("ssh-it-user")}, + AcceptEnv: []string{"HS_ACCEPTED"}, }, }, }, @@ -132,6 +133,7 @@ func TestSSHOneUserToAll(t *testing.T) { } assertSSHHostname(t, client, peer) + assertSSHAcceptEnv(t, client, peer) } } @@ -579,6 +581,33 @@ func assertSSHHostname(t *testing.T, client TailscaleClient, peer TailscaleClien require.Contains(t, peer.ContainerID(), strings.ReplaceAll(result, "\n", "")) } +func assertSSHAcceptEnv(t *testing.T, client TailscaleClient, peer TailscaleClient) { + t.Helper() + + peerFQDN, err := peer.FQDN() + require.NoError(t, err) + + command := []string{ + "env", "HS_ACCEPTED=yes", "HS_REJECTED=yes", + "/usr/bin/ssh", "-o StrictHostKeyChecking=no", "-o ConnectTimeout=1", + "-o SendEnv=HS_ACCEPTED", "-o SendEnv=HS_REJECTED", + "ssh-it-user@" + peerFQDN, "'env'", + } + + require.EventuallyWithT(t, func(c *assert.CollectT) { + stdout, stderr, err := client.Execute(command) + if !assert.NoError(c, err, "SSH stderr: %s", stderr) { + return + } + + vars := strings.Split(strings.TrimSpace(stdout), "\n") + assert.Contains(c, vars, "HS_ACCEPTED=yes") + assert.NotContains(c, vars, "HS_REJECTED=yes") + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, + "SSH from %s to %s should forward only acceptEnv variables", + client.Hostname(), peer.Hostname()) +} + func assertSSHPermissionDenied(t *testing.T, client TailscaleClient, peer TailscaleClient) { t.Helper()