fix: harden local proxy and import boundaries

This commit is contained in:
Andy Dixon
2026-06-16 17:09:20 +01:00
parent 739651d548
commit cb2fbf70eb
12 changed files with 1189 additions and 49 deletions
+35 -6
View File
@@ -49,6 +49,7 @@ const (
maxSFTPUploadChunkBytes = 1024 * 1024
maxStreamingSFTPUploadSize = int64(2 * 1024 * 1024 * 1024)
maxSSHPublicKeyBytes = 64 * 1024
sshKeygenTimeout = 30 * time.Second
)
// Service is the main API surface exposed to the frontend via Wails bindings.
@@ -1809,6 +1810,10 @@ func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphra
if err != nil {
return err
}
keyType, bits, err = validateSSHKeyGenerationOptions(keyType, bits)
if err != nil {
return err
}
sshDir := filepath.Dir(keyPath)
// Ensure .ssh directory exists
@@ -1821,10 +1826,6 @@ func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphra
return fmt.Errorf("key %q already exists", name)
}
if keyType == "" {
keyType = "ed25519"
}
// Build ssh-keygen arguments
args := []string{"-t", keyType, "-f", keyPath, "-C", name + "@nexus-terminal"}
if passphrase != "" {
@@ -1837,10 +1838,15 @@ func (s *Service) GenerateSSHKey(name string, keyType string, bits int, passphra
}
// #nosec G204 — arguments are sanitised above
cmd := exec.Command("ssh-keygen", args...)
ctx, cancel := context.WithTimeout(context.Background(), sshKeygenTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "ssh-keygen", args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("ssh-keygen timed out after %s", sshKeygenTimeout)
}
return fmt.Errorf("ssh-keygen failed: %s", stderr.String())
}
@@ -1929,12 +1935,35 @@ func safeSFTPUploadName(fileName string) (string, error) {
}
name = strings.ReplaceAll(name, "\\", "/")
safeName := path.Base(name)
if safeName == "" || safeName == "." || safeName == "/" || strings.ContainsRune(safeName, 0) {
if safeName == "" || safeName == "." || safeName == ".." || safeName == "/" || strings.ContainsRune(safeName, 0) {
return "", fmt.Errorf("invalid file name")
}
return safeName, nil
}
func validateSSHKeyGenerationOptions(keyType string, bits int) (string, int, error) {
keyType = strings.ToLower(strings.TrimSpace(keyType))
if keyType == "" {
keyType = "ed25519"
}
switch keyType {
case "ed25519", "ecdsa":
return keyType, bits, nil
case "rsa":
if bits == 0 {
bits = 4096
}
switch bits {
case 2048, 3072, 4096:
return keyType, bits, nil
default:
return "", 0, fmt.Errorf("unsupported RSA key size %d (use 2048, 3072, or 4096)", bits)
}
default:
return "", 0, fmt.Errorf("unsupported SSH key type %q", keyType)
}
}
func (s *Service) lookupSFTPUpload(uploadID string) (*sftpUpload, error) {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
+38
View File
@@ -138,6 +138,44 @@ func TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) {
}
}
func TestSafeSFTPUploadNameRejectsParentDirectory(t *testing.T) {
for _, name := range []string{"..", "folder/..", `folder\..`} {
t.Run(name, func(t *testing.T) {
if _, err := safeSFTPUploadName(name); err == nil {
t.Fatal("expected parent directory upload name to be rejected")
}
})
}
}
func TestValidateSSHKeyGenerationOptions(t *testing.T) {
keyType, bits, err := validateSSHKeyGenerationOptions("", 0)
if err != nil {
t.Fatalf("default options error = %v", err)
}
if keyType != "ed25519" {
t.Fatalf("default key type = %q, want ed25519", keyType)
}
if bits != 0 {
t.Fatalf("default bits = %d, want 0", bits)
}
keyType, bits, err = validateSSHKeyGenerationOptions("rsa", 0)
if err != nil {
t.Fatalf("rsa default options error = %v", err)
}
if keyType != "rsa" || bits != 4096 {
t.Fatalf("rsa defaults = %q/%d, want rsa/4096", keyType, bits)
}
if _, _, err := validateSSHKeyGenerationOptions("rsa", 999999); err == nil {
t.Fatal("expected oversized RSA key size to be rejected")
}
if _, _, err := validateSSHKeyGenerationOptions("dsa", 0); err == nil {
t.Fatal("expected unsupported key type to be rejected")
}
}
func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) {
svc := newTestService(t)
data := `[
+68 -7
View File
@@ -9,6 +9,7 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"sort"
"strings"
@@ -22,6 +23,7 @@ import (
const (
maxGitHubJSONBodyBytes = 4 << 20
maxGitHubErrorBodyBytes = 64 << 10
maxGitHubArchiveBytes = int64(2 << 30)
)
// Client wraps the GitHub REST API for repository listing.
@@ -190,9 +192,16 @@ func (c *Client) fetchRepos(ctx context.Context, progress func(done int, total i
// FetchLatestCommit returns the committer name and commit time for a repo/ref.
func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref string) (string, string, error) {
url := fmt.Sprintf("%s/repos/%s/commits/%s", c.baseURL, fullName, ref)
if ref == "" {
url = fmt.Sprintf("%s/repos/%s/commits/HEAD", c.baseURL, fullName)
ref = "HEAD"
}
owner, repo, err := splitRepoFullName(fullName)
if err != nil {
return "", "", err
}
url, err := c.endpointURL("repos", owner, repo, "commits", ref)
if err != nil {
return "", "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -252,12 +261,17 @@ func (c *Client) FetchLatestCommit(ctx context.Context, fullName string, ref str
// DownloadRepoArchive downloads a repository zip archive to the given path.
func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref string, destPath string) error {
if fullName == "" {
return fmt.Errorf("repository name cannot be empty")
owner, repo, err := splitRepoFullName(fullName)
if err != nil {
return err
}
url := fmt.Sprintf("%s/repos/%s/zipball", c.baseURL, fullName)
segments := []string{"repos", owner, repo, "zipball"}
if ref != "" {
url = url + "/" + ref
segments = append(segments, ref)
}
url, err := c.endpointURL(segments...)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@@ -280,6 +294,9 @@ func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref s
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("GitHub API returned HTTP %d: %s", resp.StatusCode, readGitHubErrorBody(resp.Body))
}
if resp.ContentLength > maxGitHubArchiveBytes {
return fmt.Errorf("archive is too large: %d bytes exceeds %d bytes", resp.ContentLength, maxGitHubArchiveBytes)
}
file, err := os.Create(destPath)
if err != nil {
@@ -287,7 +304,7 @@ func (c *Client) DownloadRepoArchive(ctx context.Context, fullName string, ref s
}
defer file.Close()
if _, err := io.Copy(file, resp.Body); err != nil {
if _, err := copyLimitedArchive(file, resp.Body, maxGitHubArchiveBytes); err != nil {
return fmt.Errorf("writing archive: %w", err)
}
return nil
@@ -345,3 +362,47 @@ func readGitHubErrorBody(r io.Reader) string {
}
return strings.TrimSpace(string(body))
}
func splitRepoFullName(fullName string) (string, string, error) {
parts := strings.Split(strings.TrimSpace(fullName), "/")
if len(parts) != 2 {
return "", "", fmt.Errorf("repository name must be in owner/repo format")
}
owner := strings.TrimSpace(parts[0])
repo := strings.TrimSpace(parts[1])
if owner == "" || repo == "" {
return "", "", fmt.Errorf("repository name must be in owner/repo format")
}
return owner, repo, nil
}
func (c *Client) endpointURL(segments ...string) (string, error) {
u, err := url.Parse(c.baseURL)
if err != nil {
return "", fmt.Errorf("invalid GitHub base URL: %w", err)
}
plainPath := strings.TrimRight(u.Path, "/")
escapedPath := strings.TrimRight(u.EscapedPath(), "/")
for _, segment := range segments {
if segment == "" {
return "", fmt.Errorf("empty GitHub API path segment")
}
plainPath += "/" + segment
escapedPath += "/" + url.PathEscape(segment)
}
u.Path = plainPath
u.RawPath = escapedPath
return u.String(), nil
}
func copyLimitedArchive(dst io.Writer, src io.Reader, maxBytes int64) (int64, error) {
limited := &io.LimitedReader{R: src, N: maxBytes + 1}
n, err := io.Copy(dst, limited)
if err != nil {
return n, err
}
if n > maxBytes {
return n, fmt.Errorf("archive exceeds %d bytes", maxBytes)
}
return n, nil
}
@@ -1,6 +1,8 @@
package githubclient
import (
"bytes"
"io"
"strings"
"testing"
)
@@ -24,3 +26,50 @@ func TestReadLimitedGitHubBodyAllowsResponseAtLimit(t *testing.T) {
t.Fatalf("body = %q, want abc", string(got))
}
}
func TestEndpointURLEscapesRepoPathSegments(t *testing.T) {
client := New("https://ghe.example.com/api/v3", "")
got, err := client.endpointURL("repos", "owner", "repo", "commits", "feature/a b")
if err != nil {
t.Fatalf("endpointURL() error = %v", err)
}
want := "https://ghe.example.com/api/v3/repos/owner/repo/commits/feature%2Fa%20b"
if got != want {
t.Fatalf("endpointURL() = %q, want %q", got, want)
}
}
func TestSplitRepoFullNameRejectsExtraSegments(t *testing.T) {
if _, _, err := splitRepoFullName("owner/repo/../../user"); err == nil {
t.Fatal("expected malformed repository name to be rejected")
}
}
func TestCopyLimitedArchiveRejectsOversizedStream(t *testing.T) {
var dst bytes.Buffer
n, err := copyLimitedArchive(&dst, strings.NewReader("abcd"), 3)
if err == nil {
t.Fatal("expected oversized archive stream to be rejected")
}
if n != 4 {
t.Fatalf("copied = %d, want 4", n)
}
if !strings.Contains(err.Error(), "archive exceeds") {
t.Fatalf("error = %q, want archive exceeds", err.Error())
}
}
func TestCopyLimitedArchiveAllowsStreamAtLimit(t *testing.T) {
var dst bytes.Buffer
n, err := copyLimitedArchive(&dst, strings.NewReader("abc"), 3)
if err != nil {
t.Fatalf("copyLimitedArchive() error = %v", err)
}
if n != 3 {
t.Fatalf("copied = %d, want 3", n)
}
if got, err := io.ReadAll(&dst); err != nil || string(got) != "abc" {
t.Fatalf("dst = %q, err = %v; want abc", string(got), err)
}
}
+48 -20
View File
@@ -22,16 +22,22 @@ import (
"github.com/google/uuid"
)
const (
maxConfigFileBytes = 5 * 1024 * 1024
maxConfigLineBytes = 256 * 1024
maxHostBlocks = 5000
)
// HostBlock represents a parsed SSH config Host section.
type HostBlock struct {
Aliases []string
HostName string
Port int
User string
IdentityFile string
ProxyJump string
ForwardAgent bool
Compression bool
Aliases []string
HostName string
Port int
User string
IdentityFile string
ProxyJump string
ForwardAgent bool
Compression bool
}
// ParseFile reads and parses an OpenSSH config file.
@@ -41,11 +47,33 @@ func ParseFile(path string) ([]HostBlock, error) {
return nil, fmt.Errorf("sshconfig: failed to open %s: %w", path, err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("sshconfig: failed to stat %s: %w", path, err)
}
if info.IsDir() {
return nil, fmt.Errorf("sshconfig: %s is a directory", path)
}
if info.Size() > maxConfigFileBytes {
return nil, fmt.Errorf("sshconfig: config file is too large: %d bytes exceeds %d bytes", info.Size(), maxConfigFileBytes)
}
var blocks []HostBlock
var current *HostBlock
commitCurrent := func() error {
if current == nil {
return nil
}
if len(blocks) >= maxHostBlocks {
return fmt.Errorf("sshconfig: too many Host blocks (max %d)", maxHostBlocks)
}
blocks = append(blocks, *current)
current = nil
return nil
}
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), maxConfigLineBytes)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
@@ -61,8 +89,8 @@ func ParseFile(path string) ([]HostBlock, error) {
switch key {
case "host":
// Commit previous block.
if current != nil {
blocks = append(blocks, *current)
if err := commitCurrent(); err != nil {
return nil, err
}
aliases := strings.Fields(value)
// Skip wildcard-only hosts (e.g. "Host *").
@@ -83,8 +111,8 @@ func ParseFile(path string) ([]HostBlock, error) {
case "match":
// Commit previous block and skip Match blocks.
if current != nil {
blocks = append(blocks, *current)
if err := commitCurrent(); err != nil {
return nil, err
}
current = nil
@@ -122,8 +150,8 @@ func ParseFile(path string) ([]HostBlock, error) {
}
// Commit final block.
if current != nil {
blocks = append(blocks, *current)
if err := commitCurrent(); err != nil {
return nil, err
}
if err := scanner.Err(); err != nil {
@@ -161,13 +189,13 @@ func ToProfiles(blocks []HostBlock, folder string) []models.Profile {
username := b.User
sshConf := &models.SSHConfig{
AuthMethods: []string{"publickey", "password"},
SecretMode: models.SecretModeAskEveryTime,
HostKeyPolicy: models.HostKeyAsk,
TerminalType: "xterm-256color",
AgentAuth: b.ForwardAgent,
AuthMethods: []string{"publickey", "password"},
SecretMode: models.SecretModeAskEveryTime,
HostKeyPolicy: models.HostKeyAsk,
TerminalType: "xterm-256color",
AgentAuth: b.ForwardAgent,
AgentForwarding: b.ForwardAgent,
Compression: b.Compression,
Compression: b.Compression,
}
if b.IdentityFile != "" {
+46
View File
@@ -3,6 +3,8 @@ package sshconfig
import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
)
@@ -89,6 +91,50 @@ func TestParseFile(t *testing.T) {
}
}
func TestParseFileRejectsOversizedFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "config")
f, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
if err := f.Truncate(maxConfigFileBytes + 1); err != nil {
f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
_, err = ParseFile(path)
if err == nil {
t.Fatal("expected oversized config file to be rejected")
}
if !strings.Contains(err.Error(), "too large") {
t.Fatalf("error = %q, want too large", err.Error())
}
}
func TestParseFileRejectsTooManyHostBlocks(t *testing.T) {
path := filepath.Join(t.TempDir(), "config")
var b strings.Builder
for i := 0; i < maxHostBlocks+1; i++ {
b.WriteString("Host host")
b.WriteString(strconv.Itoa(i))
b.WriteString("\n HostName example.com\n")
}
if err := os.WriteFile(path, []byte(b.String()), 0600); err != nil {
t.Fatal(err)
}
_, err := ParseFile(path)
if err == nil {
t.Fatal("expected excessive Host blocks to be rejected")
}
if !strings.Contains(err.Error(), "too many Host blocks") {
t.Fatalf("error = %q, want too many Host blocks", err.Error())
}
}
func TestToProfiles(t *testing.T) {
blocks := []HostBlock{
{
+34 -1
View File
@@ -9,6 +9,8 @@ import (
"fmt"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
@@ -53,11 +55,13 @@ type Config struct {
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
CheckOrigin: allowProxyOrigin,
ReadBufferSize: 65536,
WriteBufferSize: 65536,
}
const maxWebSocketMessageBytes = 8 << 20
// NewClient creates a new VNC proxy client.
func NewClient(cfg Config) *Client {
ctx, cancel := context.WithCancel(context.Background())
@@ -186,6 +190,7 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
return
}
defer ws.Close()
ws.SetReadLimit(maxWebSocketMessageBytes)
// Bridge WebSocket ↔ VNC TCP in both directions
done := make(chan struct{}, 2)
@@ -252,6 +257,34 @@ func (c *Client) startProxy(vncConn net.Conn) (string, error) {
return wsURL, nil
}
func allowProxyOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil {
return false
}
switch strings.ToLower(u.Scheme) {
case "wails":
return true
case "http", "https":
return isLoopbackHost(u.Hostname())
default:
return false
}
}
func isLoopbackHost(host string) bool {
h := strings.ToLower(strings.TrimSpace(host))
if h == "localhost" {
return true
}
ip := net.ParseIP(h)
return ip != nil && ip.IsLoopback()
}
func newProxyToken() (string, error) {
token := make([]byte, 16)
if _, err := rand.Read(token); err != nil {
+36 -1
View File
@@ -1,6 +1,9 @@
package vncclient
import "testing"
import (
"net/http"
"testing"
)
func TestNewProxyTokenIsRandomHex(t *testing.T) {
first, err := newProxyToken()
@@ -24,3 +27,35 @@ func TestNewProxyTokenIsRandomHex(t *testing.T) {
}
}
}
func TestAllowProxyOrigin(t *testing.T) {
tests := []struct {
name string
origin string
want bool
}{
{name: "missing origin", origin: "", want: true},
{name: "wails origin", origin: "wails://wails", want: true},
{name: "localhost http", origin: "http://localhost:34115", want: true},
{name: "loopback ipv4", origin: "http://127.0.0.1:34115", want: true},
{name: "loopback ipv6", origin: "http://[::1]:34115", want: true},
{name: "remote http", origin: "http://example.com", want: false},
{name: "lookalike host", origin: "http://127.0.0.1.example.com", want: false},
{name: "bad origin", origin: "://bad", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, "http://127.0.0.1/ws", nil)
if err != nil {
t.Fatal(err)
}
if tt.origin != "" {
req.Header.Set("Origin", tt.origin)
}
if got := allowProxyOrigin(req); got != tt.want {
t.Fatalf("allowProxyOrigin(%q) = %v, want %v", tt.origin, got, tt.want)
}
})
}
}