diff --git a/README.md b/README.md index f16c1144..8adb8626 100644 --- a/README.md +++ b/README.md @@ -1515,7 +1515,7 @@ Clients are expected to pass the JWT in one of the following ways (from best to 2. As password. Username is arbitrary. -3. As query parameter in the URL, with the `jwt` key. This method is discouraged since the JWT is visible and publicly shared if the URL is shared, causing a security issue. +3. As query parameter in the URL, with the `jwt` key. This method is discouraged since the JWT is publicly shared when the URL is shared, causing a security issue. These are the recommended methods for each client: diff --git a/apidocs/openapi.yaml b/apidocs/openapi.yaml index 9f2990ea..761953c9 100644 --- a/apidocs/openapi.yaml +++ b/apidocs/openapi.yaml @@ -93,6 +93,12 @@ components: type: string authJWTClaimKey: type: string + authJWTExclude: + type: array + items: + $ref: '#/components/schemas/AuthInternalUserPermission' + authJWTInHTTPQuery: + type: boolean # Control API api: diff --git a/internal/api/api.go b/internal/api/api.go index 16a112d0..6544d3f5 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -249,6 +249,7 @@ func (a *API) middlewareOrigin(ctx *gin.Context) { func (a *API) middlewareAuth(ctx *gin.Context) { req := &auth.Request{ Action: conf.AuthActionAPI, + Query: ctx.Request.URL.RawQuery, Credentials: httpp.Credentials(ctx.Request), IP: net.ParseIP(ctx.ClientIP()), } diff --git a/internal/auth/manager.go b/internal/auth/manager.go index 5869b252..8a9f9aac 100644 --- a/internal/auth/manager.go +++ b/internal/auth/manager.go @@ -27,6 +27,21 @@ const ( jwksRefreshPeriod = 60 * 60 * time.Second ) +func isHTTPRequest(r *Request) bool { + switch r.Action { + case conf.AuthActionPlayback, conf.AuthActionAPI, + conf.AuthActionMetrics, conf.AuthActionPprof: + return true + } + + switch r.Protocol { + case ProtocolHLS, ProtocolWebRTC: + return true + } + + return false +} + // Error is a authentication error. type Error struct { Wrapped error @@ -77,6 +92,7 @@ type Manager struct { JWTJWKSFingerprint string JWTClaimKey string JWTExclude []conf.AuthInternalUserPermission + JWTInHTTPQuery bool ReadTimeout time.Duration mutex sync.RWMutex @@ -219,18 +235,21 @@ func (m *Manager) authenticateJWT(req *Request) error { case req.Credentials.Pass != "": encodedJWT = req.Credentials.Pass - default: + case (!isHTTPRequest(req) || m.JWTInHTTPQuery): var v url.Values v, err = url.ParseQuery(req.Query) if err != nil { return err } - if len(v["jwt"]) != 1 { + if len(v["jwt"]) != 1 || len(v["jwt"][0]) == 0 { return fmt.Errorf("JWT not provided") } encodedJWT = v["jwt"][0] + + default: + return fmt.Errorf("JWT not provided") } var cc jwtClaims diff --git a/internal/auth/request.go b/internal/auth/request.go index eaa3a9a0..3418b477 100644 --- a/internal/auth/request.go +++ b/internal/auth/request.go @@ -21,14 +21,11 @@ const ( // Request is an authentication request. type Request struct { - Action conf.AuthAction - - // only for ActionPublish, ActionRead, ActionPlayback - Path string - Query string - Protocol Protocol - ID *uuid.UUID - + Action conf.AuthAction + Path string // only for ActionPublish, ActionRead, ActionPlayback + Query string + Protocol Protocol // only for ActionPublish, ActionRead + ID *uuid.UUID // only for ActionPublish, ActionRead Credentials *Credentials IP net.IP CustomVerifyFunc func(expectedUser string, expectedPass string) bool diff --git a/internal/conf/conf.go b/internal/conf/conf.go index 09fcf2cc..85a10fc0 100644 --- a/internal/conf/conf.go +++ b/internal/conf/conf.go @@ -181,6 +181,7 @@ type Conf struct { AuthJWTJWKSFingerprint string `json:"authJWTJWKSFingerprint"` AuthJWTClaimKey string `json:"authJWTClaimKey"` AuthJWTExclude AuthInternalUserPermissions `json:"authJWTExclude"` + AuthJWTInHTTPQuery bool `json:"authJWTInHTTPQuery"` // Control API API bool `json:"api"` @@ -335,6 +336,7 @@ func (conf *Conf) setDefaults() { } conf.AuthJWTClaimKey = "mediamtx_permissions" conf.AuthJWTExclude = []AuthInternalUserPermission{} + conf.AuthJWTInHTTPQuery = true // Control API conf.APIAddress = ":9997" diff --git a/internal/core/core.go b/internal/core/core.go index 68c49c93..18039f7a 100644 --- a/internal/core/core.go +++ b/internal/core/core.go @@ -277,6 +277,7 @@ func (p *Core) createResources(initial bool) error { JWTJWKSFingerprint: p.conf.AuthJWTJWKSFingerprint, JWTClaimKey: p.conf.AuthJWTClaimKey, JWTExclude: p.conf.AuthJWTExclude, + JWTInHTTPQuery: p.conf.AuthJWTInHTTPQuery, ReadTimeout: time.Duration(p.conf.ReadTimeout), } } @@ -637,6 +638,7 @@ func (p *Core) closeResources(newConf *conf.Conf, calledByAPI bool) { newConf.AuthJWTJWKSFingerprint != p.conf.AuthJWTJWKSFingerprint || newConf.AuthJWTClaimKey != p.conf.AuthJWTClaimKey || !reflect.DeepEqual(newConf.AuthJWTExclude, p.conf.AuthJWTExclude) || + newConf.AuthJWTInHTTPQuery != p.conf.AuthJWTInHTTPQuery || newConf.ReadTimeout != p.conf.ReadTimeout if !closeAuthManager && !reflect.DeepEqual(newConf.AuthInternalUsers, p.conf.AuthInternalUsers) { p.authManager.ReloadInternalUsers(newConf.AuthInternalUsers) diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index af87e3e3..31e27867 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -124,6 +124,7 @@ func (m *Metrics) middlewareOrigin(ctx *gin.Context) { func (m *Metrics) middlewareAuth(ctx *gin.Context) { req := &auth.Request{ Action: conf.AuthActionMetrics, + Query: ctx.Request.URL.RawQuery, Credentials: httpp.Credentials(ctx.Request), IP: net.ParseIP(ctx.ClientIP()), } diff --git a/internal/pprof/pprof.go b/internal/pprof/pprof.go index 9d555eb9..cfa4182e 100644 --- a/internal/pprof/pprof.go +++ b/internal/pprof/pprof.go @@ -99,6 +99,7 @@ func (pp *PPROF) middlewareOrigin(ctx *gin.Context) { func (pp *PPROF) middlewareAuth(ctx *gin.Context) { req := &auth.Request{ Action: conf.AuthActionPprof, + Query: ctx.Request.URL.RawQuery, Credentials: httpp.Credentials(ctx.Request), IP: net.ParseIP(ctx.ClientIP()), } diff --git a/internal/testapidocs/apidocs_test.go b/internal/testapidocs/apidocs_test.go index fe4ddc34..f4d14b13 100644 --- a/internal/testapidocs/apidocs_test.go +++ b/internal/testapidocs/apidocs_test.go @@ -14,9 +14,10 @@ import ( ) type openAPIProperty struct { - Ref string `json:"$ref"` - Type string `json:"type"` - Nullable bool `json:"nullable"` + Ref string `json:"$ref"` + Type string `json:"type"` + Nullable bool `json:"nullable"` + Items *openAPIProperty `json:"items"` } type openAPISchema struct { @@ -170,9 +171,19 @@ func TestAPIDocs(t *testing.T) { Nullable: true, } + case sf.Type == reflect.TypeOf(conf.AuthInternalUserPermissions{}): + content2.Properties[js] = openAPIProperty{ + Type: "array", + Items: &openAPIProperty{ + Ref: "#/components/schemas/AuthInternalUserPermission", + }, + } + default: if existing, ok := content1.Properties[js]; ok { content2.Properties[js] = existing + } else { + t.Errorf("missing item: '%s'", js) } } } diff --git a/mediamtx.yml b/mediamtx.yml index affd2c07..c05b4d10 100644 --- a/mediamtx.yml +++ b/mediamtx.yml @@ -119,7 +119,7 @@ authHTTPExclude: # } # ] # } -# Users are expected to pass the JWT in the Authorization header or as password. +# Users are expected to pass the JWT in the Authorization header, password or query parameter. # This is the JWKS URL that will be used to pull (once) the public key that allows # to validate JWTs. authJWTJWKS: @@ -134,6 +134,9 @@ authJWTClaimKey: mediamtx_permissions # Actions to exclude from JWT-based authentication. # Format is the same as the one of user permissions. authJWTExclude: [] +# allow passing the JWT through query parameters of HTTP requests (i.e. ?jwt=JWT). +# This is a security risk. +authJWTInHTTPQuery: true ############################################### # Global settings -> Control API