fix(volume-browser): address security review findings

Critical fixes:
- IDOR: verify volume belongs to project before resolving path
- Upload: override global 1MB body limit for upload endpoint (100MB)

High-priority fixes:
- Symlink escape: use filepath.EvalSymlinks in safePath validation
- Remove host filesystem path from browse API response
- Sanitize Content-Disposition filenames, force application/octet-stream
- Strip directory components from upload filenames
This commit is contained in:
2026-04-01 23:17:35 +03:00
parent aacdd255a9
commit 0491849f0f
4 changed files with 40 additions and 13 deletions
+29 -6
View File
@@ -7,6 +7,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@@ -14,6 +15,16 @@ import (
"github.com/alexei/docker-watcher/internal/volume" "github.com/alexei/docker-watcher/internal/volume"
) )
// sanitizeFilename removes characters unsafe for Content-Disposition headers.
func sanitizeFilename(name string) string {
return strings.Map(func(r rune) rune {
if r == '"' || r == '\\' || r == '\n' || r == '\r' {
return '_'
}
return r
}, name)
}
const maxUploadSize = 100 * 1024 * 1024 // 100MB const maxUploadSize = 100 * 1024 * 1024 // 100MB
// resolveVolumeRoot looks up a volume and resolves its host path. // resolveVolumeRoot looks up a volume and resolves its host path.
@@ -43,6 +54,12 @@ func (s *Server) resolveVolumeRoot(w http.ResponseWriter, r *http.Request) (stri
return "", false return "", false
} }
// Verify volume belongs to this project.
if vol.ProjectID != projectID {
respondNotFound(w, "volume")
return "", false
}
if vol.Scope == "ephemeral" { if vol.Scope == "ephemeral" {
respondError(w, http.StatusBadRequest, "ephemeral volumes have no host path to browse") respondError(w, http.StatusBadRequest, "ephemeral volumes have no host path to browse")
return "", false return "", false
@@ -89,7 +106,6 @@ func (s *Server) browseVolume(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{ respondJSON(w, http.StatusOK, map[string]any{
"path": relPath, "path": relPath,
"root": rootPath,
"entries": entries, "entries": entries,
}) })
} }
@@ -126,16 +142,19 @@ func (s *Server) downloadVolume(w http.ResponseWriter, r *http.Request) {
} }
defer f.Close() defer f.Close()
// Serve single file. // Serve single file with forced download.
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filepath.Base(relPath))) safeName := sanitizeFilename(filepath.Base(relPath))
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, safeName))
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size())) w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
io.Copy(w, f) io.Copy(w, f)
} }
func (s *Server) serveZip(w http.ResponseWriter, rootPath, relPath, name string) { func (s *Server) serveZip(w http.ResponseWriter, rootPath, relPath, name string) {
safeName := sanitizeFilename(name)
w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, name)) w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s.zip"`, safeName))
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
if err := volume.WriteZip(rootPath, relPath, w); err != nil { if err := volume.WriteZip(rootPath, relPath, w); err != nil {
slog.Error("failed to write zip", "root", rootPath, "path", relPath, "error", err) slog.Error("failed to write zip", "root", rootPath, "path", relPath, "error", err)
@@ -143,8 +162,11 @@ func (s *Server) serveZip(w http.ResponseWriter, rootPath, relPath, name string)
} }
// uploadToVolume handles POST /api/projects/{id}/volumes/{volId}/upload?path=&stage=&tag= // uploadToVolume handles POST /api/projects/{id}/volumes/{volId}/upload?path=&stage=&tag=
// Accepts multipart form uploads. // Accepts multipart form uploads. Overrides the global body limit for large files.
func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) { func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) {
// Override the global 1MB body limit for uploads.
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
rootPath, ok := s.resolveVolumeRoot(w, r) rootPath, ok := s.resolveVolumeRoot(w, r)
if !ok { if !ok {
return return
@@ -166,7 +188,8 @@ func (s *Server) uploadToVolume(w http.ResponseWriter, r *http.Request) {
continue continue
} }
targetRel := filepath.Join(relPath, fh.Filename) // Strip directory components from filename to prevent directory creation attacks.
targetRel := filepath.Join(relPath, filepath.Base(fh.Filename))
if err := volume.SaveFile(rootPath, targetRel, f); err != nil { if err := volume.SaveFile(rootPath, targetRel, f); err != nil {
f.Close() f.Close()
slog.Error("failed to save upload", "filename", fh.Filename, "error", err) slog.Error("failed to save upload", "filename", fh.Filename, "error", err)
+11 -1
View File
@@ -168,6 +168,7 @@ func SaveFile(rootPath, relativePath string, r io.Reader) error {
} }
// safePath resolves a relative path within rootPath and validates it doesn't escape. // safePath resolves a relative path within rootPath and validates it doesn't escape.
// Resolves symlinks to prevent symlink-based traversal attacks.
func safePath(rootPath, relativePath string) (string, error) { func safePath(rootPath, relativePath string) (string, error) {
if relativePath == "" { if relativePath == "" {
return rootPath, nil return rootPath, nil
@@ -181,15 +182,24 @@ func safePath(rootPath, relativePath string) (string, error) {
absPath := filepath.Join(rootPath, cleaned) absPath := filepath.Join(rootPath, cleaned)
// Double-check the resolved path is within the root. // Resolve the root path (follow symlinks in the root itself).
absRoot, err := filepath.Abs(rootPath) absRoot, err := filepath.Abs(rootPath)
if err != nil { if err != nil {
return "", fmt.Errorf("resolve root: %w", err) return "", fmt.Errorf("resolve root: %w", err)
} }
if realRoot, err := filepath.EvalSymlinks(absRoot); err == nil {
absRoot = realRoot
}
// Resolve the target path including symlinks.
absResolved, err := filepath.Abs(absPath) absResolved, err := filepath.Abs(absPath)
if err != nil { if err != nil {
return "", fmt.Errorf("resolve path: %w", err) return "", fmt.Errorf("resolve path: %w", err)
} }
if realResolved, err := filepath.EvalSymlinks(absResolved); err == nil {
absResolved = realResolved
}
if !strings.HasPrefix(absResolved, absRoot) { if !strings.HasPrefix(absResolved, absRoot) {
return "", fmt.Errorf("path traversal not allowed") return "", fmt.Errorf("path traversal not allowed")
} }
-1
View File
@@ -196,7 +196,6 @@ export interface FileEntry {
/** Response from the volume browse endpoint. */ /** Response from the volume browse endpoint. */
export interface BrowseResult { export interface BrowseResult {
path: string; path: string;
root: string;
entries: FileEntry[]; entries: FileEntry[];
} }
@@ -12,7 +12,6 @@
let entries = $state<FileEntry[]>([]); let entries = $state<FileEntry[]>([]);
let currentPath = $state(''); let currentPath = $state('');
let rootPath = $state('');
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
let uploading = $state(false); let uploading = $state(false);
@@ -62,7 +61,6 @@
const result = await api.browseVolume(projectId, volId, { path, stage, tag }); const result = await api.browseVolume(projectId, volId, { path, stage, tag });
entries = result.entries; entries = result.entries;
currentPath = result.path || ''; currentPath = result.path || '';
rootPath = result.root;
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : $t('volumeBrowser.loadFailed'); error = e instanceof Error ? e.message : $t('volumeBrowser.loadFailed');
} finally { } finally {
@@ -153,9 +151,6 @@
</label> </label>
</div> </div>
</div> </div>
{#if rootPath}
<p class="mt-1 text-xs text-[var(--text-tertiary)] font-mono">{rootPath}</p>
{/if}
</div> </div>
<!-- Breadcrumbs --> <!-- Breadcrumbs -->