From 4b8db3cfe50383206e549783239c4dc3f0aa89d5 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Thu, 14 May 2026 10:48:27 +0530 Subject: [PATCH] Add integration test for TURN auth failures (#4524) * Add integration test for TURN auth failures Covers four credential-corruption scenarios against the TURN server embedded in a single-node server: unparseable username, wrong password, expired username, and unknown API key. Each case drives a raw pion turn.Client Allocate and asserts the server rejects with a TURN error. Co-Authored-By: Claude Opus 4.7 (1M context) * TURN auth test: cover password expiry binding The TURN password's hash includes the expiry along with the secret and participant ID. Add two cases that exercise this binding: a password generated for a different expiry than the username's, and a password generated without any expiry component paired with a username that has one. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- go.mod | 2 +- test/singlenode_test.go | 120 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/go.mod b/go.mod index d2c847c42..54c4b5e30 100644 --- a/go.mod +++ b/go.mod @@ -139,7 +139,7 @@ require ( github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/srtp/v3 v3.0.10 // indirect - github.com/pion/stun/v3 v3.1.2 // indirect + github.com/pion/stun/v3 v3.1.2 github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.66.1 // indirect diff --git a/test/singlenode_test.go b/test/singlenode_test.go index d40edf3a5..fd898f32f 100644 --- a/test/singlenode_test.go +++ b/test/singlenode_test.go @@ -19,6 +19,7 @@ import ( "encoding/binary" "errors" "fmt" + "net" "net/http" "reflect" "strings" @@ -27,6 +28,8 @@ import ( "time" "github.com/pion/sdp/v3" + "github.com/pion/stun/v3" + "github.com/pion/turn/v4" "github.com/pion/webrtc/v4" "github.com/stretchr/testify/require" "github.com/thoas/go-funk" @@ -41,6 +44,7 @@ import ( "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/rtc" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/livekit-server/pkg/service" "github.com/livekit/livekit-server/pkg/sfu" "github.com/livekit/livekit-server/pkg/sfu/datachannel" "github.com/livekit/livekit-server/pkg/testutils" @@ -1385,3 +1389,119 @@ func TestTurnRelay(t *testing.T) { }) } } + +func TestTurnAuthFailure(t *testing.T) { + if testing.Short() { + t.SkipNow() + return + } + + const turnUDPPort = 3478 + + s := createSingleNodeServer(func(c *config.Config) { + c.TURN.Enabled = true + c.TURN.UDPPort = turnUDPPort + }) + go func() { + if err := s.Start(); err != nil { + logger.Errorw("server returned error", err) + } + }() + defer s.Stop(true) + + waitForServerToStart(s) + + // build a known-good username/password pair so individual cases can mutate + // only the part they are exercising. + pID := livekit.ParticipantID("PA_authfail") + authHandler := service.NewTURNAuthHandler(auth.NewSimpleKeyProvider(testApiKey, testApiSecret)) + validUsername, validExpiry := authHandler.CreateUsername(testApiKey, pID, 300) + validPassword, err := authHandler.CreatePassword(testApiKey, pID, validExpiry) + require.NoError(t, err) + + // username encoded with an already-expired timestamp. + expiredUsername, _ := authHandler.CreateUsername(testApiKey, pID, -10) + + // username encoded with an api key the server does not know about. + unknownAPIKeyUsername, _ := authHandler.CreateUsername("unknown-api-key", pID, 300) + + // password whose hash was generated for an expiry that doesn't match the + // one encoded in the username. The server reconstructs the password using + // the username's expiry, so the integrity check fails. + mismatchedExpiryPassword, err := authHandler.CreatePassword(testApiKey, pID, validExpiry+60) + require.NoError(t, err) + require.NotEqual(t, validPassword, mismatchedExpiryPassword) + + // password whose hash was generated without an expiry component (the + // pre-expiry form of the username/password pair); should not authenticate + // against a username that carries an expiry. + passwordWithoutExpiry, err := authHandler.CreatePassword(testApiKey, pID, 0) + require.NoError(t, err) + require.NotEqual(t, validPassword, passwordWithoutExpiry) + + testCases := []struct { + name string + username string + password string + }{ + { + name: "unparseable-username", + username: "not-base62!!!", + password: validPassword, + }, + { + name: "wrong-password", + username: validUsername, + password: "wrongpassword", + }, + { + name: "expired-username", + username: expiredUsername, + password: validPassword, + }, + { + name: "unknown-api-key", + username: unknownAPIKeyUsername, + password: validPassword, + }, + { + name: "password-expiry-mismatch", + username: validUsername, + password: mismatchedExpiryPassword, + }, + { + name: "password-missing-expiry", + username: validUsername, + password: passwordWithoutExpiry, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + conn, err := net.ListenPacket("udp4", "0.0.0.0:0") + require.NoError(t, err) + defer conn.Close() + + client, err := turn.NewClient(&turn.ClientConfig{ + TURNServerAddr: fmt.Sprintf("127.0.0.1:%d", turnUDPPort), + Username: tc.username, + Password: tc.password, + Realm: service.LivekitRealm, + Conn: conn, + }) + require.NoError(t, err) + defer client.Close() + require.NoError(t, client.Listen()) + + _, allocErr := client.Allocate() + require.Error(t, allocErr) + + // pion's TURN server replies with 400 Bad Request for any + // authenticated-allocate failure (unknown user or integrity check + // mismatch); the initial unauthenticated probe is what returns 401. + var turnErr *stun.TurnError + require.ErrorAs(t, allocErr, &turnErr) + require.Equal(t, stun.CodeBadRequest, turnErr.ErrorCodeAttr.Code) + }) + } +}