diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d95fecfc..8c325e27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,37 @@ on: - "v*" jobs: + # The v1.1.0-alpha.4 release shipped clients still versioned 1.1.0-alpha.3 + # because the client manifests weren't bumped before tagging — deployed + # clients then never saw the update. Fail fast on that mismatch, before any + # expensive build starts. + verify-versions: + name: Verify client version matches tag + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Compare tag with client manifests + shell: bash + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + TAURI_VERSION=$(node -p "require('./Client/tauri-client/src-tauri/tauri.conf.json').version") + NPM_VERSION=$(node -p "require('./Client/tauri-client/package.json').version") + CARGO_VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Client/tauri-client/src-tauri/Cargo.toml | head -1) + fail=0 + for pair in "tauri.conf.json:$TAURI_VERSION" "package.json:$NPM_VERSION" "Cargo.toml:$CARGO_VERSION"; do + file="${pair%%:*}"; ver="${pair#*:}" + if [ "$ver" != "$TAG_VERSION" ]; then + echo "::error::Release tag v$TAG_VERSION does not match client version $ver in $file — bump the client version before tagging." + fail=1 + fi + done + exit $fail + release-client-windows: name: Build Tauri (Windows) + needs: verify-versions runs-on: windows-latest permissions: contents: read @@ -59,6 +88,7 @@ jobs: release-client-linux: name: Build Tauri (Linux) + needs: verify-versions runs-on: ubuntu-22.04 permissions: contents: read @@ -131,6 +161,7 @@ jobs: release-server: name: Build server (${{ matrix.os }}) + needs: verify-versions strategy: fail-fast: false matrix: @@ -188,6 +219,7 @@ jobs: release-client-linux-arm64: name: Build Tauri (Linux ARM64) + needs: verify-versions runs-on: ubuntu-22.04-arm permissions: contents: read @@ -265,6 +297,7 @@ jobs: release-server-docker: name: Build & Push Server Docker Image + needs: verify-versions runs-on: ubuntu-latest permissions: contents: read diff --git a/CHANGELOG.md b/CHANGELOG.md index 067aeb47..855e26d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,26 @@ behavioural changes operators must know about. ### Behavioural changes operators must know about +- **Voice now works out of the box for clients that are not on the server + machine.** The LiveKit proxy's origin gate rejected two legitimate + client shapes with `/livekit/rtc/v1` 403s — chat worked, voice didn't: + the desktop client's fixed webview origins + (`http(s)://tauri.localhost`, `tauri://localhost`) and any UI served + from the server's own origin, whose WebSocket handshakes always carry + that origin even though same-origin fetches omit it. Both are now + recognized: first-party webview origins are always allowed, and an + `Origin` whose host equals the request's `Host` is treated as + same-origin — mirroring the default policy the chat WebSocket already + applied, with no change to the CSRF posture (a foreign origin still + needs an explicit `allowed_origins` entry). Rejected origins are now + logged (`livekit proxy: origin rejected`) so the next such failure is + diagnosable from the server log. +- **API tokens can use the admin log stream.** `POST + /admin/api/logs/ticket` required a browser login session, so headless + clients (the `mcp-introspect` dev tool, bots) could reach every other + `/admin/api/*` route but not `server_logs`. Tickets are now bound to + whichever credential authenticated the request; revoking a token cuts + an in-flight stream, exactly as session revocation always has. - **The desktop client now actually uses the OS credential store.** The `keyring` crate declares no `default` feature, so the previous `keyring = "3"` dependency compiled its in-memory *mock* store on diff --git a/Client/tauri-client/package-lock.json b/Client/tauri-client/package-lock.json index 9117dc41..a486d67b 100644 --- a/Client/tauri-client/package-lock.json +++ b/Client/tauri-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "owncord-client", - "version": "1.1.0-alpha.3", + "version": "1.1.0-alpha.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "owncord-client", - "version": "1.1.0-alpha.3", + "version": "1.1.0-alpha.5", "dependencies": { "@jitsi/rnnoise-wasm": "^0.2.1", "@tauri-apps/api": "^2.10.1", diff --git a/Client/tauri-client/package.json b/Client/tauri-client/package.json index 2ef74397..21a9fd9a 100644 --- a/Client/tauri-client/package.json +++ b/Client/tauri-client/package.json @@ -1,7 +1,7 @@ { "name": "owncord-client", "private": true, - "version": "1.1.0-alpha.3", + "version": "1.1.0-alpha.5", "type": "module", "scripts": { "dev": "vite", diff --git a/Client/tauri-client/src-tauri/Cargo.lock b/Client/tauri-client/src-tauri/Cargo.lock index c4b7b65f..6fbf3776 100644 --- a/Client/tauri-client/src-tauri/Cargo.lock +++ b/Client/tauri-client/src-tauri/Cargo.lock @@ -3274,7 +3274,7 @@ dependencies = [ [[package]] name = "owncord-client" -version = "1.1.0-alpha.3" +version = "1.1.0-alpha.5" dependencies = [ "base64 0.22.1", "device_query", diff --git a/Client/tauri-client/src-tauri/Cargo.toml b/Client/tauri-client/src-tauri/Cargo.toml index ec225fd4..7697b180 100644 --- a/Client/tauri-client/src-tauri/Cargo.toml +++ b/Client/tauri-client/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "owncord-client" -version = "1.1.0-alpha.3" +version = "1.1.0-alpha.5" edition = "2021" # Effective minimum: tauri 2.11 declares rust-version = "1.77.2", so the crate # cannot build below it. Declaring it here enables Cargo's MSRV-aware resolver diff --git a/Client/tauri-client/src-tauri/tauri.conf.json b/Client/tauri-client/src-tauri/tauri.conf.json index b7197ba0..d1d19905 100644 --- a/Client/tauri-client/src-tauri/tauri.conf.json +++ b/Client/tauri-client/src-tauri/tauri.conf.json @@ -1,6 +1,6 @@ { "productName": "OwnCord", - "version": "1.1.0-alpha.3", + "version": "1.1.0-alpha.5", "identifier": "com.owncord.client", "build": { "frontendDist": "../dist", diff --git a/Server/admin/logstream.go b/Server/admin/logstream.go index 9208510f..2bef7dd1 100644 --- a/Server/admin/logstream.go +++ b/Server/admin/logstream.go @@ -79,16 +79,18 @@ func (ts *ticketStore) redeem(ticket string) (ticketEntry, bool) { } // handleLogTicket issues a short-lived, single-use ticket for the SSE log stream. -// POST /admin/api/logs/ticket — requires normal admin auth (cookie/header). +// POST /admin/api/logs/ticket — requires normal admin auth (header). The ticket +// is bound to the hash of whichever bearer credential authenticated the request +// (login session or API token), so both principal kinds can stream logs. func handleLogTicket(database *db.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - sess, ok := r.Context().Value(adminSessionKey).(*db.Session) - if !ok || sess == nil || sess.TokenHash == "" { + hash, ok := r.Context().Value(adminTokenHashKey).(string) + if !ok || hash == "" { writeErr(w, http.StatusUnauthorized, "UNAUTHORIZED", "invalid or expired session") return } - ticket, err := logTickets.issue(sess.TokenHash) + ticket, err := logTickets.issue(hash) if err != nil { slog.Error("failed to issue log stream ticket", "err", err) writeErr(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to generate ticket") @@ -351,11 +353,14 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { http.Error(w, string(errResp), http.StatusUnauthorized) return } - // Stream lifetime == request lifetime, so all session re-checks below - // use the stream request's context. + // Stream lifetime == request lifetime, so all principal re-checks below + // use the stream request's context. The ticket's hash is resolved the + // same way adminAuthMiddleware resolves a bearer credential — login + // session first, then API token — so revoking either kind mid-stream + // cuts the stream. ctx := r.Context() - sess, err := database.GetSessionByTokenHash(ctx, entry.tokenHash) - if err != nil || sess == nil || auth.IsSessionExpired(sess.ExpiresAt) { + user, role, _, err := auth.ResolveTokenHash(ctx, database, entry.tokenHash) + if err != nil || user == nil || role == nil { errResp, _ := json.Marshal(map[string]string{ "error": "UNAUTHORIZED", "message": "invalid or expired session", @@ -363,27 +368,19 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { http.Error(w, string(errResp), http.StatusUnauthorized) return } - sessionStillAuthorized := func() bool { - current, currentErr := database.GetSessionByTokenHash(ctx, entry.tokenHash) - if currentErr != nil || current == nil || auth.IsSessionExpired(current.ExpiresAt) { - return false - } - user, userErr := database.GetUserByID(ctx, current.UserID) - if userErr != nil || user == nil { + principalStillAuthorized := func() bool { + current, currentRole, _, resolveErr := auth.ResolveTokenHash(ctx, database, entry.tokenHash) + if resolveErr != nil || current == nil || currentRole == nil { return false } // A ban mid-stream must cut the stream, same as adminAuthMiddleware // rejects a banned user on the request path. - if auth.IsEffectivelyBanned(user) { + if auth.IsEffectivelyBanned(current) { return false } - role, roleErr := database.GetRoleByID(ctx, user.RoleID) - if roleErr != nil || role == nil { - return false - } - return permissions.HasAdmin(role.Permissions) + return permissions.HasAdmin(currentRole.Permissions) } - if !sessionStillAuthorized() { + if !principalStillAuthorized() { errResp, _ := json.Marshal(map[string]string{ "error": "FORBIDDEN", "message": "administrator permission required", @@ -409,7 +406,7 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { // Send backfill. for _, entry := range ringBuf.Snapshot() { - if !sessionStillAuthorized() { + if !principalStillAuthorized() { return } if data, err := json.Marshal(entry); err == nil { @@ -429,7 +426,7 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { for { select { case entry := <-ch: - if !sessionStillAuthorized() { + if !principalStillAuthorized() { return } if data, err := json.Marshal(entry); err == nil { @@ -437,7 +434,7 @@ func handleLogStream(database *db.DB, ringBuf *RingBuffer) http.HandlerFunc { flusher.Flush() } case <-keepalive.C: - if !sessionStillAuthorized() { + if !principalStillAuthorized() { return } _, _ = fmt.Fprint(w, ": keepalive\n\n") diff --git a/Server/admin/logstream_apitoken_test.go b/Server/admin/logstream_apitoken_test.go new file mode 100644 index 00000000..96cbf39e --- /dev/null +++ b/Server/admin/logstream_apitoken_test.go @@ -0,0 +1,104 @@ +package admin_test + +import ( + "bufio" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/owncord/server/admin" + "github.com/owncord/server/auth" +) + +// TestAdminAPI_LogStreamTicketFlow_APIToken verifies that an API-token +// principal (headless client, no login session) can obtain a log stream +// ticket, redeem it for the SSE backfill, and that revoking the token +// invalidates tickets minted before the revocation. +func TestAdminAPI_LogStreamTicketFlow_APIToken(t *testing.T) { + database := openAdminTestDB(t) + logBuf := admin.NewRingBuffer(8) + logBuf.Write(admin.LogEntry{Timestamp: "2026-07-31T10:00:00Z", Level: "INFO", Message: "hello from ring", Source: "server"}) + handler := admin.NewAdminAPI(database, "1.0.0", &mockHub{}, nil, logBuf, nil, nil, newTestModService(database)) + + // An admin user authenticated only by an API token — no session row exists. + uid, err := database.CreateUser(context.Background(), "apitokenadmin", "$2a$12$placeholder", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + rawToken := "test-api-token-" + t.Name() + tokenID, err := database.CreateAPIToken(context.Background(), uid, auth.HashToken(rawToken), "mcp-introspect", nil) + if err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + ticketResp := doRequest(t, handler, http.MethodPost, "/logs/ticket", rawToken, nil) + if ticketResp.Code != http.StatusOK { + t.Fatalf("POST /logs/ticket with API token status = %d, want 200; body: %s", ticketResp.Code, ticketResp.Body.String()) + } + var payload struct { + Ticket string `json:"ticket"` + } + if err := json.Unmarshal(ticketResp.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal ticket response: %v", err) + } + if payload.Ticket == "" { + t.Fatal("expected non-empty log stream ticket") + } + + srv := httptest.NewServer(handler) + defer srv.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/logs/stream?ticket="+payload.Ticket, nil) + if err != nil { + t.Fatalf("NewRequestWithContext: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("stream request failed: %v", err) + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("GET /logs/stream?ticket=... with API-token ticket status = %d, want 200; body: %s", resp.StatusCode, string(body)) + } + sawBackfill := false + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "data: ") && strings.Contains(line, "hello from ring") { + sawBackfill = true + cancel() // stop the stream; we only need the backfill + break + } + } + if !sawBackfill { + t.Fatal("expected SSE backfill to deliver the ring buffer entry") + } + + // A ticket minted before revocation must not redeem after it. + ticketResp = doRequest(t, handler, http.MethodPost, "/logs/ticket", rawToken, nil) + if ticketResp.Code != http.StatusOK { + t.Fatalf("POST /logs/ticket (second) status = %d, want 200; body: %s", ticketResp.Code, ticketResp.Body.String()) + } + if err := json.Unmarshal(ticketResp.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal second ticket response: %v", err) + } + if _, err := database.RevokeAPIToken(context.Background(), tokenID); err != nil { + t.Fatalf("RevokeAPIToken: %v", err) + } + revokedResp, err := http.Get(srv.URL + "/logs/stream?ticket=" + payload.Ticket) + if err != nil { + t.Fatalf("revoked-token stream request failed: %v", err) + } + defer revokedResp.Body.Close() //nolint:errcheck + if revokedResp.StatusCode != http.StatusUnauthorized { + body, _ := io.ReadAll(revokedResp.Body) + t.Fatalf("revoked-token ticket status = %d, want 401; body: %s", revokedResp.StatusCode, string(body)) + } +} diff --git a/Server/admin/logstream_test.go b/Server/admin/logstream_test.go index f2bbb390..9ebc1ea6 100644 --- a/Server/admin/logstream_test.go +++ b/Server/admin/logstream_test.go @@ -113,3 +113,51 @@ func TestHandleLogStream_BackfillStopsAfterSessionRevocation(t *testing.T) { t.Fatalf("expected backfill to stop after first entry once session was revoked, wrote %d entries; body = %s", writer.writeCount, writer.buffer.String()) } } + +func TestHandleLogStream_BackfillStopsAfterAPITokenRevocation(t *testing.T) { + database := newLogStreamTestDB(t) + logBuf := NewRingBuffer(8) + logBuf.Write(LogEntry{Timestamp: "2026-07-31T10:00:00Z", Level: "info", Message: "first", Source: "test"}) + logBuf.Write(LogEntry{Timestamp: "2026-07-31T10:00:01Z", Level: "info", Message: "second", Source: "test"}) + + userID, err := database.CreateUser(context.Background(), "owner", "hash", 1) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + token, err := auth.GenerateToken() + if err != nil { + t.Fatalf("GenerateToken: %v", err) + } + tokenHash := auth.HashToken(token) + tokenID, err := database.CreateAPIToken(context.Background(), userID, tokenHash, "test", nil) + if err != nil { + t.Fatalf("CreateAPIToken: %v", err) + } + + ticket, err := logTickets.issue(tokenHash) + if err != nil { + t.Fatalf("issue ticket: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + req := httptest.NewRequest(http.MethodGet, "/logs/stream?ticket="+ticket, nil).WithContext(ctx) + writer := &revokingSSEWriter{ + header: make(http.Header), + revoke: func() { + _, _ = database.RevokeAPIToken(context.Background(), tokenID) + }, + cancel: cancel, + } + + handleLogStream(database, logBuf).ServeHTTP(writer, req) + + if writer.statusCode != http.StatusOK { + t.Fatalf("status = %d, want 200; body = %s", writer.statusCode, writer.buffer.String()) + } + if writer.writeCount != 1 { + t.Fatalf("expected backfill to stop after first entry once the API token was revoked, wrote %d entries; body = %s", writer.writeCount, writer.buffer.String()) + } +} diff --git a/Server/admin/middleware.go b/Server/admin/middleware.go index 78f3e74d..da025709 100644 --- a/Server/admin/middleware.go +++ b/Server/admin/middleware.go @@ -69,6 +69,7 @@ func adminAuthMiddleware(database *db.DB) func(http.Handler) http.Handler { ctx := context.WithValue(r.Context(), adminUserKey, user) ctx = context.WithValue(ctx, adminSessionKey, sess) // nil for API-token principals; consumers guard nil + ctx = context.WithValue(ctx, adminTokenHashKey, hash) next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/Server/admin/types.go b/Server/admin/types.go index 3ba462f5..0f1b7ec2 100644 --- a/Server/admin/types.go +++ b/Server/admin/types.go @@ -16,6 +16,10 @@ const ( adminUserKey adminContextKey = iota // adminSessionKey is the context key for the authenticated *db.Session. adminSessionKey + // adminTokenHashKey is the context key for the hash (string) of the bearer + // credential that authenticated the request — a login session or an API + // token. Unlike adminSessionKey it is set for both principal kinds. + adminTokenHashKey ) // ─── Allowed settings keys ──────────────────────────────────────────────────── diff --git a/Server/api/livekit_proxy.go b/Server/api/livekit_proxy.go index 9440c3cd..a5e52ec6 100644 --- a/Server/api/livekit_proxy.go +++ b/Server/api/livekit_proxy.go @@ -76,6 +76,8 @@ func NewLiveKitProxy(livekitURL string, allowedOrigins []string) http.Handler { // Validate Origin header (mirrors WS OriginPatterns). if !isOriginAllowed(r, allowedOrigins) { + slog.Warn("livekit proxy: origin rejected", + "origin", r.Header.Get("Origin"), "path", r.URL.Path, "remote", r.RemoteAddr) writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", Message: "access denied", @@ -102,15 +104,52 @@ func isWebSocketUpgrade(r *http.Request) bool { 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). +// firstPartyClientOrigins are the fixed webview origins of OwnCord's own +// desktop client (Tauri): WebView2 on Windows uses http(s)://tauri.localhost, +// WKWebView/WebKitGTK use the tauri:// scheme. The client's chat connection +// goes through its Rust proxy (which sends no Origin header), but the LiveKit +// JS SDK's signal requests and validate probes are issued directly from the +// webview and DO carry these origins — without this allowance, voice fails +// with 403 on every default install (empty allowed_origins) for any client +// not on the server machine. +// +// Allowing them matches the trust already extended to absent-Origin requests: +// a remote website can never present these origins (browsers resolve +// *.localhost to loopback per RFC 6761 semantics and the tauri:// scheme is +// not reachable from web content), so this does not widen the CSRF surface — +// only code running on the user's own machine could ever send them, which is +// outside the web attacker model this check defends against. +var firstPartyClientOrigins = []string{ + "http://tauri.localhost", + "https://tauri.localhost", + "tauri://localhost", +} + +// 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) and the first-party desktop client's webview origins are +// always permitted. Beyond those, 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 } + // Browsers attach the page's own origin to every WebSocket handshake + // (fetch may omit it same-origin; WS never does). An Origin whose host + // matches the request's Host is a page this server itself served — + // same-origin, not cross-origin. This mirrors websocket.Accept's default + // policy, which the chat WS endpoint already applies; web content on + // another origin can never present it (the browser pins Origin), so it + // does not widen the CSRF surface. + if u, err := url.Parse(origin); err == nil && u.Host != "" && strings.EqualFold(u.Host, r.Host) { + return true + } + for _, firstParty := range firstPartyClientOrigins { + if strings.EqualFold(origin, firstParty) { + return true + } + } if len(allowedOrigins) == 0 { return false // no allowlist configured — deny cross-origin } diff --git a/Server/api/livekit_proxy_test.go b/Server/api/livekit_proxy_test.go index 19ed2a6e..865f6b1d 100644 --- a/Server/api/livekit_proxy_test.go +++ b/Server/api/livekit_proxy_test.go @@ -73,18 +73,21 @@ func TestIsOriginAllowed_EmptyOriginAllowed(t *testing.T) { } } +// Note: fixtures use origins distinct from httptest's default request host +// (example.com) so these exercise the allowlist path, not the same-origin +// allowance. func TestIsOriginAllowed_MatchingOrigin(t *testing.T) { r := httptest.NewRequest("GET", "/livekit/", nil) - r.Header.Set("Origin", "https://example.com") - if !isOriginAllowed(r, []string{"https://example.com"}) { + r.Header.Set("Origin", "https://app.example.net") + if !isOriginAllowed(r, []string{"https://app.example.net"}) { t.Error("expected true for matching origin") } } func TestIsOriginAllowed_CaseInsensitive(t *testing.T) { r := httptest.NewRequest("GET", "/livekit/", nil) - r.Header.Set("Origin", "HTTPS://EXAMPLE.COM") - if !isOriginAllowed(r, []string{"https://example.com"}) { + r.Header.Set("Origin", "HTTPS://APP.EXAMPLE.NET") + if !isOriginAllowed(r, []string{"https://app.example.net"}) { t.Error("expected true for case-insensitive origin match") } } @@ -107,7 +110,7 @@ func TestIsOriginAllowed_WildcardAllowsAll(t *testing.T) { func TestIsOriginAllowed_EmptyAllowlistDenies(t *testing.T) { r := httptest.NewRequest("GET", "/livekit/", nil) - r.Header.Set("Origin", "https://example.com") + r.Header.Set("Origin", "https://cross.example.net") if isOriginAllowed(r, []string{}) { t.Error("expected false when allowlist is empty") } @@ -122,6 +125,68 @@ func TestIsOriginAllowed_MultipleAllowedOrigins(t *testing.T) { } } +// The desktop client's webview issues LiveKit signal/validate requests +// directly (not via its Origin-stripping Rust proxy), so its fixed origins +// must pass even on the default empty allowlist — otherwise voice 403s on +// every fresh install for any client not on the server machine. +func TestIsOriginAllowed_FirstPartyDesktopOrigins(t *testing.T) { + for _, origin := range []string{ + "http://tauri.localhost", // WebView2 (Windows) + "https://tauri.localhost", // WebView2, https variant + "tauri://localhost", // WKWebView / WebKitGTK (macOS, Linux) + "HTTP://TAURI.LOCALHOST", // case-insensitive + } { + r := httptest.NewRequest("GET", "/livekit/", nil) + r.Header.Set("Origin", origin) + if !isOriginAllowed(r, nil) { + t.Errorf("expected true for first-party desktop origin %q with empty allowlist", origin) + } + } +} + +// A browser always attaches the page's origin to WebSocket handshakes, so a +// page served by this very server (Origin host == request Host) must pass +// even on the default empty allowlist. This mirrors websocket.Accept's +// default same-origin policy, which the chat WS endpoint already applies — +// without it a same-origin client can chat but voice 403s. +func TestIsOriginAllowed_SameOriginAllowed(t *testing.T) { + for _, origin := range []string{ + "https://192.168.0.125:8443", + "HTTPS://192.168.0.125:8443", // case-insensitive + } { + r := httptest.NewRequest("GET", "https://192.168.0.125:8443/livekit/", nil) + r.Header.Set("Origin", origin) + if !isOriginAllowed(r, nil) { + t.Errorf("expected true for same-origin %q with empty allowlist", origin) + } + } +} + +// Same host but a different port is a different origin and must not pass. +func TestIsOriginAllowed_SameHostDifferentPortDenied(t *testing.T) { + r := httptest.NewRequest("GET", "https://192.168.0.125:8443/livekit/", nil) + r.Header.Set("Origin", "https://192.168.0.125:9999") + if isOriginAllowed(r, nil) { + t.Error("expected false for same host with different port") + } +} + +// A lookalike origin must NOT ride along with the first-party allowance. +func TestIsOriginAllowed_FirstPartyLookalikesDenied(t *testing.T) { + for _, origin := range []string{ + "http://tauri.localhost.evil.com", + "http://eviltauri.localhost", + "http://tauri.localhost:8080", + "tauri://evil", + } { + r := httptest.NewRequest("GET", "/livekit/", nil) + r.Header.Set("Origin", origin) + if isOriginAllowed(r, nil) { + t.Errorf("expected false for lookalike origin %q", origin) + } + } +} + // --- NewLiveKitProxy HTTP routing tests --- func TestLiveKitProxy_BlocksAdminPath(t *testing.T) { diff --git a/Server/config/config.go b/Server/config/config.go index c8429f67..ef03ccca 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -281,7 +281,8 @@ server: port: 8443 name: "OwnCord Server" data_dir: "data" - # allowed_origins: [] # empty = deny cross-origin; set to ["*"] for dev or specific origins for prod + # allowed_origins: [] # browser origins allowed to connect; empty = deny cross-origin. + # # The OwnCord desktop client is always accepted and needs no entry. # trusted_proxies: [] # CIDRs of the reverse-proxy HOPS only (e.g. ["10.0.0.2/32"]). # # Never list client networks here: a range that covers # # clients degrades per-client rate limiting and lets diff --git a/docs/server-configuration.md b/docs/server-configuration.md index ed5717d6..8906f8a8 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -33,7 +33,7 @@ the server automatically when a startup-only value changed. Note that | `server.port` | int | `8443` | HTTP(S) listen port | | `server.name` | string | `"OwnCord Server"` | Server display name (shown in `/api/v1/info` and admin panel) | | `server.data_dir` | string | `"data"` | Directory for database, certs, uploads, backups | -| `server.allowed_origins` | string[] | `[]` | WebSocket CORS allowed origins; empty list DENIES all cross-origin (set to `["*"]` to allow any origin) | +| `server.allowed_origins` | string[] | `[]` | WebSocket CORS allowed origins for **web/browser** clients; empty list DENIES all cross-origin (set to `["*"]` to allow any origin). The OwnCord desktop client needs no entry here — its webview origins (`http(s)://tauri.localhost`, `tauri://localhost`) are always accepted. | | `server.trusted_proxies` | string[] | `[]` | CIDRs of trusted reverse proxies (for X-Forwarded-For) | | `server.admin_allowed_cidrs` | string[] | private networks | CIDRs allowed to access `/admin` routes. Default: `127.0.0.0/8`, `::1/128`, `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, `fc00::/7` |