Browse Source

Merged in the ssh prox fix

Toby Chui 4 months ago
parent
commit
fe3a87c15b
4 changed files with 336 additions and 223 deletions
  1. 211 215
      mod/sshprox/sshprox.go
  2. 66 0
      mod/sshprox/sshprox_test.go
  3. 57 6
      mod/sshprox/utils.go
  4. 2 2
      webssh.go

+ 211 - 215
mod/sshprox/sshprox.go

@@ -1,215 +1,211 @@
-package sshprox
-
-import (
-	"errors"
-	"fmt"
-	"log"
-	"net/http"
-	"net/url"
-	"os"
-	"os/exec"
-	"path/filepath"
-	"runtime"
-	"strconv"
-	"strings"
-
-	"github.com/google/uuid"
-	"imuslab.com/zoraxy/mod/reverseproxy"
-	"imuslab.com/zoraxy/mod/utils"
-	"imuslab.com/zoraxy/mod/websocketproxy"
-)
-
-/*
-	SSH Proxy
-
-	This is a tool to bind gotty into Zoraxy
-	so that you can do something similar to
-	online ssh terminal
-*/
-
-type Manager struct {
-	StartingPort int
-	Instances    []*Instance
-}
-
-type Instance struct {
-	UUID         string
-	ExecPath     string
-	RemoteAddr   string
-	RemotePort   int
-	AssignedPort int
-	conn         *reverseproxy.ReverseProxy //HTTP proxy
-	tty          *exec.Cmd                  //SSH connection ported to web interface
-	Parent       *Manager
-}
-
-func NewSSHProxyManager() *Manager {
-	return &Manager{
-		StartingPort: 14810,
-		Instances:    []*Instance{},
-	}
-}
-
-// Get the next free port in the list
-func (m *Manager) GetNextPort() int {
-	nextPort := m.StartingPort
-	occupiedPort := make(map[int]bool)
-	for _, instance := range m.Instances {
-		occupiedPort[instance.AssignedPort] = true
-	}
-	for {
-		if !occupiedPort[nextPort] {
-			return nextPort
-		}
-		nextPort++
-	}
-}
-
-func (m *Manager) HandleHttpByInstanceId(instanceId string, w http.ResponseWriter, r *http.Request) {
-	targetInstance, err := m.GetInstanceById(instanceId)
-	if err != nil {
-		http.Error(w, err.Error(), http.StatusNotFound)
-		return
-	}
-
-	if targetInstance.tty == nil {
-		//Server side already closed
-		http.Error(w, "Connection already closed", http.StatusInternalServerError)
-		return
-	}
-
-	r.Header.Set("X-Forwarded-Host", r.Host)
-	requestURL := r.URL.String()
-	if r.Header["Upgrade"] != nil && strings.ToLower(r.Header["Upgrade"][0]) == "websocket" {
-		//Handle WebSocket request. Forward the custom Upgrade header and rewrite origin
-		r.Header.Set("Zr-Origin-Upgrade", "websocket")
-		requestURL = strings.TrimPrefix(requestURL, "/")
-		u, _ := url.Parse("ws://127.0.0.1:" + strconv.Itoa(targetInstance.AssignedPort) + "/" + requestURL)
-		wspHandler := websocketproxy.NewProxy(u, websocketproxy.Options{
-			SkipTLSValidation: false,
-			SkipOriginCheck:   false,
-			Logger:            nil,
-		})
-		wspHandler.ServeHTTP(w, r)
-		return
-	}
-
-	targetInstance.conn.ProxyHTTP(w, r)
-}
-
-func (m *Manager) GetInstanceById(instanceId string) (*Instance, error) {
-	for _, instance := range m.Instances {
-		if instance.UUID == instanceId {
-			return instance, nil
-		}
-	}
-	return nil, fmt.Errorf("instance not found: %s", instanceId)
-}
-func (m *Manager) NewSSHProxy(binaryRoot string) (*Instance, error) {
-	//Check if the binary exists in system/gotty/
-	binary := "gotty_" + runtime.GOOS + "_" + runtime.GOARCH
-
-	if runtime.GOOS == "windows" {
-		binary = binary + ".exe"
-	}
-
-	//Extract it from embedfs if not exists locally
-	execPath := filepath.Join(binaryRoot, binary)
-
-	//Create the storage folder structure
-	os.MkdirAll(filepath.Dir(execPath), 0775)
-
-	//Create config file if not exists
-	if !utils.FileExists(filepath.Join(filepath.Dir(execPath), ".gotty")) {
-		configFile, _ := gotty.ReadFile("gotty/.gotty")
-		os.WriteFile(filepath.Join(filepath.Dir(execPath), ".gotty"), configFile, 0775)
-	}
-
-	//Create web.ssh binary if not exists
-	if !utils.FileExists(execPath) {
-		//Try to extract it from embedded fs
-		executable, err := gotty.ReadFile("gotty/" + binary)
-		if err != nil {
-			//Binary not found in embedded
-			return nil, errors.New("platform not supported")
-		}
-
-		//Extract to target location
-		err = os.WriteFile(execPath, executable, 0777)
-		if err != nil {
-			//Binary not found in embedded
-			log.Println("Extract web.ssh failed: " + err.Error())
-			return nil, errors.New("web.ssh sub-program extract failed")
-		}
-	}
-
-	//Convert the binary path to realpath
-	realpath, err := filepath.Abs(execPath)
-	if err != nil {
-		return nil, err
-	}
-
-	thisInstance := Instance{
-		UUID:         uuid.New().String(),
-		ExecPath:     realpath,
-		AssignedPort: -1,
-		Parent:       m,
-	}
-
-	m.Instances = append(m.Instances, &thisInstance)
-
-	return &thisInstance, nil
-}
-
-// Create a new Connection to target address
-func (i *Instance) CreateNewConnection(listenPort int, username string, remoteIpAddr string, remotePort int) error {
-	//Create a gotty instance
-	connAddr := remoteIpAddr
-	if username != "" {
-		connAddr = username + "@" + remoteIpAddr
-	}
-	configPath := filepath.Join(filepath.Dir(i.ExecPath), ".gotty")
-	title := username + "@" + remoteIpAddr
-	if remotePort != 22 {
-		title = title + ":" + strconv.Itoa(remotePort)
-	}
-
-	sshCommand := []string{"ssh", "-t", connAddr, "-p", strconv.Itoa(remotePort)}
-	cmd := exec.Command(i.ExecPath, "-w", "-p", strconv.Itoa(listenPort), "--once", "--config", configPath, "--title-format", title, "bash", "-c", strings.Join(sshCommand, " "))
-	cmd.Dir = filepath.Dir(i.ExecPath)
-	cmd.Env = append(os.Environ(), "TERM=xterm")
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
-	go func() {
-		cmd.Run()
-		i.Destroy()
-	}()
-	i.tty = cmd
-	i.AssignedPort = listenPort
-	i.RemoteAddr = remoteIpAddr
-	i.RemotePort = remotePort
-
-	//Create a new proxy agent for this root
-	path, err := url.Parse("http://127.0.0.1:" + strconv.Itoa(listenPort))
-	if err != nil {
-		return err
-	}
-
-	//Create new proxy objects to the proxy
-	proxy := reverseproxy.NewReverseProxy(path)
-
-	i.conn = proxy
-	return nil
-}
-
-func (i *Instance) Destroy() {
-	// Remove the instance from the Manager's Instances list
-	for idx, inst := range i.Parent.Instances {
-		if inst == i {
-			// Remove the instance from the slice by swapping it with the last instance and slicing the slice
-			i.Parent.Instances[len(i.Parent.Instances)-1], i.Parent.Instances[idx] = i.Parent.Instances[idx], i.Parent.Instances[len(i.Parent.Instances)-1]
-			i.Parent.Instances = i.Parent.Instances[:len(i.Parent.Instances)-1]
-			break
-		}
-	}
-}
+package sshprox
+
+import (
+	"errors"
+	"fmt"
+	"log"
+	"net/http"
+	"net/url"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"runtime"
+	"strconv"
+	"strings"
+
+	"github.com/google/uuid"
+	"imuslab.com/zoraxy/mod/reverseproxy"
+	"imuslab.com/zoraxy/mod/utils"
+	"imuslab.com/zoraxy/mod/websocketproxy"
+)
+
+/*
+	SSH Proxy
+
+	This is a tool to bind gotty into Zoraxy
+	so that you can do something similar to
+	online ssh terminal
+*/
+
+type Manager struct {
+	StartingPort int
+	Instances    []*Instance
+}
+
+type Instance struct {
+	UUID         string
+	ExecPath     string
+	RemoteAddr   string
+	RemotePort   int
+	AssignedPort int
+	conn         *reverseproxy.ReverseProxy //HTTP proxy
+	tty          *exec.Cmd                  //SSH connection ported to web interface
+	Parent       *Manager
+}
+
+func NewSSHProxyManager() *Manager {
+	return &Manager{
+		StartingPort: 14810,
+		Instances:    []*Instance{},
+	}
+}
+
+func (m *Manager) HandleHttpByInstanceId(instanceId string, w http.ResponseWriter, r *http.Request) {
+	targetInstance, err := m.GetInstanceById(instanceId)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusNotFound)
+		return
+	}
+
+	if targetInstance.tty == nil {
+		//Server side already closed
+		http.Error(w, "Connection already closed", http.StatusInternalServerError)
+		return
+	}
+
+	r.Header.Set("X-Forwarded-Host", r.Host)
+	requestURL := r.URL.String()
+	if r.Header["Upgrade"] != nil && strings.ToLower(r.Header["Upgrade"][0]) == "websocket" {
+		//Handle WebSocket request. Forward the custom Upgrade header and rewrite origin
+		r.Header.Set("Zr-Origin-Upgrade", "websocket")
+		requestURL = strings.TrimPrefix(requestURL, "/")
+		u, _ := url.Parse("ws://127.0.0.1:" + strconv.Itoa(targetInstance.AssignedPort) + "/" + requestURL)
+		wspHandler := websocketproxy.NewProxy(u, websocketproxy.Options{
+			SkipTLSValidation: false,
+			SkipOriginCheck:   false,
+			Logger:            nil,
+		})
+		wspHandler.ServeHTTP(w, r)
+		return
+	}
+
+	targetInstance.conn.ProxyHTTP(w, r)
+}
+
+func (m *Manager) GetInstanceById(instanceId string) (*Instance, error) {
+	for _, instance := range m.Instances {
+		if instance.UUID == instanceId {
+			return instance, nil
+		}
+	}
+	return nil, fmt.Errorf("instance not found: %s", instanceId)
+}
+func (m *Manager) NewSSHProxy(binaryRoot string) (*Instance, error) {
+	//Check if the binary exists in system/gotty/
+	binary := "gotty_" + runtime.GOOS + "_" + runtime.GOARCH
+
+	if runtime.GOOS == "windows" {
+		binary = binary + ".exe"
+	}
+
+	//Extract it from embedfs if not exists locally
+	execPath := filepath.Join(binaryRoot, binary)
+
+	//Create the storage folder structure
+	os.MkdirAll(filepath.Dir(execPath), 0775)
+
+	//Create config file if not exists
+	if !utils.FileExists(filepath.Join(filepath.Dir(execPath), ".gotty")) {
+		configFile, _ := gotty.ReadFile("gotty/.gotty")
+		os.WriteFile(filepath.Join(filepath.Dir(execPath), ".gotty"), configFile, 0775)
+	}
+
+	//Create web.ssh binary if not exists
+	if !utils.FileExists(execPath) {
+		//Try to extract it from embedded fs
+		executable, err := gotty.ReadFile("gotty/" + binary)
+		if err != nil {
+			//Binary not found in embedded
+			return nil, errors.New("platform not supported")
+		}
+
+		//Extract to target location
+		err = os.WriteFile(execPath, executable, 0777)
+		if err != nil {
+			//Binary not found in embedded
+			log.Println("Extract web.ssh failed: " + err.Error())
+			return nil, errors.New("web.ssh sub-program extract failed")
+		}
+	}
+
+	//Convert the binary path to realpath
+	realpath, err := filepath.Abs(execPath)
+	if err != nil {
+		return nil, err
+	}
+
+	thisInstance := Instance{
+		UUID:         uuid.New().String(),
+		ExecPath:     realpath,
+		AssignedPort: -1,
+		Parent:       m,
+	}
+
+	m.Instances = append(m.Instances, &thisInstance)
+
+	return &thisInstance, nil
+}
+
+// Create a new Connection to target address
+func (i *Instance) CreateNewConnection(listenPort int, username string, remoteIpAddr string, remotePort int) error {
+	//Create a gotty instance
+	connAddr := remoteIpAddr
+	if username != "" {
+		connAddr = username + "@" + remoteIpAddr
+	}
+
+	//Trim the space in the username and remote address
+	username = strings.TrimSpace(username)
+	remoteIpAddr = strings.TrimSpace(remoteIpAddr)
+
+	//Validate the username and remote address
+	err := ValidateUsernameAndRemoteAddr(username, remoteIpAddr)
+	if err != nil {
+		return err
+	}
+
+	configPath := filepath.Join(filepath.Dir(i.ExecPath), ".gotty")
+	title := username + "@" + remoteIpAddr
+	if remotePort != 22 {
+		title = title + ":" + strconv.Itoa(remotePort)
+	}
+
+	sshCommand := []string{"ssh", "-t", connAddr, "-p", strconv.Itoa(remotePort)}
+	cmd := exec.Command(i.ExecPath, "-w", "-p", strconv.Itoa(listenPort), "--once", "--config", configPath, "--title-format", title, "bash", "-c", strings.Join(sshCommand, " "))
+	cmd.Dir = filepath.Dir(i.ExecPath)
+	cmd.Env = append(os.Environ(), "TERM=xterm")
+	cmd.Stdout = os.Stdout
+	cmd.Stderr = os.Stderr
+	go func() {
+		cmd.Run()
+		i.Destroy()
+	}()
+	i.tty = cmd
+	i.AssignedPort = listenPort
+	i.RemoteAddr = remoteIpAddr
+	i.RemotePort = remotePort
+
+	//Create a new proxy agent for this root
+	path, err := url.Parse("http://127.0.0.1:" + strconv.Itoa(listenPort))
+	if err != nil {
+		return err
+	}
+
+	//Create new proxy objects to the proxy
+	proxy := reverseproxy.NewReverseProxy(path)
+
+	i.conn = proxy
+	return nil
+}
+
+func (i *Instance) Destroy() {
+	// Remove the instance from the Manager's Instances list
+	for idx, inst := range i.Parent.Instances {
+		if inst == i {
+			// Remove the instance from the slice by swapping it with the last instance and slicing the slice
+			i.Parent.Instances[len(i.Parent.Instances)-1], i.Parent.Instances[idx] = i.Parent.Instances[idx], i.Parent.Instances[len(i.Parent.Instances)-1]
+			i.Parent.Instances = i.Parent.Instances[:len(i.Parent.Instances)-1]
+			break
+		}
+	}
+}

