Files
OwnCord/Server/api/livekit_proxy.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

199 lines
5.6 KiB
Go

package api
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
"strings"
"nhooyr.io/websocket"
)
// NewLiveKitProxy creates a reverse proxy handler that forwards both HTTP
// and WebSocket requests to the LiveKit server. This allows the client to
// reach LiveKit through OwnCord's existing HTTPS server, avoiding
// mixed-content blocks in WebView2 (secure page → insecure WebSocket).
//
// The client connects to wss://server:8443/livekit/ which is proxied to
// ws://localhost:7880/ on the LiveKit server.
func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler {
target, err := url.Parse(livekitURL)
if err != nil {
slog.Error("invalid LiveKit URL — falling back to localhost:7880",
"url", livekitURL, "error", err)
target, _ = url.Parse("http://localhost:7880")
}
// Normalise scheme for HTTP proxy target.
httpTarget := *target
switch httpTarget.Scheme {
case "ws":
httpTarget.Scheme = "http"
case "wss":
httpTarget.Scheme = "https"
}
// Normalise scheme for WebSocket proxy target.
wsTarget := *target
switch wsTarget.Scheme {
case "http":
wsTarget.Scheme = "ws"
case "https":
wsTarget.Scheme = "wss"
}
httpProxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = httpTarget.Scheme
req.URL.Host = httpTarget.Host
req.Host = httpTarget.Host
},
}
// Paths that must never be forwarded to LiveKit (internal/admin endpoints).
// Matched as exact path segments to avoid false positives (e.g. "/user-metrics").
blockedSegments := map[string]bool{"admin": true, "metrics": true, "debug": true, "twirp": true}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Detect WebSocket upgrade requests.
if isWebSocketUpgrade(r) {
proxyWebSocket(w, r, &wsTarget, allowedOrigins)
return
}
// Block sensitive LiveKit endpoints (exact segment match).
for _, seg := range strings.Split(strings.ToLower(r.URL.Path), "/") {
if blockedSegments[seg] {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "access denied",
})
return
}
}
// Validate Origin header for HTTP requests (mirrors WS OriginPatterns).
if !isOriginAllowed(r, allowedOrigins) {
writeJSON(w, http.StatusForbidden, errorResponse{
Error: "FORBIDDEN",
Message: "access denied",
})
return
}
httpProxy.ServeHTTP(w, r)
})
}
func isWebSocketUpgrade(r *http.Request) bool {
for _, v := range r.Header.Values("Connection") {
if strings.EqualFold(strings.TrimSpace(v), "upgrade") {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
}
return false
}
// isOriginAllowed checks whether the request's Origin header matches one of the
// allowed origins. Requests with no Origin header (e.g. same-origin or non-browser)
// are permitted. An empty allowedOrigins list denies all cross-origin requests
// (require explicit "*" wildcard to allow all).
func isOriginAllowed(r *http.Request, allowedOrigins []string) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true // non-browser or same-origin requests
}
if len(allowedOrigins) == 0 {
return false // no allowlist configured — deny cross-origin
}
for _, pattern := range allowedOrigins {
if pattern == "*" {
return true
}
if strings.EqualFold(origin, pattern) {
return true
}
}
return false
}
// proxyWebSocket opens a backend WS connection and shovels data in both
// directions until either side closes.
func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL, allowedOrigins []string) {
// Build backend URL preserving the request path and query.
backendURL := *target
backendURL.Path = r.URL.Path
backendURL.RawQuery = r.URL.RawQuery
// Connect to LiveKit backend.
backConn, _, err := websocket.Dial(r.Context(), backendURL.String(), &websocket.DialOptions{
Subprotocols: r.Header.Values("Sec-WebSocket-Protocol"),
})
if err != nil {
slog.Warn("livekit proxy: backend dial failed", "host", backendURL.Host, "path", backendURL.Path, "err", err)
writeJSON(w, http.StatusBadGateway, errorResponse{
Error: "BAD_GATEWAY",
Message: "backend unavailable",
})
return
}
defer backConn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort close on defer
// Accept the frontend WebSocket.
frontConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{backConn.Subprotocol()},
OriginPatterns: allowedOrigins,
})
if err != nil {
slog.Warn("livekit proxy: frontend accept failed", "err", err)
return
}
defer frontConn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort close on defer
// Use a cancellable context so when one direction finishes, the other
// goroutine's copyWS read/write is unblocked and can drain cleanly.
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
errc := make(chan error, 2)
// Frontend → Backend
go func() {
errc <- copyWS(ctx, backConn, frontConn)
}()
// Backend → Frontend
go func() {
errc <- copyWS(ctx, frontConn, backConn)
}()
// Wait for either direction to finish, then cancel+drain both.
<-errc
cancel()
<-errc
}
// copyWS reads messages from src and writes them to dst until an error or
// context cancellation.
func copyWS(ctx context.Context, dst, src *websocket.Conn) error {
for {
msgType, reader, err := src.Reader(ctx)
if err != nil {
return err
}
writer, err := dst.Writer(ctx, msgType)
if err != nil {
return err
}
if _, err = io.Copy(writer, reader); err != nil {
return err
}
if err = writer.Close(); err != nil {
return err
}
}
}