snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
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

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

317
x/normalize_test.go Normal file
View File

@@ -0,0 +1,317 @@
// 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)
}
}
}