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

566
identity/validator_test.go Normal file
View File

@@ -0,0 +1,566 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package identity_test
import (
"context"
"encoding/base64"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/herodot"
"github.com/ory/jsonschema/v3/httploader"
"github.com/ory/kratos/driver/config"
. "github.com/ory/kratos/identity"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/pkg/testhelpers"
"github.com/ory/x/configx"
"github.com/ory/x/httpx"
)
func TestSchemaValidatorDisallowsInternalNetworkRequests(t *testing.T) {
t.Parallel()
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.IdentitySchemasConfig(map[string]string{
"localhost": "https://localhost/schema/whatever",
"privateRef": "file://stub/localhost-ref.schema.json",
"inlineRef": "base64://" + base64.StdEncoding.EncodeToString([]byte(`{"traits": {}}`)),
})),
configx.WithValue(config.ViperKeyClientHTTPNoPrivateIPRanges, true),
)
v := NewValidator(reg)
for id, expectedErr := range map[string]string{
"localhost": "no such host",
"privateRef": "no route to host",
"inlineRef": "",
} {
t.Run(id, func(t *testing.T) {
t.Parallel()
i := &Identity{
SchemaID: id,
Traits: Traits(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
}
ctx := context.WithValue(t.Context(), httploader.ContextKey, reg.HTTPClient(t.Context()))
err := v.Validate(ctx, i)
if expectedErr == "" {
assert.NoError(t, err)
return
}
var hErr *herodot.DefaultError
require.ErrorAs(t, err, &hErr)
assert.Contains(t, hErr.Debug(), expectedErr)
})
}
}
func TestSchemaValidator(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
router := http.NewServeMux()
router.HandleFunc("GET /schema/{name}", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{
"$id": "https://example.com/person.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"` + r.PathValue("name") + `": {
"type": "string",
"description": "The person's first name."
},
"lastName": {
"type": "string",
"description": "The person's last name."
},
"age": {
"description": "Age in years which must be equal to or greater than zero.",
"type": "integer",
"minimum": 1
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}`))
})
ts := httptest.NewServer(router)
defer ts.Close()
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.IdentitySchemasConfig(map[string]string{
"default": ts.URL + "/schema/firstName",
"whatever": ts.URL + "/schema/whatever",
"unreachable-url": ts.URL + "/404-not-found",
})),
)
v := NewValidator(reg)
for k, tc := range []struct {
i *Identity
err string
}{
{
i: &Identity{
Traits: Traits(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
},
},
{
i: &Identity{
Traits: Traits(`{ "firstName": "first-name", "lastName": "last-name", "age": -1 }`),
},
err: "I[#/traits/age] S[#/properties/traits/properties/age/minimum] must be >= 1 but found -1",
},
{
i: &Identity{
Traits: Traits(`{ "whatever": "first-name", "lastName": "last-name", "age": 1 }`),
},
err: `I[#/traits] S[#/properties/traits/additionalProperties] additionalProperties "whatever" not allowed`,
},
{
i: &Identity{
SchemaID: "whatever",
Traits: Traits(`{ "whatever": "first-name", "lastName": "last-name", "age": 1 }`),
},
},
{
i: &Identity{
SchemaID: "whatever",
Traits: Traits(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
},
err: `I[#/traits] S[#/properties/traits/additionalProperties] additionalProperties "firstName" not allowed`,
},
{
i: &Identity{
SchemaID: "unreachable-url",
Traits: Traits(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
},
err: "Invalid configuration",
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
ctx := context.WithValue(t.Context(), httploader.ContextKey, httpx.NewResilientClient())
err := v.Validate(ctx, tc.i)
if tc.err == "" {
require.NoError(t, err)
} else {
require.EqualError(t, err, tc.err)
}
})
}
}
// TestSchemaValidatorNormalizesIdentifierTraits verifies that trait values
// used as credential, recovery, or verification identifiers are normalized
// to the same canonical form Kratos stores in the side tables. This keeps
// webhook payloads consistent with the value Kratos uses internally.
//
// Regression test for https://github.com/ory-corp/cloud/issues/11739.
func TestSchemaValidatorNormalizesIdentifierTraits(t *testing.T) {
t.Parallel()
router := http.NewServeMux()
router.HandleFunc("GET /schema/code-sms", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{
"$id": "https://example.com/code-sms.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"format": "tel",
"ory.sh/kratos": {
"credentials": {
"code": {
"identifier": true,
"via": "sms"
}
}
}
}
}
}
}
}`))
})
router.HandleFunc("GET /schema/recovery-sms", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{
"$id": "https://example.com/recovery-sms.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"my.phone.number": {
"type": "string",
"format": "tel",
"ory.sh/kratos": {
"recovery": {
"via": "sms"
}
}
}
}
}
}
}`))
})
router.HandleFunc("GET /schema/verification-sms", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{
"$id": "https://example.com/verification-sms.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"format": "tel",
"ory.sh/kratos": {
"verification": {
"via": "sms"
}
}
}
}
}
}
}`))
})
router.HandleFunc("GET /schema/verification-sms-array", func(w http.ResponseWriter, _ *http.Request) {
// Array of phones, each element a verification+sms identifier.
_, _ = w.Write([]byte(`{
"$id": "https://example.com/verification-sms-array.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"phones": {
"type": "array",
"items": {
"type": "string",
"format": "tel",
"ory.sh/kratos": {
"verification": {
"via": "sms"
}
}
}
}
}
}
}
}`))
})
router.HandleFunc("GET /schema/code-sms-shared-ref", func(w http.ResponseWriter, _ *http.Request) {
// Two trait properties share the same $defs/PhoneIdentifier
// definition. With recursion-stack semantics both must
// normalize; with a one-shot visited-set only the first would.
_, _ = w.Write([]byte(`{
"$id": "https://example.com/code-sms-shared-ref.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"$defs": {
"PhoneIdentifier": {
"type": "string",
"format": "tel",
"ory.sh/kratos": {
"credentials": {
"code": {
"identifier": true,
"via": "sms"
}
}
}
}
},
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"primary_phone": { "$ref": "#/$defs/PhoneIdentifier" },
"secondary_phone": { "$ref": "#/$defs/PhoneIdentifier" }
}
}
}
}`))
})
router.HandleFunc("GET /schema/code-email", func(w http.ResponseWriter, _ *http.Request) {
// Email-channel identifier: trait must NOT be normalized
// (case-preservation is asserted by ory/kratos#3187).
_, _ = w.Write([]byte(`{
"$id": "https://example.com/code-email.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"credentials": {
"code": {
"identifier": true,
"via": "email"
}
}
}
}
}
}
}
}`))
})
router.HandleFunc("GET /schema/code-sms-recursive", func(w http.ResponseWriter, _ *http.Request) {
// Self-referential schema: `Person` has a `spouse` property
// pointing back to `Person`. Without the recursion-stack guard
// in walkPhoneTraits, the walk would loop forever; with it the
// walk visits `phone` and `spouse.phone`, then stops.
_, _ = w.Write([]byte(`{
"$id": "https://example.com/code-sms-recursive.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"$defs": {
"Person": {
"type": "object",
"properties": {
"phone": {
"type": "string",
"format": "tel",
"ory.sh/kratos": {
"credentials": {
"code": {
"identifier": true,
"via": "sms"
}
}
}
},
"spouse": { "$ref": "#/$defs/Person" }
}
}
},
"type": "object",
"properties": {
"traits": { "$ref": "#/$defs/Person" }
}
}`))
})
router.HandleFunc("GET /schema/code-sms-anyof", func(w http.ResponseWriter, _ *http.Request) {
// Sibling combinator coverage: the production code lumps
// anyOf/oneOf/allOf into the same slices.Concat, but only
// allOf is otherwise exercised. Drop the wrong key from the
// concat and this test catches it.
_, _ = w.Write([]byte(`{
"$id": "https://example.com/code-sms-anyof.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"phone": {
"anyOf": [
{
"type": "string",
"format": "tel",
"ory.sh/kratos": {
"credentials": {
"code": {
"identifier": true,
"via": "sms"
}
}
}
}
]
}
}
}
}
}`))
})
router.HandleFunc("GET /schema/code-sms-allof", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{
"$id": "https://example.com/code-sms-allof.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"phone": {
"allOf": [
{
"type": "string",
"format": "tel",
"ory.sh/kratos": {
"credentials": {
"code": {
"identifier": true,
"via": "sms"
}
}
}
}
]
}
}
}
}
}`))
})
ts := httptest.NewServer(router)
t.Cleanup(ts.Close)
_, reg := pkg.NewFastRegistryWithMocks(t,
configx.WithValues(testhelpers.IdentitySchemasConfig(map[string]string{
"code-sms": ts.URL + "/schema/code-sms",
"recovery-sms": ts.URL + "/schema/recovery-sms",
"verification-sms": ts.URL + "/schema/verification-sms",
"verification-sms-array": ts.URL + "/schema/verification-sms-array",
"code-sms-allof": ts.URL + "/schema/code-sms-allof",
"code-sms-anyof": ts.URL + "/schema/code-sms-anyof",
"code-sms-recursive": ts.URL + "/schema/code-sms-recursive",
"code-sms-shared-ref": ts.URL + "/schema/code-sms-shared-ref",
"code-email": ts.URL + "/schema/code-email",
})),
)
v := NewValidator(reg)
for _, tc := range []struct {
name string
schemaID string
input string
wantTraits string
wantErr string
}{
{
name: "credential identifier code+sms normalizes phone in traits",
schemaID: "code-sms",
input: `{"phone":"+49 176 671 11 638"}`,
wantTraits: `{"phone":"+4917667111638"}`,
},
{
name: "credential identifier code+sms normalizes already E.164 phone idempotently",
schemaID: "code-sms",
input: `{"phone":"+4917667111638"}`,
wantTraits: `{"phone":"+4917667111638"}`,
},
{
name: "recovery via sms normalizes phone in traits",
schemaID: "recovery-sms",
input: `{"my.phone.number":"+49 176 671 11 638"}`,
wantTraits: `{"my.phone.number":"+4917667111638"}`,
},
{
name: "verification via sms normalizes phone in traits",
schemaID: "verification-sms",
input: `{"phone":"+49 176 671 11 638"}`,
wantTraits: `{"phone":"+4917667111638"}`,
},
{
name: "phone identifier nested under allOf is normalized",
schemaID: "code-sms-allof",
input: `{"phone":"+49 176 671 11 638"}`,
wantTraits: `{"phone":"+4917667111638"}`,
},
{
name: "phone identifier nested under anyOf is normalized",
schemaID: "code-sms-anyof",
input: `{"phone":"+49 176 671 11 638"}`,
wantTraits: `{"phone":"+4917667111638"}`,
},
{
// Cycle guard: a self-referential schema (Person.spouse
// → Person) must not send walkPhoneTraits into infinite
// recursion. The top-level phone still normalizes; the
// schema walk hits the spouse → Person cycle and
// short-circuits without exploding the stack. A hang or
// stack overflow here means the recursion guard regressed.
name: "recursive schema terminates and normalizes the top-level phone",
schemaID: "code-sms-recursive",
input: `{"phone":"+49 176 671 11 638"}`,
wantTraits: `{"phone":"+4917667111638"}`,
},
{
// Array elements at `phones[*]` are configured as
// verification+sms identifiers and must be normalized.
name: "verification via sms normalizes phones inside an array",
schemaID: "verification-sms-array",
input: `{"phones":["+49 176 671 11 638","+44 20 7946 0958"]}`,
wantTraits: `{"phones":["+4917667111638","+442079460958"]}`,
},
{
// Two trait properties share the same $ref definition.
// The walk must visit both, not skip the second because
// the shared schema node was seen during the first walk.
name: "shared $ref between two trait paths normalizes both",
schemaID: "code-sms-shared-ref",
input: `{"primary_phone":"+49 176 671 11 638","secondary_phone":"+44 20 7946 0958"}`,
wantTraits: `{"primary_phone":"+4917667111638","secondary_phone":"+442079460958"}`,
},
{
// Regression guard for ory/kratos#3187: email-channel
// identifiers must keep the user-supplied case in traits.
name: "email-channel identifier preserves trait case verbatim",
schemaID: "code-email",
input: `{"email":"UpperCased@Ory.SH"}`,
wantTraits: `{"email":"UpperCased@Ory.SH"}`,
},
{
// When the trait value is not a parseable phone number,
// validation should fail and traits should be untouched.
name: "unparseable phone leaves traits untouched and surfaces validation error",
schemaID: "code-sms",
input: `{"phone":"not-a-phone-number"}`,
wantTraits: `{"phone":"not-a-phone-number"}`,
wantErr: "is not valid",
},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.WithValue(t.Context(), httploader.ContextKey, httpx.NewResilientClient())
i := &Identity{
SchemaID: tc.schemaID,
Traits: Traits(tc.input),
}
err := v.Validate(ctx, i)
if tc.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.wantErr)
} else {
require.NoError(t, err)
}
assert.JSONEq(t, tc.wantTraits, string(i.Traits),
"trait JSON must match the normalized identifier form so webhook payloads are consistent with what Kratos stores")
})
}
}