Files
upstream-ory-kratos/x/normalize_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

318 lines
11 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright © 2024 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package x
import (
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
// defaultIgnorableBypassRunes is the set of code points called out in the
// 2026-05-23 security report as surviving the Cf-only strip. Each rune has
// the Default_Ignorable_Code_Point property but lives in a category outside
// Cf (Lo, Mn, or Cn), so the predicate from PR #11885 misses them and the
// duplicate-account bypass still works.
var defaultIgnorableBypassRunes = []struct {
name string
r rune
}{
{"Hangul Filler U+3164 (Lo)", 'ㅤ'},
{"Hangul Choseong Filler U+115F (Lo)", 'ᅟ'},
{"Hangul Jungseong Filler U+1160 (Lo)", 'ᅠ'},
{"Khmer Vowel Inherent AQ U+17B4 (Mn)", '឴'},
{"Khmer Vowel Inherent AA U+17B5 (Mn)", '឵'},
{"Combining Grapheme Joiner U+034F (Mn)", '͏'},
{"Reserved U+2065 (Cn, in DI_CP set)", '⁥'},
{"Variation Selector-16 U+FE0F (Mn)", '️'},
{"Mongolian Free Variation Selector One U+180B (Mn)", '᠋'},
{"Variation Selector-17 U+E0100 (Mn)", '\U000E0100'},
}
func TestNormalizeEmailIdentifier(t *testing.T) {
tests := []struct {
input string
expected string
}{
{" EXAMPLE@DOMAIN.COM ", "example@domain.com"},
{"user@domain.com", "user@domain.com"},
{"invalid-email", "invalid-email"},
// Invisible Unicode characters (category Cf) must not produce a
// distinct identifier — otherwise an attacker can register a second
// account with a visually identical email and bypass the duplicate-
// account check.
{"alice\u200B@example.com", "alice@example.com"}, // zero-width space
{"alice\u200C@example.com", "alice@example.com"}, // zero-width non-joiner
{"alice\u200D@example.com", "alice@example.com"}, // zero-width joiner
{"Alice\u00AD@example.com", "alice@example.com"}, // soft hyphen
{"alice\uFEFF@example.com", "alice@example.com"}, // zero-width no-break space (BOM)
{"alice\u2060@example.com", "alice@example.com"}, // word joiner
{"alice\u180E@example.com", "alice@example.com"}, // Mongolian vowel separator
{"\u202Aalice@example.com\u202C", "alice@example.com"}, // bidi LRE/PDF
{"al\u200Bice\u200C@ex\u200Dample.com", "alice@example.com"},
// NFKC compatibility decomposition collapses fullwidth and ligature
// lookalikes so they hit the same identifier as the ASCII canonical
// form.
{"alice@example.com", "alice@example.com"}, // fullwidth "alice"
{"office@example.com", "office@example.com"}, // ligature ffi
}
for _, test := range tests {
assert.Equal(t, test.expected, NormalizeEmailIdentifier(test.input), "input=%q", test.input)
}
}
func TestNormalizePhoneIdentifier(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"+1 650-253-0000", "+16502530000"},
{"+1 (650) 253-0000", "+16502530000"},
{"+44 20 7946 0958", "+442079460958"},
{"+49 176 671 11 638", "+4917667111638"},
{"+600136866982", "+60136866982"},
{"+256 730 691 099", "+256730691099"},
{"+256730691099", "+256730691099"},
{"invalid-phone", "invalid-phone"},
}
for _, test := range tests {
assert.Equal(t, test.expected, NormalizePhoneIdentifier(test.input))
}
}
func TestNormalizeOtherIdentifier(t *testing.T) {
tests := []struct {
input string
expected string
}{
{" username ", "username"},
{"user123", "user123"},
{" ", ""},
{" UsERNaME ", "username"},
// Invisible characters must not allow a duplicate username either.
{"al\u200Bice", "alice"}, // U+200B
{"al\u200Cice", "alice"}, // U+200C
{"al\u200Dice", "alice"}, // U+200D
{"al\u00ADice", "alice"}, // U+00AD soft hyphen
{"al\uFEFFice", "alice"}, // U+FEFF BOM
{"al\u2060ice", "alice"}, // U+2060 word joiner
{"al\u180Eice", "alice"}, // U+180E
{"alice", "alice"}, // fullwidth Latin (NFKC)
}
for _, test := range tests {
assert.Equal(t, test.expected, NormalizeOtherIdentifier(test.input), "input=%q", test.input)
}
}
func TestGracefulNormalization(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"+1 650-253-0000", "+16502530000"},
{" EXAMPLE@DOMAIN.COM ", "example@domain.com"},
{" username ", "username"},
{"invalid-phone", "invalid-phone"},
}
for _, test := range tests {
assert.Equal(t, test.expected, GracefulNormalization(test.input))
}
}
// TestCanonicalizeIdentifier_Concurrent exercises the canonicalization
// path from many goroutines so the race detector catches any shared
// mutable state in the underlying transformer. A package-level
// transform.Chain would race on its internal link buffers; a per-call
// construction must not.
func TestCanonicalizeIdentifier_Concurrent(t *testing.T) {
t.Parallel()
inputs := []struct {
input, want string
}{
{"alice\u200B@example.com", "alice@example.com"},
{"al\u200Cice\u200D@ex\u00ADample.com", "alice@example.com"},
{"alice@example.com", "alice@example.com"},
{"office@example.com", "office@example.com"},
{"al\uFEFFice\u2060@example.com", "alice@example.com"},
}
const goroutines = 64
const iterations = 200
var wg sync.WaitGroup
for range goroutines {
wg.Go(func() {
for range iterations {
for _, tc := range inputs {
got := NormalizeEmailIdentifier(tc.input)
if got != tc.want {
assert.Equal(t, tc.want, got, "input=%q", tc.input)
return
}
}
}
})
}
wg.Wait()
}
// TestNormalize_DefaultIgnorableBypass asserts that every Default_Ignorable_Code_Point
// rune called out in the 2026-05-23 security report is stripped — not just
// the Format (Cf) subset that PR #11885 covered. Each rune is injected mid-
// identifier; the canonical form must equal the rune-free baseline so that
// the UNIQUE constraint on identity_credential_identifiers catches the
// duplicate registration.
func TestNormalize_DefaultIgnorableBypass(t *testing.T) {
for _, tc := range defaultIgnorableBypassRunes {
t.Run(tc.name, func(t *testing.T) {
// Username path.
gotUser := NormalizeOtherIdentifier("alic" + string(tc.r) + "e")
assert.Equal(t, "alice", gotUser,
"username with %s must canonicalize to baseline", tc.name)
// Email path.
gotEmail := NormalizeEmailIdentifier("alic" + string(tc.r) + "e@example.com")
assert.Equal(t, "alice@example.com", gotEmail,
"email with %s must canonicalize to baseline", tc.name)
// Phone path: the rune must be removed so phonenumbers.Parse
// produces the canonical E.164 form.
gotPhone := NormalizePhoneIdentifier("+1 650-253" + string(tc.r) + "-0000")
assert.Equal(t, "+16502530000", gotPhone,
"phone with %s must canonicalize to baseline", tc.name)
// GracefulNormalization must agree with the format-specific funcs.
assert.Equal(t, "alice", GracefulNormalization("alic"+string(tc.r)+"e"))
assert.Equal(t, "alice@example.com",
GracefulNormalization("alic"+string(tc.r)+"e@example.com"))
assert.Equal(t, "+16502530000",
GracefulNormalization("+1 650-253"+string(tc.r)+"-0000"))
})
}
}
// TestNormalize_PoisonedRuneStillFolds guards against a duplicate-account
// bypass in the graceful fallback: when an identifier carries a disallowed
// rune AND uppercase or fullwidth characters, stripping the rune is not
// enough — the result must still be lowercased, width-folded, and
// NFKC-composed so it collapses onto the clean baseline. Otherwise an
// attacker registers `ALICE<ZWSP>smith`, it stores as `ALICEsmith`, and the
// UNIQUE constraint never sees it as a duplicate of `alicesmith`.
func TestNormalize_PoisonedRuneStillFolds(t *testing.T) {
zwsp := string(rune(0x200B))
tests := []struct {
name string
input string
baseline string
}{
{"uppercase + zero-width space", "ALICE" + zwsp + "SMITH", "alicesmith"},
{"fullwidth + zero-width space", "ALICE" + zwsp + "smith", "alicesmith"},
{"uppercase + soft hyphen", "FOO" + string(rune(0x00AD)) + "BAR", "foobar"},
{"fullwidth digits in email + ZWSP", "AL" + zwsp + "ICE@example.com", "alice@example.com"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.baseline, NormalizeOtherIdentifier(tc.input))
assert.Equal(t, NormalizeOtherIdentifier(tc.baseline), NormalizeOtherIdentifier(tc.input))
})
}
}
// TestNormalize_EmojiPreserved asserts that emoji and other pictographic
// symbols survive normalization rather than being stripped. They are only
// lowercased and NFKC-composed, so an identifier carrying an emoji stays
// distinct from the emoji-free baseline.
func TestNormalize_EmojiPreserved(t *testing.T) {
t.Run("emoji is preserved", func(t *testing.T) {
assert.Equal(t, "alice🎉", NormalizeOtherIdentifier("alice🎉"))
})
t.Run("emoji identifier is distinct from baseline", func(t *testing.T) {
assert.NotEqual(t,
NormalizeOtherIdentifier("alice"),
NormalizeOtherIdentifier("alice🎉"),
)
})
}
// TestNormalize_RTLScripts asserts that identifiers containing RTL
// script letters (Hebrew, Arabic) — whether pure-RTL or mixed with LTR —
// canonicalize as distinct identifiers. The profile intentionally omits
// the RFC 5893 bidi rule so that international users can register email
// addresses and usernames in their native script.
func TestNormalize_RTLScripts(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"pure Hebrew username", "דוד", "דוד"},
{"pure Arabic username", "محمد", "محمد"},
{"pure Hebrew email", "דוד@example.com", "דוד@example.com"},
{"pure Arabic email", "محمد@example.com", "محمد@example.com"},
{"mixed LTR+Hebrew username", "aliceא", "aliceא"},
{"mixed LTR+Hebrew email", "alice.דוד@example.com", "alice.דוד@example.com"},
{"uppercase mixed lowercases", "ALICEא", "aliceא"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.expected, NormalizeOtherIdentifier(tc.input))
})
}
t.Run("mixed-script identifier is distinct from LTR-only baseline", func(t *testing.T) {
// `aliceא` and `alice` must NOT collide — Hebrew alef is not a
// homograph for any Latin letter, so they are different users.
assert.NotEqual(t,
NormalizeOtherIdentifier("alice"),
NormalizeOtherIdentifier("aliceא"),
)
})
t.Run("bidi control characters are still stripped", func(t *testing.T) {
// Even without `precis.BidiRule`, explicit bidi-control
// characters (LRE, PDF, …) are disallowed at the rune level so
// they cannot be used to spoof the rendered identifier. The
// controls are built from code points so the test source itself
// stays free of bidirectional control characters (gosec G116).
lre, pdf := string(rune(0x202A)), string(rune(0x202C))
assert.Equal(t, "alice@example.com",
NormalizeEmailIdentifier(lre+"alice@example.com"+pdf))
})
}
func TestNormalizeIdentifier(t *testing.T) {
tests := []struct {
input string
format string
expected string
err bool
}{
{" EXAMPLE@DOMAIN.COM ", "email", "example@domain.com", false},
{"+1 650-253-0000", "sms", "+16502530000", false},
{"+256 730 691 099", "sms", "+256730691099", false},
{" username ", "username", "username", false},
{"invalid-phone", "sms", "", true},
}
for _, test := range tests {
result, err := NormalizeIdentifier(test.input, test.format)
if test.err {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, test.expected, result)
}
}
}