diff --git a/internal/api/settings.go b/internal/api/settings.go index 276dd45..14dd3b4 100644 --- a/internal/api/settings.go +++ b/internal/api/settings.go @@ -5,11 +5,13 @@ import ( "fmt" "log/slog" "net/http" + "path/filepath" "strings" "github.com/alexei/docker-watcher/internal/crypto" "github.com/alexei/docker-watcher/internal/npm" "github.com/alexei/docker-watcher/internal/store" + "github.com/alexei/docker-watcher/internal/volume" "github.com/alexei/docker-watcher/internal/webhook" ) @@ -24,8 +26,9 @@ type settingsRequest struct { NpmEmail string `json:"npm_email"` NpmPassword string `json:"npm_password"` PollingInterval string `json:"polling_interval"` - SSLCertificateID *int `json:"ssl_certificate_id,omitempty"` - StaleThresholdDays *int `json:"stale_threshold_days,omitempty"` + SSLCertificateID *int `json:"ssl_certificate_id,omitempty"` + StaleThresholdDays *int `json:"stale_threshold_days,omitempty"` + AllowedVolumePaths *string `json:"allowed_volume_paths,omitempty"` } // getSettings handles GET /api/settings. @@ -48,8 +51,9 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) { "has_npm_password": settings.NpmPassword != "", "polling_interval": settings.PollingInterval, "ssl_certificate_id": settings.SSLCertificateID, - "stale_threshold_days": settings.StaleThresholdDays, - "updated_at": settings.UpdatedAt, + "stale_threshold_days": settings.StaleThresholdDays, + "allowed_volume_paths": settings.AllowedVolumePaths, + "updated_at": settings.UpdatedAt, }) } @@ -110,6 +114,23 @@ func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request) { } updated.StaleThresholdDays = *req.StaleThresholdDays } + if req.AllowedVolumePaths != nil { + // Validate it's valid JSON array of strings. + paths, err := volume.ParseAllowedPaths(*req.AllowedVolumePaths) + if err != nil { + respondError(w, http.StatusBadRequest, "allowed_volume_paths must be a JSON array of strings") + return + } + // Validate each path is absolute. + for _, p := range paths { + if !filepath.IsAbs(p) { + respondError(w, http.StatusBadRequest, "each allowed volume path must be absolute") + return + } + } + updated.AllowedVolumePaths = *req.AllowedVolumePaths + _ = paths // validated + } if err := s.store.UpdateSettings(updated); err != nil { respondError(w, http.StatusInternalServerError, "failed to update settings: "+err.Error()) diff --git a/internal/api/volume_browser.go b/internal/api/volume_browser.go index 41dc4ee..65ba5f3 100644 --- a/internal/api/volume_browser.go +++ b/internal/api/volume_browser.go @@ -74,10 +74,11 @@ func (s *Server) resolveVolumeRoot(w http.ResponseWriter, r *http.Request) (stri q := r.URL.Query() params := volume.ResolveParams{ - BasePath: settings.BaseVolumePath, - ProjectName: proj.Name, - StageName: q.Get("stage"), - ImageTag: q.Get("tag"), + BasePath: settings.BaseVolumePath, + ProjectName: proj.Name, + StageName: q.Get("stage"), + ImageTag: q.Get("tag"), + AllowedVolumePaths: settings.AllowedVolumePaths, } rootPath, err := volume.ResolvePath(vol, params) diff --git a/internal/api/volumes.go b/internal/api/volumes.go index a3234dd..3a50bb5 100644 --- a/internal/api/volumes.go +++ b/internal/api/volumes.go @@ -11,6 +11,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/alexei/docker-watcher/internal/store" + "github.com/alexei/docker-watcher/internal/volume" ) // safeNamePattern restricts volume names to alphanumeric, dash, underscore, and dot. @@ -35,12 +36,13 @@ type volumeRequest struct { var validScopes = map[string]bool{ "instance": true, "stage": true, "project": true, "project_named": true, "named": true, "ephemeral": true, + "absolute": true, } -// validateVolumeScope validates the scope and name combination. -func validateVolumeScope(scope, name string) string { +// validateVolumeScope validates the scope, name, and source combination. +func validateVolumeScope(scope, name, source, allowedPathsJSON string) string { if !validScopes[scope] { - return "scope must be one of: instance, stage, project, project_named, named, ephemeral" + return "scope must be one of: instance, stage, project, project_named, named, ephemeral, absolute" } if (scope == "project_named" || scope == "named") && strings.TrimSpace(name) == "" { return "name is required for " + scope + " scope" @@ -48,6 +50,34 @@ func validateVolumeScope(scope, name string) string { if name != "" && !safeNamePattern.MatchString(name) { return "name must start with a letter or digit and contain only letters, digits, dashes, underscores, or dots" } + if scope == "absolute" { + if source == "" { + return "source path is required for absolute scope" + } + if !filepath.IsAbs(source) { + return "absolute scope requires an absolute source path" + } + // Validate against allowlist. + allowed, err := volume.ParseAllowedPaths(allowedPathsJSON) + if err != nil { + return "failed to parse allowed volume paths" + } + if len(allowed) == 0 { + return "absolute volume paths are disabled — configure allowed paths in settings first" + } + matched := false + cleanSource := filepath.Clean(source) + for _, prefix := range allowed { + cleanPrefix := filepath.Clean(prefix) + if strings.HasPrefix(cleanSource, cleanPrefix+string(filepath.Separator)) || cleanSource == cleanPrefix { + matched = true + break + } + } + if !matched { + return "source path is not under any allowed volume path" + } + } return "" } @@ -99,6 +129,12 @@ func (s *Server) listVolumeScopes(w http.ResponseWriter, r *http.Request) { NeedsName: false, PathExample: "(tmpfs — no host path)", }, + { + Scope: "absolute", + Description: "Direct host path. Must be under an allowed path configured in settings. Use for external mounts like NFS or pre-existing directories.", + NeedsName: false, + PathExample: "/mnt/nfs/data (must match allowed paths)", + }, } respondJSON(w, http.StatusOK, scopes) } @@ -179,7 +215,15 @@ func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) { } } - if errMsg := validateVolumeScope(scope, req.Name); errMsg != "" { + // Fetch settings for absolute path allowlist validation. + settings, err := s.store.GetSettings() + if err != nil { + slog.Error("failed to get settings for volume validation", "error", err) + respondError(w, http.StatusInternalServerError, "failed to validate volume") + return + } + + if errMsg := validateVolumeScope(scope, req.Name, req.Source, settings.AllowedVolumePaths); errMsg != "" { respondError(w, http.StatusBadRequest, errMsg) return } @@ -235,7 +279,17 @@ func (s *Server) updateVolume(w http.ResponseWriter, r *http.Request) { updated.Target = req.Target } if req.Scope != "" { - if errMsg := validateVolumeScope(req.Scope, req.Name); errMsg != "" { + settings, err := s.store.GetSettings() + if err != nil { + slog.Error("failed to get settings for volume validation", "error", err) + respondError(w, http.StatusInternalServerError, "failed to validate volume") + return + } + source := updated.Source + if req.Source != "" { + source = req.Source + } + if errMsg := validateVolumeScope(req.Scope, req.Name, source, settings.AllowedVolumePaths); errMsg != "" { respondError(w, http.StatusBadRequest, errMsg) return } diff --git a/internal/store/models.go b/internal/store/models.go index 80b41a7..d98bf3f 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -57,6 +57,7 @@ type Settings struct { BaseVolumePath string `json:"base_volume_path"` SSLCertificateID int `json:"ssl_certificate_id"` StaleThresholdDays int `json:"stale_threshold_days"` + AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths UpdatedAt string `json:"updated_at"` } @@ -120,12 +121,14 @@ const ( VolumeScopeProjectNamed VolumeScope = "project_named" VolumeScopeNamed VolumeScope = "named" VolumeScopeEphemeral VolumeScope = "ephemeral" + VolumeScopeAbsolute VolumeScope = "absolute" ) // ValidVolumeScopes contains all valid scope values for validation. var ValidVolumeScopes = []VolumeScope{ VolumeScopeInstance, VolumeScopeStage, VolumeScopeProject, VolumeScopeProjectNamed, VolumeScopeNamed, VolumeScopeEphemeral, + VolumeScopeAbsolute, } // IsValidVolumeScope returns true if the given string is a valid scope. diff --git a/internal/store/settings.go b/internal/store/settings.go index d9ea761..324df99 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -9,10 +9,14 @@ func (s *Store) GetSettings() (Settings, error) { var st Settings err := s.db.QueryRow( `SELECT domain, server_ip, network, subdomain_pattern, notification_url, - npm_url, npm_email, npm_password, webhook_secret, polling_interval, base_volume_path, ssl_certificate_id, stale_threshold_days, updated_at + npm_url, npm_email, npm_password, webhook_secret, polling_interval, + base_volume_path, ssl_certificate_id, stale_threshold_days, + allowed_volume_paths, updated_at FROM settings WHERE id = 1`, ).Scan(&st.Domain, &st.ServerIP, &st.Network, &st.SubdomainPattern, &st.NotificationURL, - &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, &st.UpdatedAt) + &st.NpmURL, &st.NpmEmail, &st.NpmPassword, &st.WebhookSecret, &st.PollingInterval, + &st.BaseVolumePath, &st.SSLCertificateID, &st.StaleThresholdDays, + &st.AllowedVolumePaths, &st.UpdatedAt) if err != nil { return Settings{}, fmt.Errorf("query settings: %w", err) } @@ -25,10 +29,14 @@ func (s *Store) UpdateSettings(st Settings) error { _, err := s.db.Exec( `UPDATE settings SET domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?, - npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?, updated_at=? + npm_url=?, npm_email=?, npm_password=?, webhook_secret=?, polling_interval=?, + base_volume_path=?, ssl_certificate_id=?, stale_threshold_days=?, + allowed_volume_paths=?, updated_at=? WHERE id = 1`, st.Domain, st.ServerIP, st.Network, st.SubdomainPattern, st.NotificationURL, - st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays, st.UpdatedAt, + st.NpmURL, st.NpmEmail, st.NpmPassword, st.WebhookSecret, st.PollingInterval, + st.BaseVolumePath, st.SSLCertificateID, st.StaleThresholdDays, + st.AllowedVolumePaths, st.UpdatedAt, ) if err != nil { return fmt.Errorf("update settings: %w", err) diff --git a/internal/store/store.go b/internal/store/store.go index 4b9440a..e3e843d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -88,6 +88,8 @@ func (s *Store) runMigrations() error { // Add name column and rename mode→scope for volume scopes redesign (2026-03-31). `ALTER TABLE volumes ADD COLUMN name TEXT NOT NULL DEFAULT ''`, `ALTER TABLE volumes ADD COLUMN scope TEXT NOT NULL DEFAULT ''`, + // Add allowed_volume_paths to settings for absolute volume scope allowlist (2026-04-01). + `ALTER TABLE settings ADD COLUMN allowed_volume_paths TEXT NOT NULL DEFAULT '[]'`, } for _, m := range migrations { diff --git a/internal/volume/resolver.go b/internal/volume/resolver.go index 3bac51a..769eef4 100644 --- a/internal/volume/resolver.go +++ b/internal/volume/resolver.go @@ -1,18 +1,21 @@ package volume import ( + "encoding/json" "fmt" "path/filepath" + "strings" "github.com/alexei/docker-watcher/internal/store" ) // ResolveParams holds the parameters needed to resolve a volume's host path. type ResolveParams struct { - BasePath string - ProjectName string - StageName string // required for instance and stage scopes - ImageTag string // required for instance scope + BasePath string + ProjectName string + StageName string // required for instance and stage scopes + ImageTag string // required for instance scope + AllowedVolumePaths string // JSON array of allowed absolute paths (from settings) } // ResolvePath returns the absolute host path for a volume based on its scope. @@ -32,6 +35,10 @@ func ResolvePath(vol store.Volume, params ResolveParams) (string, error) { return "", fmt.Errorf("ephemeral volumes have no host path") } + if scope == "absolute" { + return resolveAbsolute(vol.Source, params.AllowedVolumePaths) + } + switch scope { case "instance": if params.StageName == "" || params.ImageTag == "" { @@ -53,3 +60,49 @@ func ResolvePath(vol store.Volume, params ResolveParams) (string, error) { return filepath.Join(params.BasePath, params.ProjectName, vol.Source), nil } } + +// resolveAbsolute validates that the source path is under one of the allowed prefixes. +func resolveAbsolute(source, allowedPathsJSON string) (string, error) { + if source == "" { + return "", fmt.Errorf("absolute scope requires a source path") + } + + cleaned := filepath.Clean(source) + if !filepath.IsAbs(cleaned) { + return "", fmt.Errorf("absolute scope requires an absolute source path (starting with /)") + } + + allowed, err := parseAllowedPaths(allowedPathsJSON) + if err != nil { + return "", fmt.Errorf("failed to parse allowed volume paths: %w", err) + } + if len(allowed) == 0 { + return "", fmt.Errorf("absolute volume paths are disabled (no allowed paths configured in settings)") + } + + for _, prefix := range allowed { + prefixClean := filepath.Clean(prefix) + if strings.HasPrefix(cleaned, prefixClean+string(filepath.Separator)) || cleaned == prefixClean { + return cleaned, nil + } + } + + return "", fmt.Errorf("path %q is not under any allowed volume path", source) +} + +// parseAllowedPaths parses a JSON array of path strings. +func parseAllowedPaths(jsonStr string) ([]string, error) { + if jsonStr == "" || jsonStr == "[]" { + return nil, nil + } + var paths []string + if err := json.Unmarshal([]byte(jsonStr), &paths); err != nil { + return nil, err + } + return paths, nil +} + +// ParseAllowedPaths is the exported version for use in API validation. +func ParseAllowedPaths(jsonStr string) ([]string, error) { + return parseAllowedPaths(jsonStr) +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index c8945ef..e9ea7a3 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -107,6 +107,7 @@ export interface Settings { base_volume_path: string; ssl_certificate_id: number; stale_threshold_days: number; + allowed_volume_paths: string; updated_at: string; } @@ -162,7 +163,7 @@ export interface EntityPickerItem { } /** Volume scope determines the sharing level. */ -export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral'; +export type VolumeScope = 'instance' | 'stage' | 'project' | 'project_named' | 'named' | 'ephemeral' | 'absolute'; /** Volume mount configuration for a project. */ export interface Volume {