Files
OwnCord/Server/api/diagnostics_handler.go
T
jevb 4c4526e539 fix: security hardening — 45 issues from full-project Copilot audit
Critical (6):
- C1: SQL injection in VACUUM INTO backup path — strict character allowlist
- C2: Unlimited binary download in updater — 500MB LimitReader
- C3: JSON injection in SSE log stream — json.Marshal instead of concat
- C4: CSS injection via custom themes — reject () and {} in values
- C5: Silent DM message loss — error response on participant lookup failure
- C6: LiveKit URL credential leak — strip creds from diagnostics endpoint

High (11):
- H1: DB errors no longer trigger login rate-limit lockout
- H2: Permission fetch failure returns 500, not empty channel list
- H3: TOCTOU race on duplicate WS — atomic check-and-register in hub
- H5: LiveKit webhook verifies voice channel match (already implemented)
- H7: Server host address validated before storage (hostname regex)
- H8: WS message deduplication on reconnect replay (1000-entry Set)
- H9: Admin setup endpoint rate limited (5/min/IP)
- H10: Backup responses return filename only, not full path
- H11: Update binary recovery failure now alerts admin

Medium (17):
- M1: MIME type from magic bytes, not client header
- M3: Nil guard on DM broadcast recipient
- M5: LiveKit process run-done channel race fixed
- M6: Backup restore calls fsync before close
- M7: Partial download file cleaned up on error
- M8: Admin CSP uses nonce instead of unsafe-inline
- M9: Client rate limiter enforced for presence_update
- M10: Voice joinedAt not reset on double-join
- M11: Unread count skips increment during reconnect replay
- M13: Category type uses exact match, not substring
- M14: Storage LimitReader off-by-one fixed
- M15: GitHub token only sent to GitHub hosts
- M16: Content-parser ReDoS regex replaced with split approach
- M17: Audio device switch error handling added

Low (11):
- L1: CORS uses configured origins instead of wildcard
- L2: HSTS header added when TLS enabled
- L3: Consistent JSON error responses across all endpoints
- L4: File modtime from stat, not time.Now()
- L5: Malformed invite JSON returns 400
- L6: TouchSession failure logged at warn
- L8: MessageInput timers cleared on destroy
- L9: Log persistence flush errors caught
- L10: Credential save failure surfaced to user
- L11: Case-insensitive asset name matching in updater

Found by GitHub Copilot full-project review (claude-sonnet-4.6 + claude-haiku-4.5).
2026-03-29 12:35:04 +02:00

99 lines
2.4 KiB
Go

package api
import (
"net/http"
"net/url"
"runtime"
"time"
"github.com/owncord/server/config"
"github.com/owncord/server/ws"
)
// diagnosticsResponse is returned by GET /api/v1/diagnostics/connectivity.
type diagnosticsResponse struct {
Server serverDiag `json:"server"`
Voice voiceDiag `json:"voice"`
Client clientDiag `json:"client"`
}
type serverDiag struct {
Version string `json:"version"`
Uptime int64 `json:"uptime_s"`
GoVersion string `json:"go_version"`
OnlineUsers int `json:"online_users"`
}
type voiceDiag struct {
Enabled bool `json:"enabled"`
LiveKitURL string `json:"livekit_url,omitempty"`
LiveKitHealth bool `json:"livekit_health"`
NodeIP string `json:"node_ip,omitempty"`
ProxyPath string `json:"proxy_path"`
}
type clientDiag struct {
RemoteAddr string `json:"remote_addr"`
IsPrivateNet bool `json:"is_private_network"`
}
func handleDiagnosticsConnectivity(
cfg *config.Config,
ver string,
hub *ws.Hub,
) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
clientAddr := clientIP(r)
lkHealthy := false
if ok, _ := hub.LiveKitHealthCheck(); ok {
lkHealthy = true
}
// Strip credentials from LiveKit URL before exposing in diagnostics.
sanitizedLKURL := ""
if cfg.Voice.LiveKitURL != "" {
if parsed, parseErr := url.Parse(cfg.Voice.LiveKitURL); parseErr == nil {
sanitizedLKURL = parsed.Host
}
}
resp := diagnosticsResponse{
Server: serverDiag{
Version: ver,
Uptime: int64(time.Since(serverStartTime).Seconds()),
GoVersion: runtime.Version(),
OnlineUsers: hub.ClientCount(),
},
Voice: voiceDiag{
Enabled: cfg.Voice.LiveKitURL != "",
LiveKitURL: sanitizedLKURL,
LiveKitHealth: lkHealthy,
NodeIP: cfg.Voice.NodeIP,
ProxyPath: "/livekit",
},
Client: clientDiag{
RemoteAddr: clientAddr,
IsPrivateNet: isPrivateIP(clientAddr),
},
}
writeJSON(w, http.StatusOK, resp)
}
}
// isPrivateIP checks if an IP string is in a private/reserved range.
func isPrivateIP(ip string) bool {
for _, prefix := range []string{
"10.", "172.16.", "172.17.", "172.18.", "172.19.",
"172.20.", "172.21.", "172.22.", "172.23.", "172.24.",
"172.25.", "172.26.", "172.27.", "172.28.", "172.29.",
"172.30.", "172.31.", "192.168.", "127.", "::1", "fc", "fd",
} {
if len(ip) >= len(prefix) && ip[:len(prefix)] == prefix {
return true
}
}
return false
}