mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-14 14:45:49 +01:00
- Refactor whitelist validation to use getSafeCommand() which returns safe command names from a static lookup table instead of user input - This allows CodeQL to trace that executed commands come from a known-safe whitelist rather than tainted user input - Add comprehensive tests for the new getSafeCommand function - Add lgtm[go/path-injection] comments for CodeQL suppression on the example file where paths are properly validated Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
773 lines
20 KiB
Go
773 lines
20 KiB
Go
package setupwizard
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/pkg/browser"
|
|
"github.com/wailsapp/wails/v3/internal/operatingsystem"
|
|
"github.com/wailsapp/wails/v3/internal/version"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
//go:embed frontend/dist/*
|
|
var frontendFS embed.FS
|
|
|
|
// DependencyStatus represents the status of a dependency
|
|
type DependencyStatus struct {
|
|
Name string `json:"name"`
|
|
Installed bool `json:"installed"`
|
|
Version string `json:"version,omitempty"`
|
|
Status string `json:"status"` // "installed", "not_installed", "needs_update"
|
|
Required bool `json:"required"`
|
|
Message string `json:"message,omitempty"`
|
|
InstallCommand string `json:"installCommand,omitempty"`
|
|
HelpURL string `json:"helpUrl,omitempty"`
|
|
}
|
|
|
|
// DockerStatus represents Docker installation and image status
|
|
type DockerStatus struct {
|
|
Installed bool `json:"installed"`
|
|
Running bool `json:"running"`
|
|
Version string `json:"version,omitempty"`
|
|
ImageBuilt bool `json:"imageBuilt"`
|
|
ImageName string `json:"imageName"`
|
|
PullProgress int `json:"pullProgress"`
|
|
PullStatus string `json:"pullStatus"` // "idle", "pulling", "complete", "error"
|
|
PullError string `json:"pullError,omitempty"`
|
|
}
|
|
|
|
// WailsConfigInfo represents the info section of wails.yaml
|
|
type WailsConfigInfo struct {
|
|
CompanyName string `json:"companyName" yaml:"companyName"`
|
|
ProductName string `json:"productName" yaml:"productName"`
|
|
ProductIdentifier string `json:"productIdentifier" yaml:"productIdentifier"`
|
|
Description string `json:"description" yaml:"description"`
|
|
Copyright string `json:"copyright" yaml:"copyright"`
|
|
Comments string `json:"comments,omitempty" yaml:"comments,omitempty"`
|
|
Version string `json:"version" yaml:"version"`
|
|
}
|
|
|
|
// WailsConfig represents the wails.yaml configuration
|
|
type WailsConfig struct {
|
|
Info WailsConfigInfo `json:"info" yaml:"info"`
|
|
}
|
|
|
|
// SystemInfo contains detected system information
|
|
type SystemInfo struct {
|
|
OS string `json:"os"`
|
|
Arch string `json:"arch"`
|
|
WailsVersion string `json:"wailsVersion"`
|
|
GoVersion string `json:"goVersion"`
|
|
HomeDir string `json:"homeDir"`
|
|
OSName string `json:"osName,omitempty"`
|
|
OSVersion string `json:"osVersion,omitempty"`
|
|
}
|
|
|
|
// WizardState represents the complete wizard state
|
|
type WizardState struct {
|
|
Dependencies []DependencyStatus `json:"dependencies"`
|
|
System SystemInfo `json:"system"`
|
|
StartTime time.Time `json:"startTime"`
|
|
}
|
|
|
|
// Wizard is the setup wizard server
|
|
type Wizard struct {
|
|
server *http.Server
|
|
state WizardState
|
|
stateMu sync.RWMutex
|
|
dockerStatus DockerStatus
|
|
dockerMu sync.RWMutex
|
|
done chan struct{}
|
|
shutdown chan struct{}
|
|
}
|
|
|
|
// New creates a new setup wizard
|
|
func New() *Wizard {
|
|
return &Wizard{
|
|
done: make(chan struct{}),
|
|
shutdown: make(chan struct{}),
|
|
state: WizardState{
|
|
StartTime: time.Now(),
|
|
},
|
|
}
|
|
}
|
|
|
|
// Run starts the wizard and opens it in the browser
|
|
func (w *Wizard) Run() error {
|
|
// Initialize system info
|
|
w.initSystemInfo()
|
|
|
|
// Find an available port
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
return fmt.Errorf("failed to find available port: %w", err)
|
|
}
|
|
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
|
url := fmt.Sprintf("http://127.0.0.1:%d", port)
|
|
|
|
// Set up HTTP routes
|
|
mux := http.NewServeMux()
|
|
w.setupRoutes(mux)
|
|
|
|
w.server = &http.Server{
|
|
Handler: mux,
|
|
}
|
|
|
|
// Start server in goroutine
|
|
go func() {
|
|
if err := w.server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
|
fmt.Fprintf(os.Stderr, "Server error: %v\n", err)
|
|
}
|
|
}()
|
|
|
|
fmt.Printf("Setup wizard running at %s\n", url)
|
|
|
|
// Open browser
|
|
if err := browser.OpenURL(url); err != nil {
|
|
fmt.Printf("Please open %s in your browser\n", url)
|
|
}
|
|
|
|
// Wait for completion or shutdown
|
|
select {
|
|
case <-w.done:
|
|
fmt.Println("\nSetup completed successfully!")
|
|
case <-w.shutdown:
|
|
fmt.Println("\nSetup wizard closed.")
|
|
}
|
|
|
|
// Shutdown server
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
return w.server.Shutdown(ctx)
|
|
}
|
|
|
|
func (w *Wizard) setupRoutes(mux *http.ServeMux) {
|
|
// API routes
|
|
mux.HandleFunc("/api/state", w.handleState)
|
|
mux.HandleFunc("/api/dependencies/check", w.handleCheckDependencies)
|
|
mux.HandleFunc("/api/dependencies/install", w.handleInstallDependency)
|
|
mux.HandleFunc("/api/docker/status", w.handleDockerStatus)
|
|
mux.HandleFunc("/api/docker/build", w.handleDockerBuild)
|
|
mux.HandleFunc("/api/docker/start-background", w.handleDockerStartBackground)
|
|
mux.HandleFunc("/api/wails-config", w.handleWailsConfig)
|
|
mux.HandleFunc("/api/defaults", w.handleDefaults)
|
|
mux.HandleFunc("/api/complete", w.handleComplete)
|
|
mux.HandleFunc("/api/close", w.handleClose)
|
|
|
|
// Serve frontend
|
|
frontendDist, err := fs.Sub(frontendFS, "frontend/dist")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
fileServer := http.FileServer(http.FS(frontendDist))
|
|
|
|
mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
|
// Try to serve the file
|
|
path := r.URL.Path
|
|
if path == "/" {
|
|
path = "/index.html"
|
|
}
|
|
|
|
// Check if file exists
|
|
if _, err := fs.Stat(frontendDist, strings.TrimPrefix(path, "/")); err != nil {
|
|
// Serve index.html for SPA routing
|
|
r.URL.Path = "/"
|
|
}
|
|
|
|
fileServer.ServeHTTP(rw, r)
|
|
})
|
|
}
|
|
|
|
func (w *Wizard) initSystemInfo() {
|
|
w.stateMu.Lock()
|
|
defer w.stateMu.Unlock()
|
|
|
|
homeDir, _ := os.UserHomeDir()
|
|
|
|
w.state.System = SystemInfo{
|
|
OS: runtime.GOOS,
|
|
Arch: runtime.GOARCH,
|
|
WailsVersion: version.String(),
|
|
GoVersion: runtime.Version(),
|
|
HomeDir: homeDir,
|
|
}
|
|
|
|
// Get OS details
|
|
if info, err := operatingsystem.Info(); err == nil {
|
|
w.state.System.OSName = info.Name
|
|
w.state.System.OSVersion = info.Version
|
|
}
|
|
}
|
|
|
|
func (w *Wizard) handleState(rw http.ResponseWriter, r *http.Request) {
|
|
w.stateMu.RLock()
|
|
defer w.stateMu.RUnlock()
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(rw).Encode(w.state)
|
|
}
|
|
|
|
func (w *Wizard) handleCheckDependencies(rw http.ResponseWriter, r *http.Request) {
|
|
deps := w.checkAllDependencies()
|
|
|
|
w.stateMu.Lock()
|
|
w.state.Dependencies = deps
|
|
w.stateMu.Unlock()
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(rw).Encode(deps)
|
|
}
|
|
|
|
func (w *Wizard) handleWailsConfig(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
|
|
// Find wails.yaml in current directory or parent directories
|
|
configPath := findWailsConfig()
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
if configPath == "" {
|
|
json.NewEncoder(rw).Encode(nil)
|
|
return
|
|
}
|
|
|
|
data, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
json.NewEncoder(rw).Encode(nil)
|
|
return
|
|
}
|
|
|
|
var config WailsConfig
|
|
if err := yaml.Unmarshal(data, &config); err != nil {
|
|
json.NewEncoder(rw).Encode(nil)
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(rw).Encode(config)
|
|
|
|
case http.MethodPost:
|
|
var config WailsConfig
|
|
if err := json.NewDecoder(r.Body).Decode(&config); err != nil {
|
|
http.Error(rw, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if configPath == "" {
|
|
configPath = "wails.yaml"
|
|
}
|
|
|
|
data, err := yaml.Marshal(&config)
|
|
if err != nil {
|
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
if err := os.WriteFile(configPath, data, 0644); err != nil {
|
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(rw).Encode(map[string]string{"status": "saved", "path": configPath})
|
|
|
|
default:
|
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|
|
|
|
func findWailsConfig() string {
|
|
dir, err := os.Getwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
for {
|
|
configPath := filepath.Join(dir, "wails.yaml")
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
return configPath
|
|
}
|
|
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
break
|
|
}
|
|
dir = parent
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func (w *Wizard) handleComplete(rw http.ResponseWriter, r *http.Request) {
|
|
w.stateMu.RLock()
|
|
state := w.state
|
|
w.stateMu.RUnlock()
|
|
|
|
duration := time.Since(state.StartTime)
|
|
|
|
response := map[string]interface{}{
|
|
"status": "complete",
|
|
"duration": duration.String(),
|
|
}
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(rw).Encode(response)
|
|
|
|
close(w.done)
|
|
}
|
|
|
|
func (w *Wizard) handleClose(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(rw).Encode(map[string]string{"status": "closing"})
|
|
|
|
close(w.shutdown)
|
|
}
|
|
|
|
// execCommand runs a command and returns its output
|
|
func execCommand(name string, args ...string) (string, error) {
|
|
cmd := exec.Command(name, args...)
|
|
output, err := cmd.Output()
|
|
return strings.TrimSpace(string(output)), err
|
|
}
|
|
|
|
// commandExists checks if a command exists in PATH
|
|
func commandExists(name string) bool {
|
|
_, err := exec.LookPath(name)
|
|
return err == nil
|
|
}
|
|
|
|
func (w *Wizard) handleDockerStatus(rw http.ResponseWriter, r *http.Request) {
|
|
status := w.checkDocker()
|
|
|
|
w.dockerMu.Lock()
|
|
w.dockerStatus = status
|
|
w.dockerMu.Unlock()
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(rw).Encode(status)
|
|
}
|
|
|
|
func (w *Wizard) checkDocker() DockerStatus {
|
|
status := DockerStatus{
|
|
ImageName: "wails-cross",
|
|
PullStatus: "idle",
|
|
}
|
|
|
|
// Check if Docker is installed
|
|
output, err := execCommand("docker", "--version")
|
|
if err != nil {
|
|
status.Installed = false
|
|
return status
|
|
}
|
|
|
|
status.Installed = true
|
|
// Parse version from "Docker version 24.0.7, build afdd53b"
|
|
parts := strings.Split(output, ",")
|
|
if len(parts) > 0 {
|
|
status.Version = strings.TrimPrefix(strings.TrimSpace(parts[0]), "Docker version ")
|
|
}
|
|
|
|
// Check if Docker daemon is running
|
|
if _, err := execCommand("docker", "info"); err != nil {
|
|
status.Running = false
|
|
return status
|
|
}
|
|
status.Running = true
|
|
|
|
// Check if wails-cross image exists
|
|
imageOutput, err := execCommand("docker", "image", "inspect", "wails-cross")
|
|
status.ImageBuilt = err == nil && len(imageOutput) > 0
|
|
|
|
return status
|
|
}
|
|
|
|
func (w *Wizard) handleDockerBuild(rw http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
w.dockerMu.Lock()
|
|
w.dockerStatus.PullStatus = "pulling"
|
|
w.dockerStatus.PullProgress = 0
|
|
w.dockerMu.Unlock()
|
|
|
|
// Build the Docker image in background
|
|
go func() {
|
|
// Run: wails3 task setup:docker
|
|
cmd := exec.Command("wails3", "task", "setup:docker")
|
|
err := cmd.Run()
|
|
|
|
w.dockerMu.Lock()
|
|
if err != nil {
|
|
w.dockerStatus.PullStatus = "error"
|
|
w.dockerStatus.PullError = err.Error()
|
|
} else {
|
|
w.dockerStatus.PullStatus = "complete"
|
|
w.dockerStatus.ImageBuilt = true
|
|
}
|
|
w.dockerStatus.PullProgress = 100
|
|
w.dockerMu.Unlock()
|
|
}()
|
|
|
|
// Simulate progress updates while building
|
|
go func() {
|
|
for i := 0; i < 90; i += 5 {
|
|
time.Sleep(2 * time.Second)
|
|
w.dockerMu.Lock()
|
|
if w.dockerStatus.PullStatus != "pulling" {
|
|
w.dockerMu.Unlock()
|
|
return
|
|
}
|
|
w.dockerStatus.PullProgress = i
|
|
w.dockerMu.Unlock()
|
|
}
|
|
}()
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(rw).Encode(map[string]string{"status": "started"})
|
|
}
|
|
|
|
// handleDockerStartBackground checks if Docker is available and starts building in background
|
|
// This is called early in the wizard flow to get a head start on the image build
|
|
func (w *Wizard) handleDockerStartBackground(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
|
|
// Check Docker status first
|
|
status := w.checkDocker()
|
|
|
|
w.dockerMu.Lock()
|
|
w.dockerStatus = status
|
|
w.dockerMu.Unlock()
|
|
|
|
// Only start build if Docker is installed, running, and image not built yet
|
|
if !status.Installed || !status.Running || status.ImageBuilt {
|
|
json.NewEncoder(rw).Encode(map[string]interface{}{
|
|
"started": false,
|
|
"reason": getDockerNotStartedReason(status),
|
|
"status": status,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Check if already building
|
|
w.dockerMu.RLock()
|
|
alreadyBuilding := w.dockerStatus.PullStatus == "pulling"
|
|
w.dockerMu.RUnlock()
|
|
|
|
if alreadyBuilding {
|
|
json.NewEncoder(rw).Encode(map[string]interface{}{
|
|
"started": false,
|
|
"reason": "already_building",
|
|
"status": status,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Start building in background
|
|
w.dockerMu.Lock()
|
|
w.dockerStatus.PullStatus = "pulling"
|
|
w.dockerStatus.PullProgress = 0
|
|
w.dockerMu.Unlock()
|
|
|
|
// Build the Docker image in background
|
|
go func() {
|
|
cmd := exec.Command("wails3", "task", "setup:docker")
|
|
err := cmd.Run()
|
|
|
|
w.dockerMu.Lock()
|
|
if err != nil {
|
|
w.dockerStatus.PullStatus = "error"
|
|
w.dockerStatus.PullError = err.Error()
|
|
} else {
|
|
w.dockerStatus.PullStatus = "complete"
|
|
w.dockerStatus.ImageBuilt = true
|
|
}
|
|
w.dockerStatus.PullProgress = 100
|
|
w.dockerMu.Unlock()
|
|
}()
|
|
|
|
// Simulate progress updates while building
|
|
go func() {
|
|
for i := 0; i < 90; i += 5 {
|
|
time.Sleep(2 * time.Second)
|
|
w.dockerMu.Lock()
|
|
if w.dockerStatus.PullStatus != "pulling" {
|
|
w.dockerMu.Unlock()
|
|
return
|
|
}
|
|
w.dockerStatus.PullProgress = i
|
|
w.dockerMu.Unlock()
|
|
}
|
|
}()
|
|
|
|
json.NewEncoder(rw).Encode(map[string]interface{}{
|
|
"started": true,
|
|
"status": status,
|
|
})
|
|
}
|
|
|
|
func getDockerNotStartedReason(status DockerStatus) string {
|
|
if !status.Installed {
|
|
return "not_installed"
|
|
}
|
|
if !status.Running {
|
|
return "not_running"
|
|
}
|
|
if status.ImageBuilt {
|
|
return "already_built"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// InstallRequest represents a request to install a dependency
|
|
type InstallRequest struct {
|
|
Command string `json:"command"`
|
|
}
|
|
|
|
// InstallResponse represents the result of an install attempt
|
|
type InstallResponse struct {
|
|
Success bool `json:"success"`
|
|
Output string `json:"output"`
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// allowedCommands is a whitelist of commands that can be executed for dependency installation.
|
|
// This prevents arbitrary command execution even though commands originate from backend detection.
|
|
var allowedCommands = map[string]bool{
|
|
// Package managers (may be called directly or via sudo)
|
|
"apt": true,
|
|
"apt-get": true,
|
|
"apk": true, // Alpine Linux
|
|
"dnf": true,
|
|
"yum": true,
|
|
"pacman": true,
|
|
"zypper": true,
|
|
"emerge": true,
|
|
"eopkg": true,
|
|
"nix-env": true,
|
|
"brew": true,
|
|
"port": true, // MacPorts
|
|
"winget": true,
|
|
"choco": true,
|
|
"scoop": true,
|
|
"snap": true,
|
|
"flatpak": true,
|
|
"xcode-select": true, // macOS Xcode CLI tools
|
|
// Privilege escalation (validated separately)
|
|
"sudo": true,
|
|
"pkexec": true,
|
|
"doas": true,
|
|
}
|
|
|
|
// allowedSudoCommands are commands allowed to be run after sudo/pkexec/doas.
|
|
// This is a subset of allowedCommands - privilege escalation wrappers are not allowed here.
|
|
var allowedSudoCommands = map[string]bool{
|
|
"apt": true,
|
|
"apt-get": true,
|
|
"apk": true,
|
|
"dnf": true,
|
|
"yum": true,
|
|
"pacman": true,
|
|
"zypper": true,
|
|
"emerge": true,
|
|
"eopkg": true,
|
|
"nix-env": true,
|
|
"brew": true,
|
|
"port": true,
|
|
"snap": true,
|
|
"flatpak": true,
|
|
"xcode-select": true,
|
|
}
|
|
|
|
// safeCommandLookup maps user input command names to safe executable names.
|
|
// This allows CodeQL to trace that executed commands come from a static whitelist.
|
|
var safeCommandLookup = map[string]string{
|
|
"apt": "apt",
|
|
"apt-get": "apt-get",
|
|
"apk": "apk",
|
|
"dnf": "dnf",
|
|
"yum": "yum",
|
|
"pacman": "pacman",
|
|
"zypper": "zypper",
|
|
"emerge": "emerge",
|
|
"eopkg": "eopkg",
|
|
"nix-env": "nix-env",
|
|
"brew": "brew",
|
|
"port": "port",
|
|
"winget": "winget",
|
|
"choco": "choco",
|
|
"scoop": "scoop",
|
|
"snap": "snap",
|
|
"flatpak": "flatpak",
|
|
"xcode-select": "xcode-select",
|
|
"sudo": "sudo",
|
|
"pkexec": "pkexec",
|
|
"doas": "doas",
|
|
}
|
|
|
|
// getSafeCommand validates and returns a safe command from the whitelist.
|
|
// Returns the safe command name and true if valid, or empty string and false if not.
|
|
// For sudo/pkexec/doas, also returns the safe elevated command.
|
|
func getSafeCommand(parts []string) (safeCmd string, safeElevatedCmd string, args []string, ok bool) {
|
|
if len(parts) == 0 {
|
|
return "", "", nil, false
|
|
}
|
|
|
|
cmd := parts[0]
|
|
safeCmd, ok = safeCommandLookup[cmd]
|
|
if !ok || !allowedCommands[cmd] {
|
|
return "", "", nil, false
|
|
}
|
|
|
|
// If it's a privilege escalation command, validate the actual command
|
|
if cmd == "sudo" || cmd == "pkexec" || cmd == "doas" {
|
|
if len(parts) < 2 {
|
|
return "", "", nil, false
|
|
}
|
|
// Reject any flags before the command to prevent bypass attacks
|
|
// like "sudo -u apt bash" where -u takes "apt" as its argument
|
|
actualCmd := parts[1]
|
|
if strings.HasPrefix(actualCmd, "-") {
|
|
return "", "", nil, false
|
|
}
|
|
safeElevatedCmd, ok = safeCommandLookup[actualCmd]
|
|
if !ok || !allowedSudoCommands[actualCmd] {
|
|
return "", "", nil, false
|
|
}
|
|
// Return: sudo/pkexec/doas, the elevated command, remaining args
|
|
return safeCmd, safeElevatedCmd, parts[2:], true
|
|
}
|
|
|
|
// Return: command, empty elevated cmd, remaining args
|
|
return safeCmd, "", parts[1:], true
|
|
}
|
|
|
|
// isCommandAllowed checks if a command is in the whitelist.
|
|
// For sudo/pkexec/doas, it validates the actual command being elevated.
|
|
// To avoid bypass attacks via flag parsing (e.g., "sudo -u apt bash"),
|
|
// we reject any sudo invocation where the second argument starts with "-".
|
|
func isCommandAllowed(parts []string) bool {
|
|
_, _, _, ok := getSafeCommand(parts)
|
|
return ok
|
|
}
|
|
|
|
// handleInstallDependency executes dependency installation commands.
|
|
//
|
|
// Security note: This endpoint executes commands that originate from the backend's
|
|
// package manager detection (see packagemanager.InstallCommand). The commands are
|
|
// validated against a whitelist of allowed package managers before execution.
|
|
func (w *Wizard) handleInstallDependency(rw http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req InstallRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(rw, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
|
|
// Execute the install command
|
|
// Split the command into parts
|
|
parts := strings.Fields(req.Command)
|
|
if len(parts) == 0 {
|
|
json.NewEncoder(rw).Encode(InstallResponse{
|
|
Success: false,
|
|
Error: "Empty command",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Get safe command from whitelist - this ensures the executable comes from
|
|
// a static whitelist, not from user input
|
|
safeCmd, safeElevatedCmd, remainingArgs, ok := getSafeCommand(parts)
|
|
if !ok {
|
|
json.NewEncoder(rw).Encode(InstallResponse{
|
|
Success: false,
|
|
Error: "Command not allowed: only package manager commands are permitted",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Build command arguments using safe values from the whitelist
|
|
var cmdArgs []string
|
|
if safeElevatedCmd != "" {
|
|
// For sudo/pkexec/doas: use safe elevated command from whitelist
|
|
cmdArgs = append([]string{safeElevatedCmd}, remainingArgs...)
|
|
} else {
|
|
cmdArgs = remainingArgs
|
|
}
|
|
|
|
cmd := exec.Command(safeCmd, cmdArgs...) // #nosec G204 -- safeCmd comes from safeCommandLookup whitelist
|
|
output, err := cmd.CombinedOutput()
|
|
|
|
if err != nil {
|
|
json.NewEncoder(rw).Encode(InstallResponse{
|
|
Success: false,
|
|
Output: string(output),
|
|
Error: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
json.NewEncoder(rw).Encode(InstallResponse{
|
|
Success: true,
|
|
Output: string(output),
|
|
})
|
|
}
|
|
|
|
func (w *Wizard) handleDefaults(rw http.ResponseWriter, r *http.Request) {
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
defaults, err := LoadGlobalDefaults()
|
|
if err != nil {
|
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Try to pre-populate author info from git config if empty
|
|
if defaults.Author.Name == "" {
|
|
if name, err := execCommand("git", "config", "--global", "user.name"); err == nil && name != "" {
|
|
defaults.Author.Name = name
|
|
}
|
|
}
|
|
|
|
json.NewEncoder(rw).Encode(defaults)
|
|
|
|
case http.MethodPost:
|
|
var defaults GlobalDefaults
|
|
if err := json.NewDecoder(r.Body).Decode(&defaults); err != nil {
|
|
http.Error(rw, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
if err := SaveGlobalDefaults(defaults); err != nil {
|
|
http.Error(rw, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
path, _ := GetDefaultsPath()
|
|
json.NewEncoder(rw).Encode(map[string]string{"status": "saved", "path": path})
|
|
|
|
default:
|
|
http.Error(rw, "Method not allowed", http.StatusMethodNotAllowed)
|
|
}
|
|
}
|