+ 66 - 0
mod/sshprox/sshprox_test.go

@@ -0,0 +1,66 @@
+package sshprox
+
+import (
+	"testing"
+)
+
+func TestInstance_Destroy(t *testing.T) {
+	manager := NewSSHProxyManager()
+	instance, err := manager.NewSSHProxy("/tmp")
+	if err != nil {
+		t.Fatalf("Failed to create new SSH proxy: %v", err)
+	}
+
+	instance.Destroy()
+
+	if len(manager.Instances) != 0 {
+		t.Errorf("Expected Instances to be empty, got %d", len(manager.Instances))
+	}
+}
+
+func TestInstance_ValidateUsernameAndRemoteAddr(t *testing.T) {
+	tests := []struct {
+		username    string
+		remoteAddr  string
+		expectError bool
+	}{
+		{"validuser", "127.0.0.1", false},
+		{"valid.user", "example.com", false},
+		{"; bash ;", "example.com", true},
+		{"valid-user", "example.com", false},
+		{"invalid user", "127.0.0.1", true},
+		{"validuser", "invalid address", true},
+		{"invalid@user", "127.0.0.1", true},
+		{"validuser", "invalid@address", true},
+		{"injection; rm -rf /", "127.0.0.1", true},
+		{"validuser", "127.0.0.1; rm -rf /", true},
+		{"$(reboot)", "127.0.0.1", true},
+		{"validuser", "$(reboot)", true},
+		{"validuser", "127.0.0.1; $(reboot)", true},
+		{"validuser", "127.0.0.1 | ls", true},
+		{"validuser", "127.0.0.1 & ls", true},
+		{"validuser", "127.0.0.1 && ls", true},
+		{"validuser", "127.0.0.1 |& ls", true},
+		{"validuser", "127.0.0.1 ; ls", true},
+		{"validuser", "2001:0db8:85a3:0000:0000:8a2e:0370:7334", false},
+		{"validuser", "2001:db8::ff00:42:8329", false},
+		{"validuser", "2001:db8:0:1234:0:567:8:1", false},
+		{"validuser", "2001:db8::1234:0:567:8:1", false},
+		{"validuser", "2001:db8:0:0:0:0:2:1", false},
+		{"validuser", "2001:db8::2:1", false},
+		{"validuser", "2001:db8:0:0:8:800:200c:417a", false},
+		{"validuser", "2001:db8::8:800:200c:417a", false},
+		{"validuser", "2001:db8:0:0:8:800:200c:417a; rm -rf /", true},
+		{"validuser", "2001:db8::8:800:200c:417a; rm -rf /", true},
+	}
+
+	for _, test := range tests {
+		err := ValidateUsernameAndRemoteAddr(test.username, test.remoteAddr)
+		if test.expectError && err == nil {
+			t.Errorf("Expected error for username %s and remoteAddr %s, but got none", test.username, test.remoteAddr)
+		}
+		if !test.expectError && err != nil {
+			t.Errorf("Did not expect error for username %s and remoteAddr %s, but got %v", test.username, test.remoteAddr, err)
+		}
+	}
+}

