mirror of
https://github.com/livekit/livekit.git
synced 2026-11-04 09:07:37 +00:00
* service: enforce participant name length on join StartSession checked the token's metadata and attributes against the configured limits but not its name, so a participant could join with a name longer than limits.max_participant_name_length, which UpdateParticipant and the signal UpdateMetadata path reject. * service: consolidate join grant-limit checks on the validate path Extract checkGrantLimits and run it from ValidateConnectRequest and WHIPService.validateCreate so the /rtc/validate endpoint and the WHIP ingest path reject over-limit name, metadata and attributes the same way a real join does. StartSession keeps its own checks as the backstop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * service: run the full join limit check in StartSession StartSession now calls checkConnectLimits, so the RTC node checks identity, room name, participant name, metadata and attributes itself. ValidateConnectRequest runs the check after the publish suffix, so it measures the same identity StartSession sees. checkConnectLimits returns a plain error, and the HTTP callers map it to 400. The name-only StartSession test is dropped for TestConnectLimits, which gains a publish-suffix case. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * service: test the StartSession limit check directly TestStartSessionConnectLimits runs the same over-limit cases as TestConnectLimits through StartSession on the RTC node. The cases move into an exported ConnectLimitCases table so both test packages share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: ANDROIDHASSAN <84630851+ANDROIDHASSAN@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
494 lines
15 KiB
Go
494 lines
15 KiB
Go
// Copyright 2023 LiveKit, Inc.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/ua-parser/uap-go/uaparser"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/livekit/livekit-server/pkg/config"
|
|
"github.com/livekit/livekit-server/pkg/routing"
|
|
"github.com/livekit/livekit-server/pkg/routing/selector"
|
|
"github.com/livekit/livekit-server/pkg/rtc"
|
|
"github.com/livekit/livekit-server/pkg/utils"
|
|
"github.com/livekit/protocol/auth"
|
|
"github.com/livekit/protocol/livekit"
|
|
"github.com/livekit/protocol/logger"
|
|
)
|
|
|
|
var (
|
|
ErrGzipReadFailed = errors.New("cannot read decompressed data")
|
|
ErrGzipTooLarge = errors.New("decompressed data too large")
|
|
ErrRequestBodyTooLarge = errors.New("request body too large")
|
|
)
|
|
|
|
var gzipReaderPool = sync.Pool{
|
|
New: func() any { return &gzip.Reader{} },
|
|
}
|
|
|
|
func DecompressGzip(compressed []byte) ([]byte, error) {
|
|
reader := gzipReaderPool.Get().(*gzip.Reader)
|
|
defer gzipReaderPool.Put(reader)
|
|
if err := reader.Reset(bytes.NewReader(compressed)); err != nil {
|
|
return nil, fmt.Errorf("%w: %w", ErrGzipReadFailed, err)
|
|
}
|
|
|
|
out, err := io.ReadAll(io.LimitReader(reader, http.DefaultMaxHeaderBytes+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %w", ErrGzipReadFailed, err)
|
|
}
|
|
if len(out) > http.DefaultMaxHeaderBytes {
|
|
return nil, ErrGzipTooLarge
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func handleError(w http.ResponseWriter, r *http.Request, status int, err error, keysAndValues ...any) {
|
|
keysAndValues = append(keysAndValues, "status", status)
|
|
if r != nil && r.URL != nil {
|
|
keysAndValues = append(keysAndValues, "method", r.Method, "path", r.URL.Path)
|
|
}
|
|
if !errors.Is(err, context.Canceled) && !errors.Is(r.Context().Err(), context.Canceled) {
|
|
utils.GetLogger(r.Context()).WithCallDepth(1).Warnw("error handling request", err, keysAndValues...)
|
|
}
|
|
w.WriteHeader(status)
|
|
}
|
|
|
|
func HandleError(w http.ResponseWriter, r *http.Request, status int, err error, keysAndValues ...any) {
|
|
handleError(w, r, status, err, keysAndValues...)
|
|
_, _ = w.Write([]byte(err.Error()))
|
|
}
|
|
|
|
func HandleErrorJson(w http.ResponseWriter, r *http.Request, status int, err error, keysAndValues ...any) {
|
|
handleError(w, r, status, err, keysAndValues...)
|
|
json.NewEncoder(w).Encode(struct {
|
|
Error string `json:"error"`
|
|
}{
|
|
Error: err.Error(),
|
|
})
|
|
w.Header().Add("Content-type", "application/json")
|
|
}
|
|
|
|
func boolValue(s string) bool {
|
|
return s == "1" || s == "true"
|
|
}
|
|
|
|
func RemoveDoubleSlashes(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
|
// Path and RawPath must move together: once they disagree, EscapedPath()
|
|
// re-encodes from Path and every escape in the request is lost.
|
|
if strings.HasPrefix(r.URL.EscapedPath(), "//") {
|
|
r.URL.Path = r.URL.Path[1:]
|
|
if r.URL.RawPath != "" {
|
|
r.URL.RawPath = r.URL.RawPath[1:]
|
|
}
|
|
}
|
|
next(w, r)
|
|
}
|
|
|
|
// WithPathNormalization applies RemoveDoubleSlashes ahead of h, so routing and every
|
|
// middleware below it see one path form.
|
|
func WithPathNormalization(h http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
RemoveDoubleSlashes(w, r, h.ServeHTTP)
|
|
})
|
|
}
|
|
|
|
// RequestBodyLimiter bounds the size of an incoming HTTP request body so that
|
|
// large messages cannot exhaust memory. The Twirp handlers decode the whole
|
|
// body before any grant check runs, so the limit is applied here, up front.
|
|
//
|
|
// It does not decode the body itself: a request whose Content-Length exceeds
|
|
// the limit is rejected with 413, and the body is wrapped with
|
|
// http.MaxBytesReader so a missing or dishonest Content-Length is still caught
|
|
// by the downstream decoder.
|
|
type RequestBodyLimiter struct {
|
|
maxBytes int64
|
|
}
|
|
|
|
func NewRequestBodyLimiter(maxBytes int64) *RequestBodyLimiter {
|
|
return &RequestBodyLimiter{maxBytes: maxBytes}
|
|
}
|
|
|
|
func (l *RequestBodyLimiter) ServeHTTP(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) {
|
|
if l.maxBytes <= 0 || r.Body == nil {
|
|
next(w, r)
|
|
return
|
|
}
|
|
|
|
// reject early when the declared size already exceeds the limit
|
|
if r.ContentLength > l.maxBytes {
|
|
HandleError(w, r, http.StatusRequestEntityTooLarge, ErrRequestBodyTooLarge)
|
|
return
|
|
}
|
|
|
|
// bound the read in case Content-Length is missing or wrong
|
|
r.Body = http.MaxBytesReader(w, r.Body, l.maxBytes)
|
|
next(w, r)
|
|
}
|
|
|
|
func IsValidDomain(domain string) bool {
|
|
domainRegexp := regexp.MustCompile(`^(?i)[a-z0-9-]+(\.[a-z0-9-]+)+\.?$`)
|
|
return domainRegexp.MatchString(domain)
|
|
}
|
|
|
|
func GetClientIP(r *http.Request) string {
|
|
// CF proxy typically is first thing the user reaches
|
|
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
|
|
return ip
|
|
}
|
|
if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
|
|
return ip
|
|
}
|
|
if ip := r.Header.Get("X-Real-IP"); ip != "" {
|
|
return ip
|
|
}
|
|
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
|
|
return ip
|
|
}
|
|
|
|
func SetRoomConfiguration(createRequest *livekit.CreateRoomRequest, conf *livekit.RoomConfiguration) {
|
|
if conf == nil {
|
|
return
|
|
}
|
|
createRequest.Agents = conf.Agents
|
|
createRequest.Egress = conf.Egress
|
|
createRequest.EmptyTimeout = conf.EmptyTimeout
|
|
createRequest.DepartureTimeout = conf.DepartureTimeout
|
|
createRequest.MaxParticipants = conf.MaxParticipants
|
|
createRequest.MinPlayoutDelay = conf.MinPlayoutDelay
|
|
createRequest.MaxPlayoutDelay = conf.MaxPlayoutDelay
|
|
createRequest.SyncStreams = conf.SyncStreams
|
|
createRequest.Metadata = conf.Metadata
|
|
createRequest.Tags = conf.Tags
|
|
}
|
|
|
|
func ParseClientInfo(r *http.Request) *livekit.ClientInfo {
|
|
values := r.Form
|
|
ci := &livekit.ClientInfo{}
|
|
if pv, err := strconv.ParseInt(values.Get("protocol"), 10, 32); err == nil {
|
|
ci.Protocol = int32(pv)
|
|
}
|
|
if cp, err := strconv.ParseInt(values.Get("client_protocol"), 10, 32); err == nil {
|
|
ci.ClientProtocol = int32(cp)
|
|
}
|
|
sdkString := values.Get("sdk")
|
|
switch sdkString {
|
|
case "js":
|
|
ci.Sdk = livekit.ClientInfo_JS
|
|
case "ios", "swift":
|
|
ci.Sdk = livekit.ClientInfo_SWIFT
|
|
case "android":
|
|
ci.Sdk = livekit.ClientInfo_ANDROID
|
|
case "flutter":
|
|
ci.Sdk = livekit.ClientInfo_FLUTTER
|
|
case "go":
|
|
ci.Sdk = livekit.ClientInfo_GO
|
|
case "unity":
|
|
ci.Sdk = livekit.ClientInfo_UNITY
|
|
case "reactnative":
|
|
ci.Sdk = livekit.ClientInfo_REACT_NATIVE
|
|
case "rust":
|
|
ci.Sdk = livekit.ClientInfo_RUST
|
|
case "python":
|
|
ci.Sdk = livekit.ClientInfo_PYTHON
|
|
case "cpp":
|
|
ci.Sdk = livekit.ClientInfo_CPP
|
|
case "unityweb":
|
|
ci.Sdk = livekit.ClientInfo_UNITY_WEB
|
|
case "node":
|
|
ci.Sdk = livekit.ClientInfo_NODE
|
|
case "esp32":
|
|
ci.Sdk = livekit.ClientInfo_ESP32
|
|
}
|
|
|
|
ci.Version = values.Get("version")
|
|
ci.Os = values.Get("os")
|
|
ci.OsVersion = values.Get("os_version")
|
|
ci.Browser = values.Get("browser")
|
|
ci.BrowserVersion = values.Get("browser_version")
|
|
ci.DeviceModel = values.Get("device_model")
|
|
ci.Network = values.Get("network")
|
|
|
|
if capStr := values.Get("capabilities"); capStr != "" {
|
|
for _, name := range strings.Split(capStr, ",") {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if v, ok := livekit.ClientInfo_Capability_value[name]; ok {
|
|
ci.Capabilities = append(ci.Capabilities, livekit.ClientInfo_Capability(v))
|
|
}
|
|
}
|
|
}
|
|
|
|
AugmentClientInfo(ci, r)
|
|
|
|
return ci
|
|
}
|
|
|
|
var (
|
|
userAgentParserCache *uaparser.Parser
|
|
userAgentParserInit sync.Once
|
|
)
|
|
|
|
func createUserAgentParserWithCustomRules() (*uaparser.Parser, error) {
|
|
defaultYaml := uaparser.DefinitionYaml
|
|
|
|
rules := make(map[string]any)
|
|
err := yaml.Unmarshal(defaultYaml, rules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
rules["user_agent_parsers"] = append(rules["user_agent_parsers"].([]any), map[string]any{
|
|
"regex": "OBS-Studio\\/([0-9\\.]+)",
|
|
"family_replacement": "OBS Studio",
|
|
"v1_replacement": "$1",
|
|
})
|
|
|
|
customYaml, err := yaml.Marshal(rules)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return uaparser.NewFromBytes([]byte(customYaml))
|
|
}
|
|
|
|
func getUserAgentParser() *uaparser.Parser {
|
|
userAgentParserInit.Do(func() {
|
|
if parser, err := createUserAgentParserWithCustomRules(); err != nil {
|
|
logger.Warnw("could not create user agent parser with custom rules, using default", err)
|
|
userAgentParserCache = uaparser.NewFromSaved()
|
|
} else {
|
|
userAgentParserCache = parser
|
|
}
|
|
})
|
|
return userAgentParserCache
|
|
}
|
|
|
|
func AugmentClientInfo(ci *livekit.ClientInfo, req *http.Request) {
|
|
if ci == nil {
|
|
return
|
|
}
|
|
|
|
// get real address (forwarded http header) - check Cloudflare headers first, fall back to X-Forwarded-For
|
|
ci.Address = GetClientIP(req)
|
|
|
|
// attempt to parse types for SDKs that support browser as a platform
|
|
if ci.Sdk == livekit.ClientInfo_JS ||
|
|
ci.Sdk == livekit.ClientInfo_REACT_NATIVE ||
|
|
ci.Sdk == livekit.ClientInfo_FLUTTER ||
|
|
ci.Sdk == livekit.ClientInfo_UNITY ||
|
|
ci.Sdk == livekit.ClientInfo_UNKNOWN {
|
|
client := getUserAgentParser().Parse(req.UserAgent())
|
|
if ci.Browser == "" {
|
|
ci.Browser = client.UserAgent.Family
|
|
ci.BrowserVersion = client.UserAgent.ToVersionString()
|
|
}
|
|
if ci.Os == "" {
|
|
ci.Os = client.Os.Family
|
|
ci.OsVersion = client.Os.ToVersionString()
|
|
}
|
|
if ci.DeviceModel == "" {
|
|
model := client.Device.Family
|
|
if model != "" && client.Device.Model != "" && model != client.Device.Model {
|
|
model += " " + client.Device.Model
|
|
}
|
|
|
|
ci.DeviceModel = model
|
|
}
|
|
}
|
|
}
|
|
|
|
type ValidateConnectRequestParams struct {
|
|
roomName livekit.RoomName
|
|
publish string
|
|
metadata string
|
|
attributes map[string]string
|
|
}
|
|
|
|
type ValidateConnectRequestResult struct {
|
|
roomName livekit.RoomName
|
|
grants *auth.ClaimGrants
|
|
tokenExpiresAt time.Time
|
|
region string
|
|
createRoomRequest *livekit.CreateRoomRequest
|
|
}
|
|
|
|
func ValidateConnectRequest(
|
|
lgr logger.Logger,
|
|
r *http.Request,
|
|
limitConfig config.LimitConfig,
|
|
params ValidateConnectRequestParams,
|
|
router routing.MessageRouter,
|
|
roomAllocator RoomAllocator,
|
|
) (ValidateConnectRequestResult, int, error) {
|
|
var res ValidateConnectRequestResult
|
|
|
|
// require a claim
|
|
claims := GetGrants(r.Context())
|
|
if claims == nil || claims.Video == nil {
|
|
return res, http.StatusUnauthorized, rtc.ErrPermissionDenied
|
|
}
|
|
|
|
roomNameInToken, err := EnsureJoinPermission(r.Context())
|
|
if err != nil {
|
|
return res, http.StatusUnauthorized, err
|
|
}
|
|
|
|
if claims.Identity == "" {
|
|
return res, http.StatusBadRequest, ErrIdentityEmpty
|
|
}
|
|
|
|
if claims.RoomConfig != nil {
|
|
if err := claims.RoomConfig.CheckCredentials(); err != nil {
|
|
lgr.Warnw("credentials found in token", nil)
|
|
// TODO(dz): in a future version, we'll reject these connections
|
|
}
|
|
}
|
|
|
|
res.roomName = params.roomName
|
|
if roomNameInToken != "" {
|
|
res.roomName = roomNameInToken
|
|
}
|
|
if res.roomName == "" {
|
|
return res, http.StatusBadRequest, ErrNoRoomName
|
|
}
|
|
|
|
if len(params.metadata) != 0 {
|
|
// Make sure grant has GetCanUpdateOwnMetadata set
|
|
if !claims.Video.GetCanUpdateOwnMetadata() {
|
|
return res, http.StatusUnauthorized, rtc.ErrPermissionDenied
|
|
}
|
|
claims.Metadata = params.metadata
|
|
}
|
|
|
|
// Add extra attributes to the participant
|
|
if len(params.attributes) != 0 {
|
|
// Make sure grant has GetCanUpdateOwnMetadata set
|
|
if !claims.Video.GetCanUpdateOwnMetadata() {
|
|
return res, http.StatusUnauthorized, rtc.ErrPermissionDenied
|
|
}
|
|
if claims.Attributes == nil {
|
|
claims.Attributes = make(map[string]string, len(params.attributes))
|
|
}
|
|
for k, v := range params.attributes {
|
|
if v == "" {
|
|
continue // do not allow deleting existing attributes
|
|
}
|
|
claims.Attributes[k] = v
|
|
}
|
|
}
|
|
|
|
// this is new connection for existing participant - with publish only permissions
|
|
if params.publish != "" {
|
|
// Make sure grant has GetCanPublish set,
|
|
if !claims.Video.GetCanPublish() {
|
|
return res, http.StatusUnauthorized, rtc.ErrPermissionDenied
|
|
}
|
|
// Make sure by default subscribe is off
|
|
claims.Video.SetCanSubscribe(false)
|
|
claims.Identity += "#" + params.publish
|
|
}
|
|
|
|
// after the publish suffix, so the identity checked is the one StartSession sees
|
|
if err := checkConnectLimits(limitConfig, claims.Identity, res.roomName, claims); err != nil {
|
|
return res, http.StatusBadRequest, err
|
|
}
|
|
|
|
// room allocator validations
|
|
err = roomAllocator.ValidateCreateRoom(r.Context(), res.roomName)
|
|
if err != nil {
|
|
if errors.Is(err, ErrRoomNotFound) {
|
|
return res, http.StatusNotFound, err
|
|
} else {
|
|
return res, http.StatusInternalServerError, err
|
|
}
|
|
}
|
|
|
|
if router, ok := router.(routing.Router); ok {
|
|
res.region = router.GetRegion()
|
|
if foundNode, err := router.GetNodeForRoom(r.Context(), res.roomName); err == nil {
|
|
if selector.LimitsReached(limitConfig, foundNode.Stats) {
|
|
return res, http.StatusServiceUnavailable, rtc.ErrLimitExceeded
|
|
}
|
|
}
|
|
}
|
|
|
|
createRequest := &livekit.CreateRoomRequest{
|
|
Name: string(res.roomName),
|
|
RoomPreset: claims.RoomPreset,
|
|
}
|
|
SetRoomConfiguration(createRequest, claims.GetRoomConfiguration())
|
|
res.createRoomRequest = createRequest
|
|
|
|
res.grants = claims
|
|
res.tokenExpiresAt = GetTokenExpiresAt(r.Context())
|
|
return res, http.StatusOK, nil
|
|
}
|
|
|
|
// checkConnectLimits checks a join's identity, room name, participant name, metadata and
|
|
// attributes against the configured limits. Every join path runs it: the signal connection,
|
|
// WHIP, and StartSession on the RTC node, which does not trust the caller's validation.
|
|
func checkConnectLimits(limitConfig config.LimitConfig, identity string, roomName livekit.RoomName, grants *auth.ClaimGrants) error {
|
|
if !limitConfig.CheckParticipantIdentityLength(identity) {
|
|
return fmt.Errorf("%w: max length %d", ErrParticipantIdentityExceedsLimits, limitConfig.MaxParticipantIdentityLength)
|
|
}
|
|
if !limitConfig.CheckRoomNameLength(string(roomName)) {
|
|
return fmt.Errorf("%w: max length %d", ErrRoomNameExceedsLimits, limitConfig.MaxRoomNameLength)
|
|
}
|
|
if !limitConfig.CheckParticipantNameLength(grants.Name) {
|
|
return fmt.Errorf("%w: max length %d", ErrNameExceedsLimits, limitConfig.MaxParticipantNameLength)
|
|
}
|
|
if !limitConfig.CheckMetadataSize(grants.Metadata) {
|
|
return fmt.Errorf("%w: max size %d", ErrMetadataExceedsLimits, limitConfig.MaxMetadataSize)
|
|
}
|
|
if !limitConfig.CheckAttributesSize(grants.Attributes) {
|
|
return fmt.Errorf("%w: max size %d", ErrAttributeExceedsLimits, limitConfig.MaxAttributesSize)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func IsRTCPath(path string) bool {
|
|
return path == "/rtc" || path == "/rtc/v1"
|
|
}
|
|
|
|
func IsRTCValidatePath(path string) bool {
|
|
return path == "/rtc/validate" || path == "/rtc/v1/validate"
|
|
}
|
|
|
|
func IsAgentWorkerPath(path string) bool {
|
|
return path == "/agent"
|
|
}
|
|
|
|
func IsAgentPath(path string) bool {
|
|
return strings.HasPrefix(path, "/agent")
|
|
}
|