Security Fixes
This commit is contained in:
+199
-19
@@ -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 {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user