+ 57 - 6
mod/sshprox/utils.go

@@ -1,9 +1,11 @@
 package sshprox
 package sshprox
 
 
 import (
 import (
+	"errors"
 	"fmt"
 	"fmt"
 	"net"
 	"net"
 	"net/url"
 	"net/url"
+	"regexp"
 	"runtime"
 	"runtime"
 	"strings"
 	"strings"
 	"time"
 	"time"
@@ -34,6 +36,21 @@ func IsWebSSHSupported() bool {
 	return true
 	return true
 }
 }
 
 
+// Get the next free port in the list
+func (m *Manager) GetNextPort() int {
+	nextPort := m.StartingPort
+	occupiedPort := make(map[int]bool)
+	for _, instance := range m.Instances {
+		occupiedPort[instance.AssignedPort] = true
+	}
+	for {
+		if !occupiedPort[nextPort] {
+			return nextPort
+		}
+		nextPort++
+	}
+}
+
 // Check if a given domain and port is a valid ssh server
 // Check if a given domain and port is a valid ssh server
 func IsSSHConnectable(ipOrDomain string, port int) bool {
 func IsSSHConnectable(ipOrDomain string, port int) bool {
 	timeout := time.Second * 3
 	timeout := time.Second * 3
@@ -60,13 +77,47 @@ func IsSSHConnectable(ipOrDomain string, port int) bool {
 	return string(buf[:7]) == "SSH-2.0"
 	return string(buf[:7]) == "SSH-2.0"
 }
 }
 
 
-// Check if the port is used by other process or application
-func isPortInUse(port int) bool {
-	address := fmt.Sprintf(":%d", port)
-	listener, err := net.Listen("tcp", address)
-	if err != nil {
+// Validate the username and remote address to prevent injection
+func ValidateUsernameAndRemoteAddr(username string, remoteIpAddr string) error {
+	// Validate and sanitize the username to prevent ssh injection
+	validUsername := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
+	if !validUsername.MatchString(username) {
+		return errors.New("invalid username, only alphanumeric characters, dots, underscores and dashes are allowed")
+	}
+
+	//Check if the remoteIpAddr is a valid ipv4 or ipv6 address
+	if net.ParseIP(remoteIpAddr) != nil {
+		//A valid IP address do not need further validation
+		return nil
+	}
+
+	// Validate and sanitize the remote domain to prevent injection
+	validRemoteAddr := regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
+	if !validRemoteAddr.MatchString(remoteIpAddr) {
+		return errors.New("invalid remote address, only alphanumeric characters, dots, underscores and dashes are allowed")
+	}
+
+	return nil
+}
+
+// Check if the given ip or domain is a loopback address
+// or resolves to a loopback address
+func IsLoopbackIPOrDomain(ipOrDomain string) bool {
+	if strings.EqualFold(strings.TrimSpace(ipOrDomain), "localhost") || strings.TrimSpace(ipOrDomain) == "127.0.0.1" {
 		return true
 		return true
 	}
 	}
-	listener.Close()
+
+	//Check if the ipOrDomain resolves to a loopback address
+	ips, err := net.LookupIP(ipOrDomain)
+	if err != nil {
+		return false
+	}
+
+	for _, ip := range ips {
+		if ip.IsLoopback() {
+			return true
+		}
+	}
+
 	return false
 	return false
 }
 }

+ 2 - 2
webssh.go

@@ -42,7 +42,7 @@ func HandleCreateProxySession(w http.ResponseWriter, r *http.Request) {
 
 
 	if !*allowSshLoopback {
 	if !*allowSshLoopback {
 		//Not allow loopback connections
 		//Not allow loopback connections
-		if strings.EqualFold(strings.TrimSpace(ipaddr), "localhost") || strings.TrimSpace(ipaddr) == "127.0.0.1" {
+		if sshprox.IsLoopbackIPOrDomain(ipaddr) {
 			//Request target is loopback
 			//Request target is loopback
 			utils.SendErrorResponse(w, "loopback web ssh connection is not enabled on this host")
 			utils.SendErrorResponse(w, "loopback web ssh connection is not enabled on this host")
 			return
 			return
@@ -74,7 +74,7 @@ func HandleCreateProxySession(w http.ResponseWriter, r *http.Request) {
 	utils.SendJSONResponse(w, string(js))
 	utils.SendJSONResponse(w, string(js))
 }
 }
 
 
-//Check if the host support ssh, or if the target domain (and port, optional) support ssh
+// Check if the host support ssh, or if the target domain (and port, optional) support ssh
 func HandleWebSshSupportCheck(w http.ResponseWriter, r *http.Request) {
 func HandleWebSshSupportCheck(w http.ResponseWriter, r *http.Request) {
 	domain, err := utils.PostPara(r, "domain")
 	domain, err := utils.PostPara(r, "domain")
 	if err != nil {
 	if err != nil {