mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Applies fixes for 20 adversarially-verified findings from a whole-codebase security review (server side). All Go build-tag variants build, `go vet` is clean, and the suite passes (the sole failing test, ws TestEmitEvents, is a pre-existing nil-harness failure unrelated to these changes). High severity: - auth: close TOCTOU in TOTP verify rate-limit by recording each attempt atomically up-front (was Check-then-Allow), restoring the per-user brute-force cap. - plugin: enforce the CPU/time budget on every WASM guest call via a WithTimeout context (WithCloseOnContextDone interrupts runaways); the configured budget was previously parsed but never applied. - api/waf: inspect request bodies for chunked (ContentLength==-1) requests so the SQLi/XSS/RCE body rules can no longer be bypassed. - ws: rate-limit voice_join/voice_leave and voice_e2ee announce/offer, which fan out to every participant and could force mass disconnects. Medium severity: - api: run bcrypt on the unknown-user login path (no || short-circuit) to remove the timing-based username-enumeration oracle. - ws: verify LiveKit webhooks via the SDK receiver so the signature is bound to the body hash (kills forgery/replay). - authz: require READ_MESSAGES for reactions and for plugin-command broadcasts; route the latter through RequireChannelAccess. - api: cache the client-update signature fetch and rate-limit the endpoint. - service: propagate DeleteOtherSessions failure from ChangePassword instead of silently reporting success. - api: trust the rightmost non-proxy X-Forwarded-For entry, not the client-controllable leftmost one. - plugin: route auto-registered commands through the conflict-checked RegisterCommand; pin the DNS-validated IP for host_http dials (DNS-rebinding TOCTOU). - api: mark access-controlled downloads private/no-cache + Vary: Origin. Low severity: - auth: fail closed when a fully-shaped TOTP ciphertext fails GCM auth (was returning the ciphertext as plaintext). - api: apply the livekit-proxy path allowlist to WebSocket upgrades too. - service: verify attachment ownership before linking (IDOR). - admin: bound the bootstrap setup invite (5 uses / 24h); re-verify the update binary hash immediately before rename+spawn (TOCTOU). - service: require BanMembers + role hierarchy for moderation ban/unban. chore: stop tracking the stray Server/owncord-server.exe build artifact. Test infra: add uploader_id to the hand-rolled ws test attachment schemas and make MemStore.GetAttachmentByID a no-op lookup, matching production/DB behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
219 lines
6.5 KiB
Go
219 lines
6.5 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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) {
|
|
// Enforce the path allowlist and Origin check for EVERY request,
|
|
// including WebSocket upgrades — otherwise a client could reach a
|
|
// blocked/admin endpoint simply by sending an Upgrade header.
|
|
|
|
// 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 (mirrors WS OriginPatterns).
|
|
if !isOriginAllowed(r, allowedOrigins) {
|
|
writeJSON(w, http.StatusForbidden, errorResponse{
|
|
Error: "FORBIDDEN",
|
|
Message: "access denied",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Detect WebSocket upgrade requests.
|
|
if isWebSocketUpgrade(r) {
|
|
proxyWebSocket(w, r, &wsTarget, allowedOrigins)
|
|
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, dialResp, err := websocket.Dial(r.Context(), backendURL.String(), &websocket.DialOptions{
|
|
Subprotocols: r.Header.Values("Sec-WebSocket-Protocol"),
|
|
})
|
|
if dialResp != nil && dialResp.Body != nil {
|
|
defer dialResp.Body.Close() //nolint:errcheck // best-effort close
|
|
}
|
|
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
|
|
}
|
|
|
|
// wsProxyMaxMessageSize is the maximum WebSocket message size the LiveKit
|
|
// proxy will forward. Messages exceeding this are dropped to prevent OOM.
|
|
// 256 KB is generous for LiveKit signaling (typically < 10 KB).
|
|
const wsProxyMaxMessageSize = 256 * 1024
|
|
|
|
// copyWS reads messages from src and writes them to dst until an error or
|
|
// context cancellation. H-5: Messages exceeding wsProxyMaxMessageSize are
|
|
// rejected to prevent memory exhaustion via oversized frames.
|
|
func copyWS(ctx context.Context, dst, src *websocket.Conn) error {
|
|
for {
|
|
msgType, reader, err := src.Reader(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Wrap reader with a size limit to prevent OOM from oversized messages.
|
|
limited := io.LimitReader(reader, wsProxyMaxMessageSize+1)
|
|
writer, err := dst.Writer(ctx, msgType)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, copyErr := io.Copy(writer, limited)
|
|
if copyErr != nil {
|
|
return copyErr
|
|
}
|
|
if n > wsProxyMaxMessageSize {
|
|
return fmt.Errorf("livekit proxy: message exceeds %d byte limit", wsProxyMaxMessageSize)
|
|
}
|
|
if closeErr := writer.Close(); closeErr != nil {
|
|
return closeErr
|
|
}
|
|
}
|
|
}
|