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
147 lines
4.5 KiB
Go
147 lines
4.5 KiB
Go
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
package model
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func testSamlRelayStateKey(t *testing.T) []byte {
|
|
t.Helper()
|
|
key := make([]byte, 32)
|
|
_, err := rand.Read(key)
|
|
require.NoError(t, err)
|
|
return key
|
|
}
|
|
|
|
func TestSignAndVerifySamlRelayStateRoundTrip(t *testing.T) {
|
|
key := testSamlRelayStateKey(t)
|
|
relayProps := map[string]string{
|
|
"action": OAuthActionSignup,
|
|
"invite_id": "some-invite-id",
|
|
}
|
|
|
|
relayState := SignSamlRelayState(key, relayProps)
|
|
require.NotEmpty(t, relayState)
|
|
|
|
got, err := VerifySamlRelayState(key, relayState)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, OAuthActionSignup, got["action"])
|
|
assert.Equal(t, "some-invite-id", got["invite_id"])
|
|
// The expiry field is internal bookkeeping and must not leak into the caller's relayProps.
|
|
_, hasExp := got["exp"]
|
|
assert.False(t, hasExp)
|
|
}
|
|
|
|
func TestSignSamlRelayStateDoesNotMutateInput(t *testing.T) {
|
|
key := testSamlRelayStateKey(t)
|
|
relayProps := map[string]string{"action": OAuthActionLogin}
|
|
|
|
SignSamlRelayState(key, relayProps)
|
|
|
|
assert.Equal(t, map[string]string{"action": OAuthActionLogin}, relayProps)
|
|
}
|
|
|
|
func TestVerifySamlRelayStateRejectsTamperedPayload(t *testing.T) {
|
|
key := testSamlRelayStateKey(t)
|
|
relayState := SignSamlRelayState(key, map[string]string{"action": OAuthActionSignup, "team_id": "legit-team"})
|
|
|
|
payloadPart, sigPart, ok := strings.Cut(relayState, ".")
|
|
require.True(t, ok)
|
|
|
|
payload, err := base64.RawURLEncoding.DecodeString(payloadPart)
|
|
require.NoError(t, err)
|
|
|
|
tampered := strings.Replace(string(payload), "legit-team", "forged-team", 1)
|
|
require.NotEqual(t, string(payload), tampered, "test payload must actually contain the substring being tampered")
|
|
|
|
forgedRelayState := base64.RawURLEncoding.EncodeToString([]byte(tampered)) + "." + sigPart
|
|
|
|
_, err = VerifySamlRelayState(key, forgedRelayState)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestVerifySamlRelayStateRejectsTamperedSignature(t *testing.T) {
|
|
key := testSamlRelayStateKey(t)
|
|
relayState := SignSamlRelayState(key, map[string]string{"action": OAuthActionSignup})
|
|
|
|
payloadPart, sigPart, ok := strings.Cut(relayState, ".")
|
|
require.True(t, ok)
|
|
|
|
sig, err := base64.RawURLEncoding.DecodeString(sigPart)
|
|
require.NoError(t, err)
|
|
sig[0] ^= 0xFF
|
|
forgedRelayState := payloadPart + "." + base64.RawURLEncoding.EncodeToString(sig)
|
|
|
|
_, err = VerifySamlRelayState(key, forgedRelayState)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestVerifySamlRelayStateRejectsWrongKey(t *testing.T) {
|
|
signingKey := testSamlRelayStateKey(t)
|
|
verifyingKey := testSamlRelayStateKey(t)
|
|
relayState := SignSamlRelayState(signingKey, map[string]string{"action": OAuthActionSignup})
|
|
|
|
_, err := VerifySamlRelayState(verifyingKey, relayState)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestVerifySamlRelayStateRejectsMalformedInput(t *testing.T) {
|
|
key := testSamlRelayStateKey(t)
|
|
|
|
testCases := []string{
|
|
"",
|
|
"not-a-real-relay-state",
|
|
"missing-separator-entirely",
|
|
"!!!invalid-base64!!!." + base64.RawURLEncoding.EncodeToString([]byte("sig")),
|
|
base64.RawURLEncoding.EncodeToString([]byte("{}")) + ".!!!invalid-base64!!!",
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
_, err := VerifySamlRelayState(key, tc)
|
|
assert.Error(t, err, "expected rejection for input: %q", tc)
|
|
}
|
|
}
|
|
|
|
func TestVerifySamlRelayStateRejectsExpired(t *testing.T) {
|
|
key := testSamlRelayStateKey(t)
|
|
|
|
// Hand-construct a relay state with a backdated expiry, using the same wire format as
|
|
// SignSamlRelayState, since that function always embeds a fresh (non-expired) expiry.
|
|
expiredProps := map[string]string{
|
|
"action": OAuthActionSignup,
|
|
"exp": strconv.FormatInt(GetMillis()-1000, 10),
|
|
}
|
|
payload := []byte(MapToJSON(expiredProps))
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write(payload)
|
|
relayState := base64.RawURLEncoding.EncodeToString(payload) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
|
|
_, err := VerifySamlRelayState(key, relayState)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestVerifySamlRelayStateRejectsLegacyBase64Blob(t *testing.T) {
|
|
key := testSamlRelayStateKey(t)
|
|
|
|
// The pre-fix RelayState format: plain base64(JSON), no signature at all. Confirms the
|
|
// old wire format (and thus the MM-69889 forged team_id/invite_id attack shape) is rejected.
|
|
legacy := base64.StdEncoding.EncodeToString([]byte(MapToJSON(map[string]string{
|
|
"action": OAuthActionSignup,
|
|
"team_id": "forged-team-id",
|
|
})))
|
|
|
|
_, err := VerifySamlRelayState(key, legacy)
|
|
assert.Error(t, err)
|
|
}
|