feat(volume-browser): absolute scope with allowlist security
- Add 'absolute' volume scope for direct host paths (NFS, external mounts) - Allowlist in settings: allowed_volume_paths (JSON array of prefixes) - Validation: absolute source must be under an allowed prefix - Empty allowlist = absolute scope disabled entirely - Settings API exposes/validates allowed_volume_paths - Frontend type updated with absolute scope
This commit is contained in:
+59
-5
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user