Files
upstream-mattermost/server/public/model/oauth_dcr.go
upstream-mirror 240db4e2a7
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
2026-09-16 00:29:55 +05:00

264 lines
8.0 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"net/http"
"net/url"
"strings"
)
type ClientRegistrationRequest struct {
RedirectURIs []string `json:"redirect_uris"`
TokenEndpointAuthMethod *string `json:"token_endpoint_auth_method,omitempty"`
ClientName *string `json:"client_name,omitempty"`
ClientURI *string `json:"client_uri,omitempty"`
}
type ClientRegistrationResponse struct {
ClientID string `json:"client_id"`
ClientSecret *string `json:"client_secret,omitempty"`
RedirectURIs []string `json:"redirect_uris"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
GrantTypes []string `json:"grant_types"`
ResponseTypes []string `json:"response_types"`
Scope string `json:"scope,omitempty"`
ClientName *string `json:"client_name,omitempty"`
ClientURI *string `json:"client_uri,omitempty"`
}
const (
DCRErrorInvalidRedirectURI = "invalid_redirect_uri"
DCRErrorInvalidClientMetadata = "invalid_client_metadata"
DCRErrorUnsupportedOperation = "unsupported_operation"
)
type DCRError struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
type dcrRedirectURIPattern struct {
scheme string
host string
path string
rawQuery string
}
func (r *ClientRegistrationRequest) IsValid() *AppError {
if len(r.RedirectURIs) == 0 {
return NewAppError("ClientRegistrationRequest.IsValid", "model.dcr.is_valid.redirect_uris.app_error", nil, "", http.StatusBadRequest)
}
for _, uri := range r.RedirectURIs {
if !IsValidDCRRedirectURI(uri) {
return NewAppError("ClientRegistrationRequest.IsValid", "model.dcr.is_valid.redirect_uri_format.app_error", nil, "uri="+uri, http.StatusBadRequest)
}
}
if r.ClientName != nil && len(*r.ClientName) > 64 {
return NewAppError("ClientRegistrationRequest.IsValid", "model.dcr.is_valid.client_name.app_error", nil, "", http.StatusBadRequest)
}
if r.ClientURI != nil {
if !IsValidHTTPURL(*r.ClientURI) {
return NewAppError("ClientRegistrationRequest.IsValid", "model.dcr.is_valid.client_uri_format.app_error", nil, "uri="+*r.ClientURI, http.StatusBadRequest)
}
if len(*r.ClientURI) > 256 {
return NewAppError("ClientRegistrationRequest.IsValid", "model.dcr.is_valid.client_uri_length.app_error", nil, "", http.StatusBadRequest)
}
}
if r.TokenEndpointAuthMethod != nil && *r.TokenEndpointAuthMethod != ClientAuthMethodClientSecretPost && *r.TokenEndpointAuthMethod != ClientAuthMethodNone {
return NewAppError("ClientRegistrationRequest.IsValid", "model.dcr.is_valid.unsupported_auth_method.app_error", nil, "method="+*r.TokenEndpointAuthMethod, http.StatusBadRequest)
}
return nil
}
func NewDCRError(errorType, description string) *DCRError {
return &DCRError{
Error: errorType,
ErrorDescription: description,
}
}
func GetDefaultGrantTypes() []string {
return []string{GrantTypeAuthorizationCode, GrantTypeRefreshToken}
}
func GetDefaultResponseTypes() []string {
return []string{ResponseTypeCode}
}
// IsValidDCRRedirectURI validates a concrete DCR redirect URI. Unlike
// IsValidHTTPURL, it accepts custom (non-HTTP) schemes so that desktop OAuth
// clients can use their own URI schemes (e.g. cursor://anysphere.cursor-mcp/oauth/callback).
// The URI must be absolute with both a scheme and a host. Dangerous schemes
// (javascript, data, vbscript, file, blob, about) are rejected even in
// authority form (e.g. "javascript://evil.example.com/x").
func IsValidDCRRedirectURI(rawURL string) bool {
u, err := url.ParseRequestURI(rawURL)
if err != nil || u.Scheme == "" || u.Host == "" {
return false
}
switch strings.ToLower(u.Scheme) {
case "javascript", "data", "vbscript", "file", "blob", "about":
return false
}
return true
}
// IsValidDCRRedirectURIPattern validates a DCR redirect URI allowlist pattern.
// Patterns must be absolute URIs with a scheme and host and be well-formed for
// glob matching. Custom schemes (e.g. cursor://) are permitted in addition to
// http:// and https://.
func IsValidDCRRedirectURIPattern(pattern string) bool {
// Reject control characters and other invalid chars
for _, r := range pattern {
if r < 0x20 || r == 0x7f {
return false
}
}
// Reject malformed wildcard runs. Supported tokens are "*" and "**".
if strings.Contains(pattern, "***") {
return false
}
// Replace wildcard tokens with concrete placeholders so URL parsing can validate
// overall shape (scheme, host, and URI formatting).
normalized := strings.ReplaceAll(pattern, "**", "mmdoublewildcard")
normalized = strings.ReplaceAll(normalized, "*", "mmsinglewildcard")
// Use a numeric placeholder so wildcarded port values (e.g. localhost:*)
// normalize to a URI shape accepted by URL parsing (localhost:1).
normalized = strings.ReplaceAll(normalized, "mmdoublewildcard", "1")
normalized = strings.ReplaceAll(normalized, "mmsinglewildcard", "1")
return IsValidDCRRedirectURI(normalized)
}
// RedirectURIMatchesGlob returns true if uri matches the glob pattern.
// Matching is URL-component aware, so host, path, and query wildcards cannot
// satisfy requirements from another component.
func RedirectURIMatchesGlob(uri, pattern string) bool {
candidate, err := url.ParseRequestURI(uri)
if err != nil || candidate.Scheme == "" || candidate.Host == "" {
return false
}
if !IsValidDCRRedirectURIPattern(pattern) {
return false
}
parsedPattern, ok := parseDCRRedirectURIPattern(pattern)
if !ok {
return false
}
if candidate.Scheme != parsedPattern.scheme {
return false
}
if !redirectURIMatchesGlobRecur(candidate.Host, parsedPattern.host, 0, 0) {
return false
}
if !redirectURIMatchesGlobRecur(candidate.EscapedPath(), parsedPattern.path, 0, 0) {
return false
}
if parsedPattern.rawQuery == "" {
return candidate.RawQuery == ""
}
if candidate.RawQuery == "" {
return false
}
return redirectURIMatchesGlobRecur(candidate.RawQuery, parsedPattern.rawQuery, 0, 0)
}
func parseDCRRedirectURIPattern(pattern string) (dcrRedirectURIPattern, bool) {
scheme, rest, ok := strings.Cut(pattern, "://")
if !ok {
return dcrRedirectURIPattern{}, false
}
hostEnd := len(rest)
for _, separator := range []string{"/", "?"} {
if i := strings.Index(rest, separator); i >= 0 && i < hostEnd {
hostEnd = i
}
}
host := rest[:hostEnd]
if host == "" {
return dcrRedirectURIPattern{}, false
}
remainder := rest[hostEnd:]
path := ""
rawQuery := ""
if strings.HasPrefix(remainder, "/") {
path, rawQuery, _ = strings.Cut(remainder, "?")
} else if strings.HasPrefix(remainder, "?") {
rawQuery = remainder[1:]
}
return dcrRedirectURIPattern{
scheme: scheme,
host: host,
path: path,
rawQuery: rawQuery,
}, true
}
func redirectURIMatchesGlobRecur(uri, pattern string, ui, pi int) bool {
for pi < len(pattern) {
if pattern[pi] == '*' {
if pi+1 < len(pattern) && pattern[pi+1] == '*' {
// ** matches any chars including /
pi += 2
if pi >= len(pattern) {
return true
}
for ui <= len(uri) {
if redirectURIMatchesGlobRecur(uri, pattern, ui, pi) {
return true
}
ui++
}
return false
}
// * matches zero or more chars except /
if redirectURIMatchesGlobRecur(uri, pattern, ui, pi+1) {
return true
}
for ui < len(uri) && uri[ui] != '/' {
ui++
if redirectURIMatchesGlobRecur(uri, pattern, ui, pi+1) {
return true
}
}
return false
}
if ui >= len(uri) || uri[ui] != pattern[pi] {
return false
}
ui++
pi++
}
return ui == len(uri)
}
// RedirectURIMatchesAllowlist returns true if uri matches at least one pattern in allowlist.
// If allowlist is empty, returns true (no restriction).
func RedirectURIMatchesAllowlist(uri string, allowlist []string) bool {
if len(allowlist) == 0 {
return true
}
for _, p := range allowlist {
trimmed := strings.TrimSpace(p)
if trimmed != "" && RedirectURIMatchesGlob(uri, trimmed) {
return true
}
}
return false
}