Skip to content

Commit 349e1ff

Browse files
author
Datanoise
committed
feat(security): Implement robust path validation for AutoDJ filesystem access
1 parent df8ebe5 commit 349e1ff

4 files changed

Lines changed: 604 additions & 6 deletions

File tree

relay/streamer.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,12 +108,29 @@ func (s *Streamer) ToggleLoop() {
108108
s.Loop = !s.Loop
109109
}
110110

111+
func (s *Streamer) ToggleInjectMetadata() {
112+
s.mu.Lock()
113+
defer s.mu.Unlock()
114+
s.InjectMetadata = !s.InjectMetadata
115+
}
116+
111117
func (s *Streamer) Stop() {
112118
s.mu.Lock()
113119
defer s.mu.Unlock()
114120
s.State = StateStopped
115-
if s.cancel != nil {
116-
s.cancel()
121+
if s.fileCancel != nil {
122+
s.fileCancel()
123+
}
124+
}
125+
126+
func (s *Streamer) Restart() {
127+
s.mu.Lock()
128+
defer s.mu.Unlock()
129+
if s.CurrentPos > 0 {
130+
s.CurrentPos--
131+
}
132+
if s.fileCancel != nil {
133+
s.fileCancel()
117134
}
118135
}
119136

server/server.go

Lines changed: 118 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -301,20 +301,24 @@ func (s *Server) setupRoutes() *http.ServeMux {
301301
mux.HandleFunc("/admin/add-webhook", s.handleAddWebhook)
302302
mux.HandleFunc("/admin/delete-webhook", s.handleDeleteWebhook)
303303
mux.HandleFunc("/admin/player/toggle", s.handlePlayerToggle)
304+
mux.HandleFunc("/admin/player/restart", s.handlePlayerRestart)
304305
mux.HandleFunc("/admin/player/scan", s.handlePlayerScan)
305306
mux.HandleFunc("/admin/player/clear-playlist", s.handlePlayerClearPlaylist)
306307
mux.HandleFunc("/admin/player/save-playlist", s.handlePlayerSavePlaylist)
308+
mux.HandleFunc("/admin/player/playlist-info", s.handlePlayerPlaylistInfo)
307309
mux.HandleFunc("/admin/player/load-playlist", s.handlePlayerLoadPlaylist)
308310
mux.HandleFunc("/admin/player/reorder", s.handlePlayerReorder)
309311
mux.HandleFunc("/admin/player/queue", s.handlePlayerQueue)
310312
mux.HandleFunc("/admin/player/shuffle", s.handlePlayerShuffle)
311313
mux.HandleFunc("/admin/player/loop", s.handlePlayerLoop)
314+
mux.HandleFunc("/admin/player/metadata", s.handlePlayerMetadata)
312315
mux.HandleFunc("/admin/player/next", s.handlePlayerNext)
313316
mux.HandleFunc("/admin/player/files", s.handlePlayerFiles)
314317
mux.HandleFunc("/admin/player/playlist-action", s.handlePlayerPlaylistAction)
315318
mux.HandleFunc("/admin/autodj/add", s.handleAddAutoDJ)
316319
mux.HandleFunc("/admin/autodj/delete", s.handleDeleteAutoDJ)
317320
mux.HandleFunc("/admin/autodj/toggle", s.handleToggleAutoDJ)
321+
mux.HandleFunc("/admin/autodj/studio", s.handleAutoDJStudio)
318322
mux.HandleFunc("/admin/autodj/update", s.handleUpdateAutoDJ)
319323
mux.HandleFunc("/admin/add-relay", s.handleAddRelay)
320324
mux.HandleFunc("/admin/toggle-relay", s.handleToggleRelay)
@@ -590,8 +594,10 @@ func (s *Server) safeJoin(base, rel string) (string, error) {
590594
return "", err
591595
}
592596

597+
logrus.Debugf("safeJoin: base=%s rel=%s joined=%s relPath=%s", absBase, rel, joined, relPath)
598+
593599
if strings.HasPrefix(relPath, "..") || strings.HasPrefix(relPath, "/") {
594-
return "", fmt.Errorf("security: path traversal attempt detected")
600+
return "", fmt.Errorf("security: path traversal attempt detected: %s", relPath)
595601
}
596602

597603
return joined, nil
@@ -2681,13 +2687,14 @@ func (s *Server) handlePlayerQueue(w http.ResponseWriter, r *http.Request) {
26812687
musicDir := streamer.GetMusicDir()
26822688
rel, err := filepath.Rel(musicDir, path)
26832689
if err != nil {
2690+
logrus.WithError(err).Warnf("Security: Blocked queue addition: filepath.Rel failed for base=%s path=%s", musicDir, path)
26842691
http.Error(w, "Forbidden", http.StatusForbidden)
26852692
return
26862693
}
26872694

26882695
fullPath, err := s.safeJoin(musicDir, rel)
26892696
if err != nil {
2690-
logrus.WithError(err).Warnf("Security: Blocked queue addition of %s for mount %s", path, mount)
2697+
logrus.WithError(err).Warnf("Security: Blocked queue addition of %s (relative to %s) for mount %s. Full path attempted: %s", rel, musicDir, mount, fullPath)
26912698
http.Error(w, "Forbidden", http.StatusForbidden)
26922699
return
26932700
}
@@ -2774,6 +2781,110 @@ func (s *Server) handlePlayerLoop(w http.ResponseWriter, r *http.Request) {
27742781
w.WriteHeader(http.StatusOK)
27752782
}
27762783

2784+
func (s *Server) handleAutoDJStudio(w http.ResponseWriter, r *http.Request) {
2785+
user, ok := s.checkAuth(r)
2786+
if !ok {
2787+
return
2788+
}
2789+
2790+
mount := r.URL.Query().Get("mount")
2791+
logrus.Infof("Studio: Requested mount: %s", mount)
2792+
2793+
streamer := s.StreamerM.GetStreamer(mount)
2794+
if streamer == nil {
2795+
logrus.Warnf("Studio: Streamer not found for mount: %s", mount)
2796+
http.Redirect(w, r, "/admin#tab-streamer", http.StatusSeeOther)
2797+
return
2798+
}
2799+
2800+
s.sessionsMu.RLock()
2801+
sess := s.sessions[user.Username]
2802+
csrf := ""
2803+
if sess != nil {
2804+
csrf = sess.CSRFToken
2805+
}
2806+
s.sessionsMu.RUnlock()
2807+
2808+
data := map[string]interface{}{
2809+
"Streamer": streamer.GetStats(),
2810+
"CSRFToken": csrf,
2811+
"Config": s.Config,
2812+
"User": user,
2813+
}
2814+
2815+
w.Header().Set("Content-Type", "text/html")
2816+
if err := s.tmpl.ExecuteTemplate(w, "autodj_studio.html", data); err != nil {
2817+
logrus.WithError(err).Error("Studio template error")
2818+
}
2819+
}
2820+
2821+
func (s *Server) handlePlayerPlaylistInfo(w http.ResponseWriter, r *http.Request) {
2822+
if _, ok := s.checkAuth(r); !ok {
2823+
return
2824+
}
2825+
2826+
mount := r.URL.Query().Get("mount")
2827+
streamer := s.StreamerM.GetStreamer(mount)
2828+
if streamer == nil {
2829+
http.Error(w, "Streamer not found", http.StatusNotFound)
2830+
return
2831+
}
2832+
2833+
w.Header().Set("Content-Type", "application/json")
2834+
json.NewEncoder(w).Encode(streamer.GetPlaylistInfo())
2835+
}
2836+
2837+
func (s *Server) handlePlayerMetadata(w http.ResponseWriter, r *http.Request) {
2838+
if !s.isCSRFSafe(r) {
2839+
http.Error(w, "Forbidden", http.StatusForbidden)
2840+
return
2841+
}
2842+
if _, ok := s.checkAuth(r); !ok {
2843+
return
2844+
}
2845+
2846+
mount := r.FormValue("mount")
2847+
streamer := s.StreamerM.GetStreamer(mount)
2848+
if streamer == nil {
2849+
http.Error(w, "Streamer not found", http.StatusNotFound)
2850+
return
2851+
}
2852+
2853+
streamer.ToggleInjectMetadata()
2854+
metaState := streamer.GetStats().InjectMetadata
2855+
2856+
// Persist
2857+
for _, adj := range s.Config.AutoDJs {
2858+
if adj.Mount == mount {
2859+
adj.InjectMetadata = metaState
2860+
s.Config.SaveConfig()
2861+
break
2862+
}
2863+
}
2864+
2865+
w.WriteHeader(http.StatusOK)
2866+
}
2867+
2868+
func (s *Server) handlePlayerRestart(w http.ResponseWriter, r *http.Request) {
2869+
if !s.isCSRFSafe(r) {
2870+
http.Error(w, "Forbidden", http.StatusForbidden)
2871+
return
2872+
}
2873+
if _, ok := s.checkAuth(r); !ok {
2874+
return
2875+
}
2876+
2877+
mount := r.FormValue("mount")
2878+
streamer := s.StreamerM.GetStreamer(mount)
2879+
if streamer == nil {
2880+
http.Error(w, "Streamer not found", http.StatusNotFound)
2881+
return
2882+
}
2883+
2884+
streamer.Restart()
2885+
w.WriteHeader(http.StatusOK)
2886+
}
2887+
27772888
func (s *Server) handlePlayerNext(w http.ResponseWriter, r *http.Request) {
27782889
if !s.isCSRFSafe(r) {
27792890
http.Error(w, "Forbidden", http.StatusForbidden)
@@ -2809,7 +2920,9 @@ func (s *Server) handlePlayerFiles(w http.ResponseWriter, r *http.Request) {
28092920
}
28102921

28112922
musicDir := streamer.GetMusicDir()
2812-
fullPath, err := s.safeJoin(musicDir, subDir)
2923+
2924+
// Ensure fullPath is a valid path within musicDir
2925+
fullPath, err := s.validatePathInMusicDir(musicDir, filepath.Join(musicDir, subDir))
28132926
if err != nil {
28142927
logrus.WithError(err).Warnf("Security: Blocked file browser access to %s for mount %s", subDir, mount)
28152928
http.Error(w, "Forbidden", http.StatusForbidden)
@@ -2913,11 +3026,12 @@ func (s *Server) handlePlayerPlaylistAction(w http.ResponseWriter, r *http.Reque
29133026

29143027
fullPath, err := s.safeJoin(musicDir, rel)
29153028
if err != nil {
2916-
logrus.WithError(err).Warnf("Security: Blocked playlist addition of %s for mount %s", relPath, mount)
3029+
logrus.WithError(err).Warnf("Security: Blocked playlist addition of %s (relative to %s) for mount %s. Full path attempted: %s", rel, musicDir, mount, fullPath)
29173030
http.Error(w, "Forbidden", http.StatusForbidden)
29183031
return
29193032
}
29203033

3034+
29213035
logrus.Infof("AutoDJ %s: Attempting to add %s", mount, fullPath)
29223036

29233037
info, err := os.Stat(fullPath)

server/templates/admin.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,9 @@ <h3 style="margin: 0; display: flex; align-items: center; gap: 0.5rem;">
427427
</div> </div>
428428
<div style="display: flex; gap: 0.5rem; align-items: center;">
429429
{{$stats := .GetStats}}
430+
<a href="/admin/autodj/studio?mount={{urlquery .OutputMount}}" class="btn btn-primary btn-sm" style="padding: 0.3rem 0.8rem; font-weight: 800; letter-spacing: 0.5px;">
431+
<i data-lucide="layout-template" style="width: 14px; height: 14px; margin-right: 4px;"></i> STUDIO
432+
</a>
430433
<button type="button" class="btn btn-outline btn-sm" title="Edit AutoDJ" onclick='showEditAutoDJ({ "Name": "{{$stats.Name}}", "Mount": "{{$stats.Mount}}", "MusicDir": "{{$stats.MusicDir}}", "Format": "{{.Format}}", "Bitrate": {{.Bitrate}}, "Loop": {{$stats.Loop}}, "InjectMetadata": {{$stats.InjectMetadata}}, "Visible": {{$stats.Visible}}, "MPDEnabled": {{if $stats.MPDPort}}true{{else}}false{{end}}, "MPDPort": "{{$stats.MPDPort}}", "MPDPassword": "{{$stats.MPDPassword}}" })'>
431434
<i data-lucide="edit-3" style="width: 14px; height: 14px;"></i>
432435
</button>

0 commit comments

Comments
 (0)