Files
upstream-ory-kratos/selfservice/strategy/password/validator_test.go
upstream-mirror c60cc7ca75
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped
snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
2026-09-15 21:19:12 +05:00

470 lines
17 KiB
Go

// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package password_test
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha1" //#nosec G505 -- compatibility for imported passwords
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/hashicorp/go-retryablehttp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
"github.com/ory/kratos/driver"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/selfservice/strategy/password"
"github.com/ory/kratos/text"
"github.com/ory/x/configx"
"github.com/ory/x/contextx"
"github.com/ory/x/httpx"
)
// testRegistry embeds RegistryDefault and overrides HTTPClient to inject a
// controllable HTTP client for HIBP-related tests.
type testRegistry struct {
*driver.RegistryDefault
cl *retryablehttp.Client
}
func (r *testRegistry) HTTPClient(_ context.Context, _ ...httpx.ResilientOptions) *retryablehttp.Client {
return r.cl
}
// newTestValidator wires base with a fake HIBP HTTP client and returns the
// validator along with the fake client for request assertions and stubbing.
func newTestValidator(t *testing.T, base *driver.RegistryDefault, opts ...httpx.ResilientOptions) (*password.DefaultPasswordValidator, *fakeHttpClient) {
t.Helper()
fakeClient := NewFakeHTTPClient()
cl := httpx.NewResilientClient(append([]httpx.ResilientOptions{
httpx.ResilientClientWithMaxRetry(1),
httpx.ResilientClientWithConnectionTimeout(time.Millisecond),
}, opts...)...)
cl.HTTPClient = &fakeClient.Client
s, err := password.NewDefaultPasswordValidatorStrategy(&testRegistry{RegistryDefault: base, cl: cl})
require.NoError(t, err)
return s, fakeClient
}
func TestDefaultPasswordValidationStrategy(t *testing.T) {
t.Parallel()
// Tests are based on:
// - https://www.troyhunt.com/passwords-evolved-authentication-guidance-for-the-modern-era/
// - https://www.microsoft.com/en-us/research/wp-content/uploads/2016/06/Microsoft_Password_Guidance-1.pdf
t.Run("default strategy", func(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t)
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
require.NoError(t, err)
for k, tc := range []struct {
id, pw string
expectedErr error
}{
{pw: "", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
{pw: "12", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
{pw: "1234", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
{pw: "123456", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
{pw: "12345678", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
{pw: "password", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
{pw: "1234567890", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
{pw: "qwertyui", expectedErr: text.NewErrorValidationPasswordTooManyBreaches(0)},
{pw: "l3f9to", expectedErr: text.NewErrorValidationPasswordMinLength(0, 0)},
{pw: "l3f9toh1uaf81n21"},
{pw: "l3f9toh1uaf81n21", id: "l3f9toh1uaf81n21", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{pw: "l3f9toh1"},
{pw: "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"},
// simple permutation tests
{id: "hello@example.com", pw: "hello@example.com12345", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "hello@example.com", pw: "123hello@example.com123", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "hello@example.com", pw: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "hello@example.com", pw: "HELLO@EXAMPLE.COM", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "hello@example.com", pw: "HELLO@example.com", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{pw: "hello@example.com", id: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "hello@example.com", pw: "h3ll0@example", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{pw: "hello@example.com", id: "hello@exam", expectedErr: text.NewErrorValidationPasswordIdentifierTooSimilar()},
{id: "abcd", pw: "9d3c8a1b"},
{id: "a", pw: "kjOklafe"},
{id: "ab", pw: "0000ab0000123"},
// longest common substring with long password
{id: "d4f6090b-5a84", pw: "d4f6090b-5a84-2184-4404-8d1b-8da3eb00ebbe"},
{id: "asdflasdflasdf", pw: "asdflasdflpiuhefnciluaksdzuföfhg"},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
t.Parallel()
err := s.Validate(t.Context(), tc.id, tc.pw)
if tc.expectedErr == nil {
require.NoErrorf(t, err, "id: %s, pw: %s", tc.id, tc.pw)
} else {
require.ErrorIsf(t, err, tc.expectedErr, "id: %s, pw: %s", tc.id, tc.pw)
}
})
}
})
t.Run("failure cases", func(t *testing.T) {
t.Parallel()
_, base := pkg.NewFastRegistryWithMocks(t)
s, fakeClient := newTestValidator(t, base, httpx.ResilientClientWithMaxRetryWait(time.Millisecond))
t.Run("case=should send request to pwnedpasswords.com", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
require.Error(t, s.Validate(ctx, "mohutdesub", "damrumukuh"))
require.Contains(t, fakeClient.RequestedURLs(), "https://api.pwnedpasswords.com/range/BCBA9")
})
t.Run("case=should fail if request fails and ignoreNetworkErrors is not set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
fakeClient.RespondWithError("Network request failed")
require.Error(t, s.Validate(ctx, "", "sumdarmetp"))
})
t.Run("case=should not fail if request fails and ignoreNetworkErrors is set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
fakeClient.RespondWithError("Network request failed")
require.NoError(t, s.Validate(ctx, "", "pepegtawni"))
})
t.Run("case=should fail if response has non 200 code and ignoreNetworkErrors is not set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
fakeClient.RespondWith(http.StatusForbidden, "")
require.Error(t, s.Validate(ctx, "", "jolhakowef"))
})
t.Run("case=should not fail if response has non 200 code code and ignoreNetworkErrors is set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
fakeClient.RespondWith(http.StatusInternalServerError, "")
require.NoError(t, s.Validate(ctx, "", "jenuzuhjoj"))
})
t.Run("case=should fail with a network error if reading the response body fails and ignoreNetworkErrors is not set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
fakeClient.RespondWithBodyError("003D68EB55068C33ACE09247EE4C639306B:3\n", errBodyRead)
require.ErrorIs(t, s.Validate(ctx, "", "vebjihwoct"), password.ErrNetworkFailure)
})
t.Run("case=should not fail if reading the response body fails and ignoreNetworkErrors is set", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, true)
fakeClient.RespondWithBodyError("003D68EB55068C33ACE09247EE4C639306B:3\n", errBodyRead)
require.NoError(t, s.Validate(ctx, "", "hufzeqmalt"))
})
})
t.Run("max breaches", func(t *testing.T) {
t.Parallel()
const maxBreaches = 5
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordMaxBreaches, maxBreaches))
s, fakeClient := newTestValidator(t, base)
hibpResp := make(chan string, 1)
fakeClient.responder = func(req *http.Request) (*http.Response, error) {
buffer := bytes.NewBufferString(<-hibpResp)
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(buffer),
ContentLength: int64(buffer.Len()),
Request: req,
}, nil
}
hashPw := func(t *testing.T, pw string) string {
//#nosec G401 -- sha1 is used for k-anonymity
h := sha1.New()
_, err := h.Write([]byte(pw))
require.NoError(t, err)
hpw := h.Sum(nil)
return fmt.Sprintf("%X", hpw)[5:]
}
randomPassword := func(t *testing.T) string {
pw := make([]byte, 10)
_, err := rand.Read(pw)
require.NoError(t, err)
return fmt.Sprintf("%x", pw)
}
for _, tc := range []struct {
name string
res func(t *testing.T, hash string) string
expectErr error
}{
{
name: "contains invalid data which is ignored",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf("%s:2\ninvalid", hash)
},
},
{
name: "is missing a colon",
res: func(t *testing.T, hash string) string {
return hash
},
},
{
name: "contains invalid hash count",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf("%s:text\n%s:2", hashPw(t, randomPassword(t)), hash)
},
expectErr: herodot.ErrUpstreamError(),
},
{
name: "is missing hash count",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf("%s\n%s:2", hash, hashPw(t, randomPassword(t)))
},
},
{
name: "response contains no matches",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf("%s:57", hashPw(t, randomPassword(t)))
},
},
{
name: "contains less than maxBreachesThreshold",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf(
"%s:%d\n%s:%d",
hash,
maxBreaches,
hashPw(t, randomPassword(t)),
maxBreaches+1,
)
},
},
{
name: "contains less than maxBreachesThreshold with a leading comma",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf(
"%s:%d\n%s:0,%d",
hash,
maxBreaches,
hashPw(t, randomPassword(t)),
maxBreaches+1,
)
},
},
{
name: "contains more than maxBreachesThreshold",
res: func(t *testing.T, hash string) string {
return fmt.Sprintf("%s:%d", hash, maxBreaches+1)
},
expectErr: text.NewErrorValidationPasswordTooManyBreaches(
int64(maxBreaches) + 1, // #nosec G115
),
},
} {
t.Run(fmt.Sprintf("case=%s/expected err=%s", tc.name, tc.expectErr), func(t *testing.T) {
pw := randomPassword(t)
hash := hashPw(t, pw)
hibpResp <- tc.res(t, hash)
err := s.Validate(context.Background(), "", pw)
assert.ErrorIs(t, err, tc.expectErr)
})
// verify the fetch was done, i.e. channel is empty
select {
case r := <-hibpResp:
t.Logf("expected the validate step to fetch the response, but I still got %s", r)
t.FailNow()
default:
// continue
}
}
})
}
func TestChangeHaveIBeenPwnedValidationHost(t *testing.T) {
t.Parallel()
testServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
t.Cleanup(testServer.Close)
testServerURL, err := url.Parse(testServer.URL)
require.NoError(t, err)
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedHost, testServerURL.Host))
s, fakeClient := newTestValidator(t, base)
testServerExpectedCallURL := fmt.Sprintf("https://%s/range/BCBA9", testServerURL.Host)
t.Run("case=should send request to test server", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIgnoreNetworkErrors, false)
require.Error(t, s.Validate(ctx, "mohutdesub", "damrumukuh"))
require.Contains(t, fakeClient.RequestedURLs(), testServerExpectedCallURL)
})
}
func TestDisableHaveIBeenPwnedValidationHost(t *testing.T) {
t.Parallel()
_, base := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedEnabled, false))
s, fakeClient := newTestValidator(t, base)
t.Run("case=should not send request to test server", func(t *testing.T) {
require.NoError(t, s.Validate(t.Context(), "mohutdesub", "damrumukuh"))
require.Empty(t, fakeClient.RequestedURLs())
})
}
func TestChangeMinPasswordLength(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordMinLength, 10))
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
require.NoError(t, err)
t.Run("case=should not fail if password is longer than min length", func(t *testing.T) {
require.NoError(t, s.Validate(t.Context(), "", "kuobahcaas"))
})
t.Run("case=should fail if password is shorter than min length", func(t *testing.T) {
require.Error(t, s.Validate(t.Context(), "", "rfqyfjied"))
})
}
func TestChangeIdentifierSimilarityCheckEnabled(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t)
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
require.NoError(t, err)
t.Run("case=should not fail if password is similar to identifier", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, false)
require.NoError(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"))
})
t.Run("case=should fail if password is similar to identifier", func(t *testing.T) {
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, true)
require.Error(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"))
})
}
// TestIdentifierSimilarityCheckMaxLength guards against an algorithmic-complexity
// DoS (H1 #3866310): the O(n·m) identifier-similarity check must skip oversized
// operands, which a short identifier can never be similar to anyway.
func TestIdentifierSimilarityCheckMaxLength(t *testing.T) {
t.Parallel()
// Disable HaveIBeenPwned so validation is deterministic and offline.
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValue(config.ViperKeyPasswordHaveIBeenPwnedEnabled, false))
s, err := password.NewDefaultPasswordValidatorStrategy(reg)
require.NoError(t, err)
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyPasswordIdentifierSimilarityCheckEnabled, true)
t.Run("case=skips the similarity check for oversized operands", func(t *testing.T) {
// Two identical strings one char past the 256-char cap would trip the
// too-similar check if it ran, so a nil result proves the length guard
// skipped it. Staying just over the cap keeps the test cheap even if the
// guard regresses (the check allocates an O(n·m) matrix).
large := strings.Repeat("a", 257)
require.NoError(t, s.Validate(ctx, large, large))
})
t.Run("case=still detects similarity for short operands", func(t *testing.T) {
require.ErrorIs(t, s.Validate(ctx, "bosqwfaxee", "bosqwfaxee"), text.NewErrorValidationPasswordIdentifierTooSimilar())
})
}
type fakeHttpClient struct {
http.Client
requestedURLs []string
responder func(*http.Request) (*http.Response, error)
}
func NewFakeHTTPClient() *fakeHttpClient {
client := fakeHttpClient{
responder: func(*http.Request) (*http.Response, error) {
return nil, errors.New("No responder defined in fake HTTP client")
},
}
client.Client = http.Client{
Transport: &fakeRoundTripper{&client},
Timeout: time.Second,
}
return &client
}
func (c *fakeHttpClient) RespondWith(status int, body string) {
c.responder = func(request *http.Request) (*http.Response, error) {
buffer := bytes.NewBufferString(body)
return &http.Response{
StatusCode: status,
Body: io.NopCloser(buffer),
ContentLength: int64(buffer.Len()),
Request: request,
}, nil
}
}
func (c *fakeHttpClient) RespondWithError(err string) {
c.responder = func(*http.Request) (*http.Response, error) {
return nil, errors.New(err)
}
}
// RespondWithBodyError responds with a status 200 whose body yields prefix and
// then fails, mimicking a connection reset or a client timeout that only hits
// once the response is already being read.
func (c *fakeHttpClient) RespondWithBodyError(prefix string, err error) {
c.responder = func(request *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(io.MultiReader(strings.NewReader(prefix), errorReader{err})),
Request: request,
}, nil
}
}
func (c *fakeHttpClient) Reset() {
c.requestedURLs = nil
}
func (c *fakeHttpClient) RequestedURLs() []string {
return c.requestedURLs
}
func (c *fakeHttpClient) handle(request *http.Request) (*http.Response, error) {
c.requestedURLs = append(c.requestedURLs, request.URL.String())
if request.Body != nil {
_ = request.Body.Close()
}
return c.responder(request)
}
type fakeRoundTripper struct {
client *fakeHttpClient
}
func (rt *fakeRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) {
return rt.client.handle(request)
}
// errBodyRead is the error net/http returns from a response body read when the
// client's timeout fires after the response headers have been received.
var errBodyRead = errors.New("net/http: request canceled (Client.Timeout or context cancellation while reading body)")
type errorReader struct{ err error }
func (r errorReader) Read([]byte) (int, error) { return 0, r.err }