Skip to content

Commit

Permalink
upstream: ban user/hostnames with most shell metacharacters
Browse files Browse the repository at this point in the history
  • Loading branch information
lonnywong committed Dec 23, 2023
1 parent 4cbd617 commit fd06484
Show file tree
Hide file tree
Showing 5 changed files with 170 additions and 56 deletions.
49 changes: 23 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,17 @@ Website: [https://trzsz.github.io/ssh](https://trzsz.github.io/ssh) ( English )

## tssh 简介

你喜欢的 ssh 终端是否有好用的服务器管理功能?是否支持记住密码?是否有好用的文件传输工具?
- 你喜欢的 ssh 终端是否有好用的服务器管理功能?是否支持记住密码?是否有好用的文件传输工具?

tssh 支持选择或搜索 `~/.ssh/config` 中配置的服务器,支持 vim 操作习惯,解决 ssh 终端的服务器管理问题。
- tssh 支持选择或搜索 `~/.ssh/config` 中配置的服务器,支持 vim 操作习惯,解决 ssh 终端的服务器管理问题。

tssh 支持一次选择多台服务器,批量登录,并支持批量执行预先指定的命令,方便快速完成批量服务器操作。
- tssh 支持一次选择多台服务器,批量登录,并支持批量执行预先指定的命令,方便快速完成批量服务器操作。

tssh 支持配置服务器登录密码,解决每次手工输入密码的麻烦( 在自己能控制的服务器,推荐使用公私钥登录 )。
- tssh 支持配置服务器登录密码,解决每次手工输入密码的麻烦( 在自己能控制的服务器,推荐使用公私钥登录 )。

tssh 内置支持 [trzsz](https://trzsz.github.io/cn/) ( trz / tsz ) 文件传输工具,一并解决了 Windows 中使用 `trzsz ssh` 上传速度很慢的问题。
- tssh 内置支持 [trzsz](https://trzsz.github.io/cn/) ( trz / tsz ) 文件传输工具,一并解决了 Windows 中使用 `trzsz ssh` 上传速度很慢的问题。

_在作者的 MacOS 上,使用 `trzsz ssh` 的上传速度在 10 MB/s 左右,而使用 `tssh` 可以到 80 MB/s 以上。_
- _在作者的 MacOS 上,使用 `trzsz ssh` 的上传速度在 10 MB/s 左右,而使用 `tssh` 可以到 80 MB/s 以上。_

## 安装方法

Expand Down Expand Up @@ -164,11 +164,11 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\

- 登录服务器,将公钥( 即前面生成密钥对时 `.pub` 后缀的文件内容 )追加写入服务器上的 `~/.ssh/authorized_keys` 文件中。

一行代表一个客户端的公钥,注意 `~/.ssh/authorized_keys` 要设置正确的权限:
- 一行代表一个客户端的公钥,注意 `~/.ssh/authorized_keys` 要设置正确的权限:

```sh
chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys
```
```sh
chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys
```

- 在客户端配置好 `~/.ssh/config` 文件,举例:

Expand Down Expand Up @@ -246,7 +246,7 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\
#!! ExpectCaseSendPass3 token d7... # 在 ExpectPattern3 匹配之前,若遇到 token 则解码并发送 d7...
```

使用 `tssh --debug` 登录,可以看到 `expect` 捕获到的输出,以及其匹配结果和自动输入的交互。
- 使用 `tssh --debug` 登录,可以看到 `expect` 捕获到的输出,以及其匹配结果和自动输入的交互。

## 记住密码

Expand Down Expand Up @@ -386,11 +386,11 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\

- 需要在客户端( 本地电脑 )上安装 `lrzsz`,Windows 可以从 [lrzsz-win32](https://github.com/trzsz/lrzsz-win32/releases) 下载解压并加到 `PATH` 中,也可以如下安装:

```
scoop install https://trzsz.github.io/lrzsz.json
```
scoop install https://trzsz.github.io/lrzsz.json
choco install lrzsz --version=0.12.21
```
choco install lrzsz --version=0.12.21
```
- 关于 `rz / sz` 进度条,己传大小和传输速度会有一点偏差,它的主要作用只是指示传输正在进行中。
Expand All @@ -404,7 +404,7 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\
- 上文说的“记住密码”和“记住答案”,只要在配置项前面加上 `enc` 则可以配置密文,防止被人窥屏。并且,密文可以解决密码含有`#`的问题。
运行 `tssh --enc-secret`,输入密码或答案,可得到用于配置的密文( 相同密码每次运行结果不同 ):
- 运行 `tssh --enc-secret`,输入密码或答案,可得到用于配置的密文( 相同密码每次运行结果不同 ):
```
Host server2
Expand All @@ -417,15 +417,11 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\
- 运行 `tssh --install-trzsz` 可以将 [trzsz](https://github.com/trzsz/trzsz-go) ( `trz` / `tsz` ) 安装到服务器上。
默认安装到 `~/.local/bin/` 目录,可以通过 `--install-path /path/to/install` 指定安装目录。

`--install-path` 安装目录含有 `~/`,则必须加上单引号,如`--install-path '~/path'`

若获取 `trzsz` 的最新版本号失败,可以通过 `--trzsz-version x.x.x` 参数自行指定。

若下载 `trzsz` 的安装包失败,可以自行下载并通过 `--trzsz-bin-path /path/to/trzsz.tar.gz` 参数指定。

注意:`--install-trzsz` 不支持 Windows 服务器,不支持跳板机( 除非以 `ProxyJump` 跳过 )。
- 默认安装到 `~/.local/bin/` 目录,可以通过 `--install-path /path/to/install` 指定安装目录。
- 若 `--install-path` 安装目录含有 `~/`,则必须加上单引号,如`--install-path '~/path'`。
- 若获取 `trzsz` 的最新版本号失败,可以通过 `--trzsz-version x.x.x` 参数自行指定。
- 若下载 `trzsz` 的安装包失败,可以自行下载并通过 `--trzsz-bin-path /path/to/trzsz.tar.gz` 参数指定。
- 注意:`--install-trzsz` 不支持 Windows 服务器,不支持跳板机( 除非以 `ProxyJump` 跳过 )。
- 关于修改终端标题,其实无需 `tssh` 就能实现,只要在服务器的 shell 配置文件中(如`~/.bashrc`)配置:
Expand All @@ -437,7 +433,8 @@ _`~/` 代表 HOME 目录。在 Windows 中,请将下文的 `~/` 替换成 `C:\
PROMPT_COMMAND='echo -ne "\033]0;${USER}@${HOSTNAME}: ${PWD}\007"'
```

- 如果在 `~/.tssh.conf` 中设置了 `SetTerminalTitle = Yes`,则会在登录后自动设置终端标题,但是服务器上的 `PROMPT_COMMAND` 会覆盖 `tssh` 设置的标题。在 `tssh` 退出后不会重置为原来的标题,你需要在本地 shell 中设置 `PROMPT_COMMAND`,让它覆盖 `tssh` 设置的标题。
- 如果在 `~/.tssh.conf` 中设置了 `SetTerminalTitle = Yes`,则会在登录后自动设置终端标题,但是服务器上的 `PROMPT_COMMAND` 会覆盖 `tssh` 设置的标题。
-`tssh` 退出后不会重置为原来的标题,你需要在本地 shell 中设置 `PROMPT_COMMAND`,让它覆盖 `tssh` 设置的标题。

## 快捷键

Expand Down
7 changes: 6 additions & 1 deletion tssh/ctrl_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,12 @@ func connectViaControl(args *sshArgs, param *loginParam) *ssh.Client {
return nil
}

socket := resolveHomeDir(expandTokens(ctrlPath, args, param, "%CdhikLlnpru"))
socket, err := expandTokens(ctrlPath, args, param, "%CdhikLlnpru")
if err != nil {
warning("expand control socket [%s] failed: %v", socket, err)
return nil
}
socket = resolveHomeDir(socket)

switch strings.ToLower(ctrlMaster) {
case "yes", "ask":
Expand Down
6 changes: 5 additions & 1 deletion tssh/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,11 @@ func (p *cmdPipe) Close() error {
}

func execProxyCommand(args *sshArgs, param *loginParam) (net.Conn, string, error) {
command := resolveHomeDir(expandTokens(param.command, args, param, "%hnpr"))
command, err := expandTokens(param.command, args, param, "%hnpr")
if err != nil {
return nil, param.command, err
}
command = resolveHomeDir(command)
debug("exec proxy command: %s", command)

argv, err := splitCommandLine(command)
Expand Down
57 changes: 46 additions & 11 deletions tssh/tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,44 @@ import (
"crypto/sha1"
"fmt"
"os"
"regexp"
"strings"
"unicode"
)

func isHostValid(host string) bool {
if strings.HasPrefix(host, "-") {
return false
}
for _, ch := range host {
if strings.ContainsRune("'`\"$\\;&<>|(){}", ch) {
return false
}
if unicode.IsSpace(ch) || unicode.IsControl(ch) {
return false
}
}
return true
}

