mattermost/server/public/model/oauth_dcr_test.go
Nick Misasi 5d3a04760b
[MM-67605] Add DCR redirect URI allowlist for OAuth DCR (#35291)
* [MM-67605] Add DCR redirect URI allowlist enforcement

Introduce ServiceSettings.DCRRedirectURIAllowlist with glob-based validation and enforce it during OAuth dynamic client registration to block unapproved redirect URIs. Add System Console wiring and tests for config validation, wildcard matching semantics, API error behavior, and localhost wildcard support.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix pre-commit checks: TypeScript type assertion, gofmt, and regenerate CI artifacts

- admin_definition_dcr_allowlist.test.tsx: Add AdminDefinitionSettingInput type assertion for 'multiple' property
- oauth_dcr_test.go: Fix comment spacing (gofmt)
- Regenerate mocks, go.sum, gen-serialized, mmctl-docs per CI requirements

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert unnecessary pre-commit regenerations

Revert mmctl docs, mocks, go.sum, and gen-serialized to master. Keep only
the TypeScript and gofmt fixes from the previous commit.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix import order in admin_definition_dcr_allowlist.test.tsx

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix i18n

* Update server/public/model/oauth_dcr.go

Co-authored-by: Eva Sarafianou <eva.sarafianou@gmail.com>

* Fix

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Eva Sarafianou <eva.sarafianou@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
2026-02-19 01:06:08 +00:00

150 lines
5.9 KiB
Go

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestClientRegistrationRequestIsValid(t *testing.T) {
t.Run("PublicClient_Valid", func(t *testing.T) {
req := &ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
TokenEndpointAuthMethod: NewPointer(ClientAuthMethodNone),
ClientName: NewPointer("Test Public Client"),
}
require.Nil(t, req.IsValid())
})
t.Run("PublicClient_AuthMethodValidation", func(t *testing.T) {
req := &ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
TokenEndpointAuthMethod: NewPointer(ClientAuthMethodNone),
ClientName: NewPointer("Test Public Client"),
}
require.Nil(t, req.IsValid())
req.TokenEndpointAuthMethod = NewPointer("invalid_method")
require.NotNil(t, req.IsValid())
})
t.Run("PublicClient_RedirectURIValidation", func(t *testing.T) {
req := &ClientRegistrationRequest{
TokenEndpointAuthMethod: NewPointer(ClientAuthMethodNone),
ClientName: NewPointer("Test Public Client"),
}
require.NotNil(t, req.IsValid())
req.RedirectURIs = []string{"https://example.com/callback"}
require.Nil(t, req.IsValid())
req.RedirectURIs = []string{"http://localhost:3000/callback"}
require.Nil(t, req.IsValid())
req.RedirectURIs = []string{"invalid-uri"}
require.NotNil(t, req.IsValid())
})
}
func TestNewOAuthAppFromClientRegistration(t *testing.T) {
t.Run("PublicClient", func(t *testing.T) {
req := &ClientRegistrationRequest{
RedirectURIs: []string{"https://example.com/callback"},
TokenEndpointAuthMethod: NewPointer(ClientAuthMethodNone),
ClientName: NewPointer("Test Public Client"),
}
creatorId := NewId()
app := NewOAuthAppFromClientRegistration(req, creatorId)
require.Equal(t, creatorId, app.CreatorId)
require.Equal(t, req.RedirectURIs, []string(app.CallbackUrls))
require.Equal(t, *req.TokenEndpointAuthMethod, app.GetTokenEndpointAuthMethod())
require.Equal(t, *req.ClientName, app.Name)
require.True(t, app.IsDynamicallyRegistered)
app.PreSave()
require.Nil(t, app.IsValid())
require.Empty(t, app.ClientSecret)
})
}
func TestRedirectURIMatchesGlob(t *testing.T) {
t.Run("direct match", func(t *testing.T) {
require.True(t, RedirectURIMatchesGlob("https://example.com/cb", "https://example.com/cb"))
require.False(t, RedirectURIMatchesGlob("https://example.com/cb", "https://example.com/cb2"))
require.False(t, RedirectURIMatchesGlob("https://example.com/cb2", "https://example.com/cb"))
})
t.Run("full-string anchored", func(t *testing.T) {
require.False(t, RedirectURIMatchesGlob("https://example.com/cb/evil", "https://example.com/cb"))
require.False(t, RedirectURIMatchesGlob("https://evil.example.com/cb", "https://example.com/cb"))
})
t.Run("single star matches non-slash chars", func(t *testing.T) {
require.True(t, RedirectURIMatchesGlob("https://example.com/cb", "https://example.com/*"))
require.True(t, RedirectURIMatchesGlob("https://example.com/segment", "https://example.com/*"))
require.False(t, RedirectURIMatchesGlob("https://example.com/a/b", "https://example.com/*"))
require.True(t, RedirectURIMatchesGlob("https://example.com/", "https://example.com/*"))
})
t.Run("double star matches including slash", func(t *testing.T) {
require.True(t, RedirectURIMatchesGlob("https://example.com/a/b/c", "https://example.com/**"))
require.True(t, RedirectURIMatchesGlob("https://example.com/callback", "https://example.com/**"))
require.True(t, RedirectURIMatchesGlob("https://example.com/", "https://example.com/**"))
require.False(t, RedirectURIMatchesGlob("https://evil.example.com/", "https://example.com/**"))
})
t.Run("host wildcard", func(t *testing.T) {
require.True(t, RedirectURIMatchesGlob("https://app.example.com/cb", "https://*.example.com/cb"))
require.True(t, RedirectURIMatchesGlob("https://foo.example.com/path", "https://*.example.com/*"))
require.False(t, RedirectURIMatchesGlob("https://example.com.evil/cb", "https://*.example.com/cb"))
})
t.Run("port wildcard", func(t *testing.T) {
require.True(t, RedirectURIMatchesGlob("https://localhost:3000/cb", "https://localhost:*/cb"))
require.False(t, RedirectURIMatchesGlob("https://localhost:3000/cb", "https://localhost:8080/cb"))
})
t.Run("multiple patterns one match suffices", func(t *testing.T) {
allowlist := []string{"https://a.com/**", "https://b.com/**"}
require.True(t, RedirectURIMatchesAllowlist("https://a.com/x", allowlist))
require.True(t, RedirectURIMatchesAllowlist("https://b.com/y", allowlist))
require.False(t, RedirectURIMatchesAllowlist("https://c.com/z", allowlist))
})
t.Run("empty allowlist permits all", func(t *testing.T) {
require.True(t, RedirectURIMatchesAllowlist("https://any.com/cb", []string{}))
})
t.Run("one bad URI rejects request", func(t *testing.T) {
allowlist := []string{"https://allowed.com/**"}
uris := []string{"https://allowed.com/cb1", "https://disallowed.com/cb2"}
allMatch := true
for _, uri := range uris {
if !RedirectURIMatchesAllowlist(uri, allowlist) {
allMatch = false
break
}
}
require.False(t, allMatch)
})
}
func TestIsValidDCRRedirectURIPattern(t *testing.T) {
require.True(t, IsValidDCRRedirectURIPattern("https://example.com/**"))
require.True(t, IsValidDCRRedirectURIPattern("http://localhost:3000/cb"))
require.True(t, IsValidDCRRedirectURIPattern("http://localhost:*"))
require.True(t, IsValidDCRRedirectURIPattern("http://x")) // minimum valid http URL (8 chars)
require.True(t, IsValidDCRRedirectURIPattern("https://x")) // minimum valid https URL (9 chars)
require.False(t, IsValidDCRRedirectURIPattern("https://"))
require.False(t, IsValidDCRRedirectURIPattern("ftp://example.com"))
require.False(t, IsValidDCRRedirectURIPattern("https://example.com/***"))
}