fix: auth bypass removed, sanitize names, json errors, viewer port toggle

This commit is contained in:
Claus Lohmar 2026-08-06 18:43:14 +01:00
parent fbb40f4e26
commit 2032402597
4 changed files with 34 additions and 20 deletions

25
api.go
View file

@ -65,7 +65,7 @@ func (s *Server) handleCameraByID(w http.ResponseWriter, r *http.Request) {
case http.MethodPut: case http.MethodPut:
var updated CameraConfig var updated CameraConfig
if err := json.NewDecoder(r.Body).Decode(&updated); err != nil { if err := json.NewDecoder(r.Body).Decode(&updated); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON: " + err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON"})
return return
} }
updated.ID = camID updated.ID = camID
@ -97,7 +97,7 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
defer s.mu.Unlock() defer s.mu.Unlock()
var newCfg Config var newCfg Config
if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil { if err := json.NewDecoder(r.Body).Decode(&newCfg); err != nil {
jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON: " + err.Error()}) jsonResponse(w, http.StatusBadRequest, APIResponse{Error: "invalid JSON"})
return return
} }
@ -108,8 +108,16 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
newCfg.Cameras[i].Password = old.Password newCfg.Cameras[i].Password = old.Password
} }
} }
// Sanitize name: replace spaces with underscores for CLI-friendly paths. // Sanitize name: replace unsafe chars with underscores.
newCfg.Cameras[i].Name = strings.ReplaceAll(newCfg.Cameras[i].Name, " ", "_") n := newCfg.Cameras[i].Name
n = strings.Map(func(r rune) rune {
if r == '/' || r == '\\' || r == 0 || r == '.' || r == ':' {
return '_'
}
return r
}, n)
n = strings.ReplaceAll(n, " ", "_")
newCfg.Cameras[i].Name = n
} }
// Hash new master password if provided (not masked, not empty, not already hashed). // Hash new master password if provided (not masked, not empty, not already hashed).
@ -155,8 +163,13 @@ func (s *Server) handleConfigReload(w http.ResponseWriter, r *http.Request) {
// GET /stream/{cam_id}?type=sub (default: sub stream via go2rtc) // GET /stream/{cam_id}?type=sub (default: sub stream via go2rtc)
func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) { func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
camID := strings.TrimPrefix(r.URL.Path, "/stream/") camID := strings.TrimPrefix(r.URL.Path, "/stream/")
if camID == "" { if camID == "" || camID == ".." || strings.ContainsAny(camID, "/\\?&#") {
http.Error(w, "camera ID required", http.StatusBadRequest) http.Error(w, "invalid camera ID", http.StatusBadRequest)
return
}
// Validate it's a known camera.
if _, idx := s.findCamera(camID); idx < 0 {
http.Error(w, "camera not found", http.StatusNotFound)
return return
} }

View file

@ -181,15 +181,12 @@ func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) {
// authRequired redirects to /login if no valid session. // authRequired redirects to /login if no valid session.
func (s *Server) authRequired(next http.Handler) http.Handler { func (s *Server) authRequired(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Allow login page and API, static assets, and recordings. // Allow login page and API, static assets. Everything else requires auth.
path := r.URL.Path path := r.URL.Path
if path == "/login" || path == "/login.html" || if path == "/login" || path == "/login.html" ||
strings.HasPrefix(path, "/api/login") || strings.HasPrefix(path, "/api/login") ||
strings.HasPrefix(path, "/api/logout") || strings.HasPrefix(path, "/api/logout") ||
strings.HasPrefix(path, "/api/session") || strings.HasPrefix(path, "/api/session") {
strings.HasPrefix(path, "/recordings/") ||
strings.HasPrefix(path, "/go2rtc/") ||
strings.HasPrefix(path, "/stream/") {
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
return return
} }

View file

@ -101,13 +101,15 @@ func main() {
errCh <- app.server.ListenAndServe() errCh <- app.server.ListenAndServe()
}() }()
// Start viewer server (no auth, live wall only) — after main server is created. // Start viewer server only if enabled.
if cfg.Server.ViewerPort != "" { if cfg.Auth.Viewer.Enabled {
go func() { go func() {
if err := app.server.StartViewerServer(); err != nil { if err := app.server.StartViewerServer(); err != nil {
log.Printf("Viewer server: %v", err) log.Printf("Viewer server: %v", err)
} }
}() }()
} else {
log.Println("Viewer server: disabled in config")
} }
select { select {

View file

@ -262,23 +262,25 @@ async function renderCameraCards() {
} }
container.innerHTML = ` container.innerHTML = `
<div class="camera-card" id="auth-card" style="grid-column:1/-1"> <div class="camera-card" id="auth-card" style="grid-column:1/-1">
<h3>🔐 Authentication</h3> <h3>🔐 Access Setup</h3>
<div style="display:flex;gap:16px;flex-wrap:wrap;margin-top:8px"> <div style="display:flex;gap:16px;flex-wrap:wrap;margin-top:8px">
<div style="flex:1;min-width:200px"> <div style="flex:1;min-width:200px">
<h4 style="font-size:13px;color:var(--accent);margin-bottom:6px">Master (full access)</h4> <h4 style="font-size:13px;color:var(--accent);margin-bottom:6px">Port 8080 Admin</h4>
<label>Username <input type="text" value="${escAttr(config?.auth?.master?.username || '')}" data-auth="master-user"></label> <label>Username <input type="text" value="${escAttr(config?.auth?.master?.username || '')}" data-auth="master-user"></label>
<label>Password <input type="password" placeholder="new password" data-auth="master-pass"></label> <label>Password <input type="password" placeholder="new password" data-auth="master-pass"></label>
<div class="row" style="margin-top:8px">
<label>Require login on port 8080 <input type="checkbox" ${config?.auth?.enabled ? 'checked' : ''} data-auth="auth-enabled" style="width:auto"></label>
</div>
</div> </div>
<div style="flex:1;min-width:200px"> <div style="flex:1;min-width:200px">
<h4 style="font-size:13px;color:var(--accent);margin-bottom:6px">Viewer (live wall only, port 8090)</h4> <h4 style="font-size:13px;color:var(--accent);margin-bottom:6px">Port 8090 Local View</h4>
<label>Username <input type="text" value="${escAttr(config?.auth?.viewer?.username || '')}" data-auth="viewer-user"></label> <label>Username <input type="text" value="${escAttr(config?.auth?.viewer?.username || '')}" data-auth="viewer-user"></label>
<label>Password <input type="password" placeholder="new password" data-auth="viewer-pass"></label> <label>Password <input type="password" placeholder="new password" data-auth="viewer-pass"></label>
<div class="row"><label>Enable viewer <input type="checkbox" ${config?.auth?.viewer?.enabled ? 'checked' : ''} data-auth="viewer-enabled" style="width:auto"></label></div> <div class="row" style="margin-top:8px">
<label>Enable local view access <input type="checkbox" ${config?.auth?.viewer?.enabled ? 'checked' : ''} data-auth="viewer-enabled" style="width:auto"></label>
</div>
</div> </div>
</div> </div>
<div class="row" style="margin-top:8px">
<label>Require login on port 8080 <input type="checkbox" ${config?.auth?.enabled ? 'checked' : ''} data-auth="auth-enabled" style="width:auto"></label>
</div>
</div> </div>
<div style="grid-column:1/-1;display:flex;gap:8px;margin-bottom:4px"> <div style="grid-column:1/-1;display:flex;gap:8px;margin-bottom:4px">
<button class="btn-primary" onclick="addCamera()">+ Add Camera</button> <button class="btn-primary" onclick="addCamera()">+ Add Camera</button>