Validate TURN config to guard against invalid values (#4742)

This commit is contained in:
Raja Subramanian
2026-08-11 20:34:49 +05:30
committed by GitHub
parent c4c356f6ca
commit 7167f91493
7 changed files with 344 additions and 37 deletions
+7 -1
View File
@@ -100,7 +100,11 @@ rtc:
# secret: ""
# # Path for file containing shared secret for TURN server authentication
# # When both secret and secret_file are set, secret takes precedence
# # An empty/whitespace-only secret file fails startup rather than silently
# # falling back to static credentials
# secret_file: "/var/lib/coturn/secret"
# # TTL of generated credentials in seconds. Defaults to 14400 (4h) when 0.
# # Negative values fall back to the 300s (5m) default; values are capped at 86400 (24h).
# ttl: 14400 # seconds
# # Insecure username/password authentication
# username: ""
@@ -304,7 +308,8 @@ keys:
# # optional (set only if not using external TLS termination)
# # cert_file: /path/to/cert.pem
# # key_file: /path/to/key.pem
# # TTL of the TURN credentials in seconds - defaults to 300
# # TTL of the TURN credentials in seconds - defaults to 300.
# # Values <= 0 fall back to the 300s (5m) default; values are capped at 86400 (24h).
# ttl_seconds: 300
# # list of restricted peer CIDRs (loopback, link-local (unicast, multicast), multicast, private, unspecified) to allow access to.
# # By default (i. e. empty list), all restricted peer CIDRs are denied access.
@@ -316,6 +321,7 @@ keys:
# # list of peer CIDRs to deny access to.
# # This applies to all peer CIDRs, including restricted ones.
# # Deny list takes precedence over allow list.
# # A malformed CIDR in either list fails startup rather than being silently ignored.
# deny_peer_cidrs:
# - 10.0.0.0/8
# - 192.168.0.0/16
+97 -19
View File
@@ -46,12 +46,27 @@ import (
const (
generatedCLIFlagUsage = "generated"
// TURNMaxTTLSeconds is the operational maximum for TURN credential TTLs (24 hours).
// It bounds credential lifetime and prevents time.Duration overflow when multiplying
// by time.Second (values above ~9.2e9 seconds wrap on 64-bit builds).
TURNMaxTTLSeconds = 24 * 60 * 60
// DefaultTURNTTLSeconds is the default TTL for embedded TURN credentials, and the
// fallback used when a configured TTL is <= 0.
DefaultTURNTTLSeconds = 300
// DefaultExternalTURNTTLSeconds is the default TTL applied to external TURN
// (static-auth-secret) credentials when the configured TTL is left at 0.
DefaultExternalTURNTTLSeconds = 14400
)
var (
ErrKeyFileIncorrectPermission = errors.New("key file others permissions must be set to 0")
ErrTURNSecretFileIncorrectPermission = errors.New("turn secret file others permissions must be set to 0")
ErrKeysNotSet = errors.New("one of key-file or keys must be provided")
ErrTURNSecretEmpty = errors.New("turn secret is empty")
ErrTURNServerNoCredentials = errors.New("turn server has no usable credentials: set a non-empty secret/secret_file for dynamic auth, or username and credential for static auth")
)
type Config struct {
@@ -174,7 +189,8 @@ type TURNServer struct {
// File containing the secret
SecretFile string `yaml:"secret_file,omitempty"`
// TTL is the time-to-live in seconds for generated credentials when using Secret.
// Defaults to 14400 seconds (4 hours) if not specified
// Defaults to 14400 seconds (4 hours) when 0. Negative values fall back to the 5m
// default and large values are capped at TURNMaxTTLSeconds.
TTL int `yaml:"ttl,omitempty"`
}
@@ -252,7 +268,8 @@ type TURNConfig struct {
RelayPortRangeEnd uint16 `yaml:"relay_range_end,omitempty"`
ExternalTLS bool `yaml:"external_tls,omitempty"`
BindAddresses []string `yaml:"bind_addresses,omitempty"`
// TTL of the TURN credentials in seconds - defaults to 300
// TTL of the TURN credentials in seconds - defaults to 300. Values <= 0 fall back to the
// 300s (5m) default and large values are capped at TURNMaxTTLSeconds.
TTLSeconds int `yaml:"ttl_seconds,omitempty"`
// list of restricted peer CIDRs (loopback, link-local (unicast, multicast), multicast, private, unspecified) to allow access to.
// By default (i. e. empty list), all restricted peer CIDRs are denied access.
@@ -521,7 +538,7 @@ var DefaultConfig = Config{
TURN: TURNConfig{
Enabled: false,
BindAddresses: []string{"0.0.0.0"},
TTLSeconds: 300,
TTLSeconds: DefaultTURNTTLSeconds,
},
NodeSelector: NodeSelectorConfig{
Kind: "any",
@@ -580,6 +597,8 @@ func NewConfig(confString string, strictMode bool, c *cli.Command, baseFlags []c
return nil, fmt.Errorf("could not validate RTC config: %v", err)
}
conf.NormalizeTURNTTLs()
// expand env vars in filenames
file, err := homedir.Expand(os.ExpandEnv(conf.KeyFile))
if err != nil {
@@ -734,30 +753,89 @@ func (conf *Config) ValidateKeys() error {
func (conf *Config) LoadTURNSecrets() error {
var otherFilter os.FileMode = 0o007
for i, s := range conf.RTC.TURNServers {
if s.SecretFile == "" {
continue
}
if s.Secret != "" {
// trim first so a blank inline secret falls back to secret_file rather than being treated as set
inlineSecret := strings.TrimSpace(s.Secret)
switch {
case s.SecretFile != "" && inlineSecret != "":
logger.Warnw("both secret and secret_file are set for TURN server, the hardcoded secret will be used", nil,
"host", s.Host, "port", s.Port)
continue
conf.RTC.TURNServers[i].Secret = inlineSecret
case s.SecretFile != "":
st, err := os.Stat(s.SecretFile)
if err != nil {
return err
}
if st.Mode().Perm()&otherFilter != 0o000 {
return ErrTURNSecretFileIncorrectPermission
}
data, err := os.ReadFile(s.SecretFile)
if err != nil {
return fmt.Errorf("reading turn secret file %q: %w", s.SecretFile, err)
}
secret := strings.TrimSpace(string(data))
if secret == "" {
return fmt.Errorf("turn server %q secret file %q: %w", s.Host, s.SecretFile, ErrTURNSecretEmpty)
}
conf.RTC.TURNServers[i].Secret = secret
default:
conf.RTC.TURNServers[i].Secret = inlineSecret
}
st, err := os.Stat(s.SecretFile)
if err != nil {
return err
// validate credential mode as an explicit union rather than inferring it from a
// post-load empty secret (which would silently fall back to static credentials)
s := conf.RTC.TURNServers[i]
hasDynamic := s.Secret != ""
hasStatic := s.Username != "" && s.Credential != ""
if !hasDynamic && !hasStatic {
return fmt.Errorf("turn server %q: %w", s.Host, ErrTURNServerNoCredentials)
}
if st.Mode().Perm()&otherFilter != 0o000 {
return ErrTURNSecretFileIncorrectPermission
}
data, err := os.ReadFile(s.SecretFile)
if err != nil {
return fmt.Errorf("reading turn secret file %q: %w", s.SecretFile, err)
}
conf.RTC.TURNServers[i].Secret = strings.TrimSpace(string(data))
}
return nil
}
// ClampTURNTTLSeconds bounds a TURN credential TTL to [DefaultTURNTTLSeconds, TURNMaxTTLSeconds]:
// non-positive values (which would otherwise produce already-expired credentials) fall back to
// the 5m default and overflowing values clamp to the max. The second return value reports whether
// the input was out of range.
func ClampTURNTTLSeconds(ttlSeconds int) (int, bool) {
switch {
case ttlSeconds <= 0:
return DefaultTURNTTLSeconds, true
case ttlSeconds > TURNMaxTTLSeconds:
return TURNMaxTTLSeconds, true
default:
return ttlSeconds, false
}
}
// NormalizeTURNTTLs clamps configured TURN TTLs to the safe range, warning on any
// adjustment. Safe to call more than once.
func (conf *Config) NormalizeTURNTTLs() {
if clamped, changed := ClampTURNTTLSeconds(conf.TURN.TTLSeconds); changed {
logger.Warnw(
"turn.ttl_seconds out of range, using safe value", nil,
"configured", conf.TURN.TTLSeconds,
"clamped", clamped,
"max", TURNMaxTTLSeconds,
)
conf.TURN.TTLSeconds = clamped
}
for i := range conf.RTC.TURNServers {
// external TTL of 0 means "use the default", so only cap the upper bound here;
// non-positive values fall back to the default when credentials are generated
if ttl := conf.RTC.TURNServers[i].TTL; ttl > TURNMaxTTLSeconds {
logger.Warnw(
"turn_servers ttl out of range, using safe value", nil,
"host", conf.RTC.TURNServers[i].Host,
"configured", ttl,
"clamped", TURNMaxTTLSeconds,
"max", TURNMaxTTLSeconds,
)
conf.RTC.TURNServers[i].TTL = TURNMaxTTLSeconds
}
}
}
func GenerateCLIFlags(existingFlags []cli.Flag, hidden bool) ([]cli.Flag, error) {
defaultConfig := &DefaultConfig
flags := make([]cli.Flag, 0)
+124
View File
@@ -15,6 +15,8 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
@@ -83,3 +85,125 @@ func TestGeneratedFlags(t *testing.T) {
func TestYAMLTag(t *testing.T) {
require.NoError(t, configtest.CheckYAMLTags(Config{}))
}
func TestClampTURNTTLSeconds(t *testing.T) {
cases := []struct {
name string
in int
want int
changed bool
}{
{"negative falls back to default", -1, DefaultTURNTTLSeconds, true},
{"large negative falls back to default", -1 << 40, DefaultTURNTTLSeconds, true},
{"zero falls back to default", 0, DefaultTURNTTLSeconds, true},
{"in range preserved", 600, 600, false},
{"max preserved", TURNMaxTTLSeconds, TURNMaxTTLSeconds, false},
{"over max clamps to max", TURNMaxTTLSeconds + 1, TURNMaxTTLSeconds, true},
{"overflowing value clamps to max", 1<<62 + 1, TURNMaxTTLSeconds, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, changed := ClampTURNTTLSeconds(c.in)
require.Equal(t, c.want, got)
require.Equal(t, c.changed, changed)
})
}
}
func TestNormalizeTURNTTLs(t *testing.T) {
conf, err := NewConfig("", true, nil, nil)
require.NoError(t, err)
conf.TURN.TTLSeconds = -5
conf.RTC.TURNServers = []TURNServer{
{Host: "over", TTL: TURNMaxTTLSeconds + 100},
{Host: "negative", TTL: -1},
{Host: "default", TTL: 0},
{Host: "ok", TTL: 600},
}
conf.NormalizeTURNTTLs()
// embedded TTL <= 0 falls back to the 5m default
require.Equal(t, DefaultTURNTTLSeconds, conf.TURN.TTLSeconds)
// external TTLs: only the upper bound is capped at load; 0/negative keep their
// "use the default" meaning and are resolved when credentials are generated
require.Equal(t, TURNMaxTTLSeconds, conf.RTC.TURNServers[0].TTL)
require.Equal(t, -1, conf.RTC.TURNServers[1].TTL)
require.Equal(t, 0, conf.RTC.TURNServers[2].TTL)
require.Equal(t, 600, conf.RTC.TURNServers[3].TTL)
}
func TestNewConfigNormalizesTURNTTL(t *testing.T) {
const content = `turn:
ttl_seconds: -10`
conf, err := NewConfig(content, true, nil, nil)
require.NoError(t, err)
require.Equal(t, DefaultTURNTTLSeconds, conf.TURN.TTLSeconds)
}
func writeSecretFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "secret")
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
return path
}
func TestLoadTURNSecrets(t *testing.T) {
t.Run("empty secret file is rejected", func(t *testing.T) {
conf := &Config{}
conf.RTC.TURNServers = []TURNServer{{Host: "h", SecretFile: writeSecretFile(t, "")}}
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNSecretEmpty)
})
t.Run("whitespace-only secret file is rejected", func(t *testing.T) {
conf := &Config{}
conf.RTC.TURNServers = []TURNServer{{Host: "h", SecretFile: writeSecretFile(t, " \n\t ")}}
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNSecretEmpty)
})
t.Run("valid secret file is loaded and trimmed", func(t *testing.T) {
conf := &Config{}
conf.RTC.TURNServers = []TURNServer{{Host: "h", SecretFile: writeSecretFile(t, " topsecret\n")}}
require.NoError(t, conf.LoadTURNSecrets())
require.Equal(t, "topsecret", conf.RTC.TURNServers[0].Secret)
})
t.Run("inline whitespace secret without static creds is rejected", func(t *testing.T) {
conf := &Config{}
conf.RTC.TURNServers = []TURNServer{{Host: "h", Secret: " "}}
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNServerNoCredentials)
})
t.Run("no credentials at all is rejected", func(t *testing.T) {
conf := &Config{}
conf.RTC.TURNServers = []TURNServer{{Host: "h"}}
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNServerNoCredentials)
})
t.Run("static credentials are accepted", func(t *testing.T) {
conf := &Config{}
conf.RTC.TURNServers = []TURNServer{{Host: "h", Username: "u", Credential: "c"}}
require.NoError(t, conf.LoadTURNSecrets())
})
t.Run("partial static credentials are rejected", func(t *testing.T) {
conf := &Config{}
conf.RTC.TURNServers = []TURNServer{{Host: "h", Username: "u"}}
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNServerNoCredentials)
})
t.Run("inline secret takes precedence and is trimmed", func(t *testing.T) {
conf := &Config{}
conf.RTC.TURNServers = []TURNServer{{Host: "h", Secret: " inline ", SecretFile: writeSecretFile(t, "fromfile")}}
require.NoError(t, conf.LoadTURNSecrets())
require.Equal(t, "inline", conf.RTC.TURNServers[0].Secret)
})
t.Run("blank inline secret falls back to secret file", func(t *testing.T) {
conf := &Config{}
conf.RTC.TURNServers = []TURNServer{{Host: "h", Secret: " ", SecretFile: writeSecretFile(t, "fromfile")}}
require.NoError(t, conf.LoadTURNSecrets())
require.Equal(t, "fromfile", conf.RTC.TURNServers[0].Secret)
})
}
+5 -2
View File
@@ -1096,11 +1096,14 @@ func (r *RoomManager) iceServersForParticipant(apiKey string, participant types.
var username, credential string
if s.Secret != "" {
// Generate dynamic credentials using TURN static auth secrets
// Generate dynamic credentials using TURN static auth secrets.
// 0 means use the default; clamp the rest so a negative or overflowing TTL
// cannot produce a wrong-lifetime credential.
ttl := s.TTL
if ttl == 0 {
ttl = 14400 // Default 4 hours
ttl = config.DefaultExternalTURNTTLSeconds
}
ttl, _ = config.ClampTURNTTLSeconds(ttl)
expiry := time.Now().Add(time.Duration(ttl) * time.Second).Unix()
participantID := string(participant.ID())
+34 -11
View File
@@ -46,6 +46,20 @@ const (
var ErrExpired = errors.New("expired")
// parsePeerCIDRs compiles a list of CIDR strings, failing with a field-specific
// error on any invalid entry so a malformed peer policy is never silently ignored.
func parsePeerCIDRs(field string, cidrs []string) ([]*net.IPNet, error) {
parsed := make([]*net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs {
_, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
return nil, fmt.Errorf("invalid CIDR %q in %s: %w", cidr, field, err)
}
parsed = append(parsed, ipnet)
}
return parsed, nil
}
func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone bool) (*turn.Server, error) {
turnConf := conf.TURN
if !turnConf.Enabled {
@@ -64,6 +78,17 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone
}
}
// parse peer CIDR policies once at startup so a malformed entry fails loudly
// instead of being silently skipped on every permission decision (fail-open)
allowRestrictedPeerCIDRs, err := parsePeerCIDRs("turn.allow_restricted_peer_cidrs", turnConf.AllowRestrictedPeerCIDRs)
if err != nil {
return nil, err
}
denyPeerCIDRs, err := parsePeerCIDRs("turn.deny_peer_cidrs", turnConf.DenyPeerCIDRs)
if err != nil {
return nil, err
}
serverConfig := turn.ServerConfig{
Realm: LivekitRealm,
AuthHandler: authHandler,
@@ -105,12 +130,10 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone
peerIP.IsPrivate() ||
peerIP.IsUnspecified() {
allowed := false
for _, cidr := range turnConf.AllowRestrictedPeerCIDRs {
if _, ipnet, err := net.ParseCIDR(cidr); err == nil {
if ipnet.Contains(peerIP) {
allowed = true
break
}
for _, ipnet := range allowRestrictedPeerCIDRs {
if ipnet.Contains(peerIP) {
allowed = true
break
}
}
if !allowed {
@@ -120,11 +143,9 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone
// if allowed, check deny list for overrides
}
for _, cidr := range turnConf.DenyPeerCIDRs {
if _, ipnet, err := net.ParseCIDR(cidr); err == nil {
if ipnet.Contains(peerIP) {
return false
}
for _, ipnet := range denyPeerCIDRs {
if ipnet.Contains(peerIP) {
return false
}
}
@@ -206,6 +227,8 @@ func NewTURNAuthHandler(keyProvider auth.KeyProvider) *TURNAuthHandler {
}
func (h *TURNAuthHandler) CreateUsername(apiKey string, pID livekit.ParticipantID, ttlSeconds int) (string, int64) {
// clamp defensively: non-positive TTLs fall back to the default and overflowing ones are capped
ttlSeconds, _ = config.ClampTURNTTLSeconds(ttlSeconds)
expiry := time.Now().Add(time.Duration(ttlSeconds) * time.Second).Unix()
return base62.EncodeToString(fmt.Appendf(nil, "%s|%s|%d", apiKey, pID, expiry)), expiry
}
+74 -2
View File
@@ -18,6 +18,7 @@ import (
"fmt"
"net"
"testing"
"time"
"github.com/jxskiss/base62"
"github.com/pion/stun/v3"
@@ -26,6 +27,8 @@ import (
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/livekit"
"github.com/livekit/livekit-server/pkg/config"
)
const (
@@ -75,7 +78,8 @@ func TestTURNAuthHandler_HandleAuth_ExpiredAllocateRejected(t *testing.T) {
h := newTestTurnAuthHandler()
pID := livekit.ParticipantID("PA_expired_alloc")
username, _ := h.CreateUsername(turnTestAPIKey, pID, -60)
expiry := time.Now().Add(-time.Minute).Unix()
username := base62.EncodeToString(fmt.Appendf(nil, "%s|%s|%d", turnTestAPIKey, pID, expiry))
_, _, ok := h.HandleAuth(&turn.RequestAttributes{
Username: username,
Realm: LivekitRealm,
@@ -89,7 +93,8 @@ func TestTURNAuthHandler_HandleAuth_ExpiredNonAllocateAllowed(t *testing.T) {
h := newTestTurnAuthHandler()
pID := livekit.ParticipantID("PA_expired_refresh")
username, expiry := h.CreateUsername(turnTestAPIKey, pID, -60)
expiry := time.Now().Add(-time.Minute).Unix()
username := base62.EncodeToString(fmt.Appendf(nil, "%s|%s|%d", turnTestAPIKey, pID, expiry))
// CreatePassword still enforces ErrExpired on its own, but the server hands
// the same key it generated at allocation time — reproduce that by directly
@@ -206,3 +211,70 @@ func TestTURNAuthHandler_CreatePassword_ZeroExpiryRejected(t *testing.T) {
_, err := h.CreatePassword(turnTestAPIKey, pID, 0)
require.ErrorIs(t, err, ErrExpired)
}
func TestParsePeerCIDRs(t *testing.T) {
t.Run("valid entries are compiled", func(t *testing.T) {
nets, err := parsePeerCIDRs("turn.deny_peer_cidrs", []string{"203.0.113.0/24", "10.0.0.0/8"})
require.NoError(t, err)
require.Len(t, nets, 2)
require.True(t, nets[0].Contains(net.ParseIP("203.0.113.5")))
require.False(t, nets[0].Contains(net.ParseIP("203.0.114.5")))
})
t.Run("empty list is fine", func(t *testing.T) {
nets, err := parsePeerCIDRs("turn.deny_peer_cidrs", nil)
require.NoError(t, err)
require.Empty(t, nets)
})
t.Run("invalid entry is rejected with field context", func(t *testing.T) {
_, err := parsePeerCIDRs("turn.deny_peer_cidrs", []string{"203.0.113.0/33"})
require.Error(t, err)
require.Contains(t, err.Error(), "turn.deny_peer_cidrs")
require.Contains(t, err.Error(), "203.0.113.0/33")
})
}
func TestNewTurnServer_InvalidPeerCIDRFailsStartup(t *testing.T) {
for _, tc := range []struct {
name string
mutID func(c *config.Config)
field string
}{
{
name: "invalid deny cidr",
mutID: func(c *config.Config) { c.TURN.DenyPeerCIDRs = []string{"203.0.113.0/33"} },
field: "turn.deny_peer_cidrs",
},
{
name: "invalid allow cidr",
mutID: func(c *config.Config) { c.TURN.AllowRestrictedPeerCIDRs = []string{"not-a-cidr"} },
field: "turn.allow_restricted_peer_cidrs",
},
} {
t.Run(tc.name, func(t *testing.T) {
conf := &config.Config{}
conf.TURN.Enabled = true
conf.TURN.UDPPort = 3478
tc.mutID(conf)
_, err := NewTurnServer(conf, nil, false)
require.Error(t, err)
require.Contains(t, err.Error(), tc.field)
})
}
}
func TestTURNAuthHandler_CreateUsername_TTLClamped(t *testing.T) {
h := newTestTurnAuthHandler()
pID := livekit.ParticipantID("PA_ttl_clamp")
// An overflowing TTL must not wrap into a past expiry; it clamps to the max.
_, overflowExpiry := h.CreateUsername(turnTestAPIKey, pID, 1<<62+1)
require.Greater(t, overflowExpiry, time.Now().Unix())
require.LessOrEqual(t, overflowExpiry, time.Now().Unix()+int64(config.TURNMaxTTLSeconds)+1)
// A non-positive TTL falls back to the default rather than producing a past/wrapped expiry.
_, negativeExpiry := h.CreateUsername(turnTestAPIKey, pID, -1<<40)
require.InDelta(t, time.Now().Unix()+int64(config.DefaultTURNTTLSeconds), negativeExpiry, 2)
}
+3 -2
View File
@@ -1434,8 +1434,9 @@ func TestTurnAuthFailure(t *testing.T) {
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 already-expired timestamp; constructed directly
// because CreateUsername floors non-positive TTLs to the default.
expiredUsername := base62.EncodeToString(fmt.Appendf(nil, "%s|%s|%d", testApiKey, pID, time.Now().Add(-time.Minute).Unix()))
// username encoded with an api key the server does not know about.
unknownAPIKeyUsername, _ := authHandler.CreateUsername("unknown-api-key", pID, 300)