Security Fixes

This commit is contained in:
Andy Dixon
2026-06-16 09:41:51 +01:00
parent 9bbd9ceccf
commit fcb19c96c8
22 changed files with 1578 additions and 134 deletions
+199 -19
View File
@@ -8,6 +8,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"os"
"os/exec"
@@ -43,8 +44,10 @@ var appVersion = "dev"
var appUpdateURL = ""
const (
maxProfileImportJSONBytes = 5 * 1024 * 1024
maxInlineSFTPUploadBytes = 25 * 1024 * 1024
maxProfileImportJSONBytes = 5 * 1024 * 1024
maxInlineSFTPUploadBytes = 25 * 1024 * 1024
maxSFTPUploadChunkBytes = 1024 * 1024
maxStreamingSFTPUploadSize = int64(2 * 1024 * 1024 * 1024)
)
// Service is the main API surface exposed to the frontend via Wails bindings.
@@ -60,6 +63,17 @@ type Service struct {
// secretChans maps request IDs to channels awaiting user secret input.
secretMu sync.Mutex
secretChans map[string]chan secretResponse
uploadMu sync.Mutex
uploads map[string]*sftpUpload
}
type sftpUpload struct {
mu sync.Mutex
writer io.WriteCloser
sessionID string
remotePath string
written int64
}
// NewService creates a Service wired to the given backing components.
@@ -78,6 +92,7 @@ func NewService(
sessions: sess,
auditLog: auditLog,
prefs: prefs,
uploads: make(map[string]*sftpUpload),
}
}
@@ -1088,6 +1103,39 @@ func (s *Service) DeleteRecording(id string) error {
return s.sessions.DeleteRecording(id)
}
// GetRecordingStorageInfo returns aggregate recording storage usage and limits.
func (s *Service) GetRecordingStorageInfo() (map[string]interface{}, error) {
info, err := s.sessions.RecordingStorageInfo()
if err != nil {
return nil, err
}
result := map[string]interface{}{
"count": info.Count,
"totalBytes": info.TotalBytes,
"maxRecordingBytes": info.MaxRecordingBytes,
"maxPlaybackBytes": info.MaxPlaybackBytes,
}
if info.OldestCreatedAt != nil {
result["oldestCreatedAt"] = info.OldestCreatedAt.Format(time.RFC3339)
}
if info.NewestCreatedAt != nil {
result["newestCreatedAt"] = info.NewestCreatedAt.Format(time.RFC3339)
}
return result, nil
}
// CleanupRecordings removes recordings by age and total storage limit.
func (s *Service) CleanupRecordings(maxAgeDays int, maxTotalBytes int64) (map[string]interface{}, error) {
result, err := s.sessions.CleanupRecordings(maxAgeDays, maxTotalBytes)
if err != nil {
return nil, err
}
return map[string]interface{}{
"deleted": result.Deleted,
"freedBytes": result.FreedBytes,
}, nil
}
// ---------------------------------------------------------------------------
// Encrypted export / import
// ---------------------------------------------------------------------------
@@ -1349,15 +1397,120 @@ func (s *Service) SFTPUploadData(sessionID string, remoteDir string, fileName st
return fmt.Errorf("upload payload is too large (max %d bytes)", maxInlineSFTPUploadBytes)
}
safeName := filepath.Base(fileName)
if safeName == "" || safeName == "." || safeName == "/" {
return fmt.Errorf("invalid file name")
safeName, err := safeSFTPUploadName(fileName)
if err != nil {
return err
}
destPath := path.Join(remoteDir, safeName)
return s.sessions.SFTPUploadBytes(sessionID, destPath, data)
}
// SFTPUploadStart opens a remote SFTP file for chunked browser uploads.
func (s *Service) SFTPUploadStart(sessionID string, remoteDir string, fileName string, totalBytes int64) (string, error) {
if s.sessions == nil {
return "", fmt.Errorf("sessions service is not configured")
}
if totalBytes < 0 {
return "", fmt.Errorf("upload size must not be negative")
}
if totalBytes > maxStreamingSFTPUploadSize {
return "", fmt.Errorf("upload is too large (max %d bytes)", maxStreamingSFTPUploadSize)
}
safeName, err := safeSFTPUploadName(fileName)
if err != nil {
return "", err
}
remotePath := path.Join(remoteDir, safeName)
writer, err := s.sessions.SFTPOpenUpload(sessionID, remotePath)
if err != nil {
return "", err
}
uploadID := uuid.New().String()
s.uploadMu.Lock()
s.uploads[uploadID] = &sftpUpload{
writer: writer,
sessionID: sessionID,
remotePath: remotePath,
}
s.uploadMu.Unlock()
return uploadID, nil
}
// SFTPUploadChunk writes a base64-encoded chunk to an active browser upload.
func (s *Service) SFTPUploadChunk(uploadID string, base64Chunk string) error {
if base64Chunk == "" {
return fmt.Errorf("empty upload chunk")
}
if base64.StdEncoding.DecodedLen(len(base64Chunk)) > maxSFTPUploadChunkBytes {
return fmt.Errorf("upload chunk is too large (max %d bytes)", maxSFTPUploadChunkBytes)
}
data, err := base64.StdEncoding.DecodeString(base64Chunk)
if err != nil {
return fmt.Errorf("failed to decode upload chunk: %w", err)
}
if len(data) > maxSFTPUploadChunkBytes {
return fmt.Errorf("upload chunk is too large (max %d bytes)", maxSFTPUploadChunkBytes)
}
upload, err := s.lookupSFTPUpload(uploadID)
if err != nil {
return err
}
upload.mu.Lock()
defer upload.mu.Unlock()
if upload.written+int64(len(data)) > maxStreamingSFTPUploadSize {
return fmt.Errorf("upload is too large (max %d bytes)", maxStreamingSFTPUploadSize)
}
n, err := upload.writer.Write(data)
upload.written += int64(n)
if err != nil {
s.removeSFTPUpload(uploadID)
_ = upload.writer.Close()
if s.sessions != nil {
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
}
return fmt.Errorf("failed to write upload chunk: %w", err)
}
if n != len(data) {
s.removeSFTPUpload(uploadID)
_ = upload.writer.Close()
if s.sessions != nil {
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
}
return io.ErrShortWrite
}
return nil
}
// SFTPUploadFinish closes an active browser upload.
func (s *Service) SFTPUploadFinish(uploadID string) error {
upload, err := s.takeSFTPUpload(uploadID)
if err != nil {
return err
}
upload.mu.Lock()
defer upload.mu.Unlock()
return upload.writer.Close()
}
// SFTPUploadCancel closes and removes a partially uploaded remote file.
func (s *Service) SFTPUploadCancel(uploadID string) error {
upload, err := s.takeSFTPUpload(uploadID)
if err != nil {
return err
}
upload.mu.Lock()
closeErr := upload.writer.Close()
upload.mu.Unlock()
if s.sessions != nil {
_ = s.sessions.SFTPRemove(upload.sessionID, upload.remotePath)
}
return closeErr
}
// SFTPPwd returns the current working directory on the remote host.
func (s *Service) SFTPPwd(sessionID string) (string, error) {
return s.sessions.SFTPPwd(sessionID)
@@ -1751,6 +1904,46 @@ func validateSSHKeyName(name string) (string, error) {
return name, nil
}
func safeSFTPUploadName(fileName string) (string, error) {
name := strings.TrimSpace(fileName)
if name == "" {
return "", fmt.Errorf("invalid file name")
}
name = strings.ReplaceAll(name, "\\", "/")
safeName := path.Base(name)
if safeName == "" || safeName == "." || safeName == "/" || strings.ContainsRune(safeName, 0) {
return "", fmt.Errorf("invalid file name")
}
return safeName, nil
}
func (s *Service) lookupSFTPUpload(uploadID string) (*sftpUpload, error) {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
upload := s.uploads[uploadID]
if upload == nil {
return nil, fmt.Errorf("upload session not found")
}
return upload, nil
}
func (s *Service) takeSFTPUpload(uploadID string) (*sftpUpload, error) {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
upload := s.uploads[uploadID]
if upload == nil {
return nil, fmt.Errorf("upload session not found")
}
delete(s.uploads, uploadID)
return upload, nil
}
func (s *Service) removeSFTPUpload(uploadID string) {
s.uploadMu.Lock()
defer s.uploadMu.Unlock()
delete(s.uploads, uploadID)
}
func validateImportStrategy(strategy string) error {
switch strategy {
case "skip", "overwrite", "duplicate":
@@ -1774,20 +1967,7 @@ func validateImportProfiles(profiles []*models.Profile) error {
}
func normaliseProfileForSave(profile *models.Profile) {
if profile == nil {
return
}
if profile.Tags == nil {
profile.Tags = []string{}
}
if profile.VNC != nil {
if profile.VNC.SecretMode == "prompt" {
profile.VNC.SecretMode = models.SecretModeAskEveryTime
}
if profile.VNC.SSHSecretMode == "prompt" {
profile.VNC.SSHSecretMode = models.SecretModeAskEveryTime
}
}
models.NormalizeProfileDefaults(profile)
}
func redactProfileSecrets(profiles []*models.Profile) []*models.Profile {
+76
View File
@@ -113,6 +113,82 @@ func TestSFTPUploadDataRejectsOversizedPayloadBeforeSessionLookup(t *testing.T)
}
}
func TestSFTPUploadChunkRejectsOversizedChunkBeforeUploadLookup(t *testing.T) {
svc := newTestService(t)
payload := base64.StdEncoding.EncodeToString(make([]byte, maxSFTPUploadChunkBytes+1))
err := svc.SFTPUploadChunk("missing-upload", payload)
if err == nil {
t.Fatal("expected oversized upload chunk to be rejected")
}
if !strings.Contains(err.Error(), "chunk is too large") {
t.Fatalf("error = %q, want chunk is too large", err.Error())
}
}
func TestSafeSFTPUploadNamePreservesHTMLCharacters(t *testing.T) {
got, err := safeSFTPUploadName(`../report <final>.txt`)
if err != nil {
t.Fatalf("safeSFTPUploadName() error = %v", err)
}
if got != "report <final>.txt" {
t.Fatalf("safe name = %q, want HTML characters preserved in base name", got)
}
}
func TestImportProfilesNormalizesSparseRAWProfile(t *testing.T) {
svc := newTestService(t)
data := `[
{
"displayName": "Raw Device",
"protocol": "raw",
"host": "device.local",
"raw": {}
}
]`
count, err := svc.ImportProfiles(data, "skip")
if err != nil {
t.Fatalf("ImportProfiles() error = %v", err)
}
if count != 1 {
t.Fatalf("imported = %d, want 1", count)
}
profiles, err := svc.ListProfiles()
if err != nil {
t.Fatalf("ListProfiles() error = %v", err)
}
if len(profiles) != 1 {
t.Fatalf("profiles = %d, want 1", len(profiles))
}
if profiles[0].Port != models.DefaultRAWPort {
t.Fatalf("port = %d, want %d", profiles[0].Port, models.DefaultRAWPort)
}
if profiles[0].RAW.Encoding != "utf-8" {
t.Fatalf("raw encoding = %q, want utf-8", profiles[0].RAW.Encoding)
}
}
func TestCreateProfileNormalizesLegacyVNCSecretMode(t *testing.T) {
svc := newTestService(t)
profile := models.NewProfile(models.ProtocolVNC)
profile.DisplayName = "VNC"
profile.Host = "vnc.local"
profile.VNC.SecretMode = "prompt"
profile.VNC.SSHSecretMode = "prompt"
got, err := svc.CreateProfile(profile)
if err != nil {
t.Fatalf("CreateProfile() error = %v", err)
}
if got.VNC.SecretMode != models.SecretModeAskEveryTime {
t.Fatalf("secret mode = %q, want %q", got.VNC.SecretMode, models.SecretModeAskEveryTime)
}
if got.VNC.SSHSecretMode != models.SecretModeAskEveryTime {
t.Fatalf("ssh secret mode = %q, want %q", got.VNC.SSHSecretMode, models.SecretModeAskEveryTime)
}
}
func newTestService(t *testing.T) *Service {
t.Helper()
store, err := storage.NewStore(filepath.Join(t.TempDir(), "test.db"))