func isUserValid(user string) bool {
if strings.HasPrefix(user, "-") {
return false
}
if strings.ContainsAny(user, "'`\";&<>|(){}") {
return false
}
// disallow '-' after whitespace
if regexp.MustCompile(`\s-`).MatchString(user) {
return false
}
// disallow \ in last position
if strings.HasSuffix(user, "\\") {
return false
}
return true
}

var getHostname = func() string {
hostname, err := os.Hostname()
if err != nil {
Expand All @@ -41,7 +76,7 @@ var getHostname = func() string {
return hostname
}

func expandTokens(str string, args *sshArgs, param *loginParam, tokens string) string {
func expandTokens(str string, args *sshArgs, param *loginParam, tokens string) (string, error) {
var buf strings.Builder
state := byte(0)
for _, c := range str {
Expand All @@ -56,19 +91,22 @@ func expandTokens(str string, args *sshArgs, param *loginParam, tokens string) s
}
state = 0
if !strings.ContainsRune(tokens, c) {
warning("token [%%%c] in [%s] is not supported", c, str)
buf.WriteRune('%')
buf.WriteRune(c)
continue
return "", fmt.Errorf("token [%%%c] in [%s] is not supported", c, str)
}
switch c {
case '%':
buf.WriteRune('%')
case 'h':
if !isHostValid(param.host) {
return "", fmt.Errorf("hostname contains invalid characters")
}
buf.WriteString(param.host)
case 'p':
buf.WriteString(param.port)
case 'r':
if !isUserValid(param.user) {
return "", fmt.Errorf("remote username contains invalid characters")
}
buf.WriteString(param.user)
case 'n':
buf.WriteString(args.Destination)
Expand All @@ -84,14 +122,11 @@ func expandTokens(str string, args *sshArgs, param *loginParam, tokens string) s
hashStr := fmt.Sprintf("%s%s%s%s", getHostname(), param.host, param.port, param.user)
buf.WriteString(fmt.Sprintf("%x", sha1.Sum([]byte(hashStr))))
default:
warning("token [%%%c] in [%s] is not supported yet", c, str)
buf.WriteRune('%')
buf.WriteRune(c)
return "", fmt.Errorf("token [%%%c] in [%s] is not supported yet", c, str)
}
}
if state != 0 {
warning("[%s] ends with %% is invalid", str)
buf.WriteRune('%')
return "", fmt.Errorf("[%s] ends with %% is invalid", str)
}
return buf.String()
return buf.String(), nil
}
107 changes: 90 additions & 17 deletions tssh/tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,15 @@ SOFTWARE.
package tssh

import (
"fmt"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestExpandTokens(t *testing.T) {
assert := assert.New(t)
originalWarning := warning
defer func() {
warning = originalWarning
}()
var output string
warning = func(format string, a ...any) {
output = fmt.Sprintf(format, a...)
}
require := require.New(t)
originalGetHostname := getHostname
defer func() {
getHostname = originalGetHostname
Expand All @@ -56,11 +49,16 @@ func TestExpandTokens(t *testing.T) {
port: "1337",
user: "penny",
}
assertProxyCommand := func(original, expanded, result string) {
assertProxyCommand := func(original, expanded, errMsg string) {
t.Helper()
output = ""
assert.Equal(expanded, expandTokens(original, args, param, "%hnpr"))
assert.Equal(result, output)
result, err := expandTokens(original, args, param, "%hnpr")
if errMsg != "" {
require.NotNil(err)
assert.Equal(errMsg, err.Error())
return
}
require.Nil(err)
assert.Equal(expanded, result)
}

assertProxyCommand("%%", "%", "")
Expand All @@ -73,11 +71,16 @@ func TestExpandTokens(t *testing.T) {
assertProxyCommand("%l", "%l", "token [%l] in [%l] is not supported")
assertProxyCommand("a_%h_%C", "a_127.0.0.1_%C", "token [%C] in [a_%h_%C] is not supported")

assertControlPath := func(original, expanded, result string) {
assertControlPath := func(original, expanded, errMsg string) {
t.Helper()
output = ""
assert.Equal(expanded, expandTokens(original, args, param, "%CdhikLlnpru"))
assert.Equal(result, output)
result, err := expandTokens(original, args, param, "%CdhikLlnpru")
if errMsg != "" {
require.NotNil(err)
assert.Equal(errMsg, err.Error())
return
}
require.Nil(err)
assert.Equal(expanded, result)
}

assertControlPath("%p和%r", "1337和penny", "")
Expand All @@ -91,3 +94,73 @@ func TestExpandTokens(t *testing.T) {
assertControlPath("p_%h_%d", "p_127.0.0.1_%d", "token [%d] in [p_%h_%d] is not supported yet")
assertControlPath("h%", "h%", "[h%] ends with % is invalid")
}

func TestInvalidHost(t *testing.T) {
assert := assert.New(t)
require := require.New(t)

assertInvalidHost := func(host string) {
t.Helper()
_, err := expandTokens("%h", &sshArgs{}, &loginParam{host: host}, "%hnpr")
require.NotNil(err)
assert.Equal("hostname contains invalid characters", err.Error())
}

assertInvalidHost("-invalidhostname")
assertInvalidHost("invalid'hostname")
assertInvalidHost("invalid`hostname")
assertInvalidHost("invalid\"hostname")
assertInvalidHost("invalid$hostname")
assertInvalidHost("invalid\\hostname")
assertInvalidHost("invalid;hostname")
assertInvalidHost("invalid&hostname")
assertInvalidHost("invalid<hostname")
assertInvalidHost("invalid>hostname")
assertInvalidHost("invalid|hostname")
assertInvalidHost("invalid(hostname")
assertInvalidHost("invalid)hostname")
assertInvalidHost("invalid{hostname")
assertInvalidHost("invalid}hostname")
assertInvalidHost("invalid hostname")
assertInvalidHost("invalid\thostname")
assertInvalidHost("invalid\rhostname")
assertInvalidHost("invalid\nhostname")
assertInvalidHost("invalid\vhostname")
assertInvalidHost("invalid\fhostname")
assertInvalidHost("invalid\u0007hostname")
assertInvalidHost("invalid\u0018hostname")
assertInvalidHost("invalid\u007fhostname")
assertInvalidHost("invalid\u2028hostname")
assertInvalidHost("invalid\u2029hostname")
}

func TestInvalidUser(t *testing.T) {
assert := assert.New(t)
require := require.New(t)

assertInvalidUser := func(user string) {
t.Helper()
_, err := expandTokens("%r", &sshArgs{}, &loginParam{user: user}, "%hnpr")
require.NotNil(err)
assert.Equal("remote username contains invalid characters", err.Error())
}

assertInvalidUser("-invalidusername")
assertInvalidUser("invalid'username")
assertInvalidUser("invalid`username")
assertInvalidUser("invalid\"username")
assertInvalidUser("invalid;username")
assertInvalidUser("invalid&username")
assertInvalidUser("invalid<username")
assertInvalidUser("invalid>username")
assertInvalidUser("invalid|username")
assertInvalidUser("invalid(username")
assertInvalidUser("invalid)username")
assertInvalidUser("invalid{username")
assertInvalidUser("invalid}username")
assertInvalidUser("invalid -username")
assertInvalidUser("invalid\t-username")
assertInvalidUser("invalid\r-username")
assertInvalidUser("invalid\n-username")
assertInvalidUser("invalidusername\\")
}

0 comments on commit fd06484

Please sign in to comment.