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:
2026-04-01 23:31:27 +03:00
parent 0491849f0f
commit 582e7e39e3
8 changed files with 165 additions and 22 deletions
+25 -4
View File
@@ -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())