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

38
schema/context.go Normal file
View File

@@ -0,0 +1,38 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"strings"
"github.com/ory/jsonschema/v3"
)
func ContextSetRoot(err *jsonschema.ValidationError, head string) *jsonschema.ValidationError {
err.InstancePtr = prefixPointer(err.InstancePtr, head)
if err.Context != nil {
switch ctx := err.Context.(type) {
case *jsonschema.ValidationErrorContextRequired:
for k, pointer := range ctx.Missing {
ctx.Missing[k] = prefixPointer(pointer, head)
}
}
}
for k := range err.Causes {
err.Causes[k] = ContextSetRoot(err.Causes[k], head)
}
return err
}
func prefixPointer(pointer, head string) string {
if pointer == "#" || pointer == "#/" {
pointer = "#/" + head
} else {
pointer = "#/" + head + "/" + strings.TrimPrefix(pointer, "#/")
}
return pointer
}

45
schema/context_test.go Normal file
View File

@@ -0,0 +1,45 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/ory/jsonschema/v3"
)
func TestContextSetRoot(t *testing.T) {
for k, tc := range []struct {
in jsonschema.ValidationError
head string
expect jsonschema.ValidationError
}{
{
head: "traits",
in: jsonschema.ValidationError{
InstancePtr: "#/foo/bar",
Context: &jsonschema.ValidationErrorContextRequired{Missing: []string{"#/foo/bar/baz"}},
Causes: []*jsonschema.ValidationError{{
InstancePtr: "#/foo/bar",
Context: &jsonschema.ValidationErrorContextRequired{Missing: []string{"#/foo/bar/baz"}},
}},
},
expect: jsonschema.ValidationError{
InstancePtr: "#/traits/foo/bar",
Context: &jsonschema.ValidationErrorContextRequired{Missing: []string{"#/traits/foo/bar/baz"}},
Causes: []*jsonschema.ValidationError{{
InstancePtr: "#/traits/foo/bar",
Context: &jsonschema.ValidationErrorContextRequired{Missing: []string{"#/traits/foo/bar/baz"}},
}},
},
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
assert.EqualValues(t, tc.expect, *ContextSetRoot(&tc.in, tc.head))
})
}
}

424
schema/errors.go Normal file
View File

@@ -0,0 +1,424 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"fmt"
"github.com/pkg/errors"
"github.com/ory/jsonschema/v3"
"github.com/ory/kratos/text"
)
type ValidationError struct {
*jsonschema.ValidationError
Messages text.Messages
}
func NewRequiredError(missingPtr, missingFieldName string) error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: fmt.Sprintf("missing properties: %s", missingFieldName),
InstancePtr: missingPtr,
Context: &jsonschema.ValidationErrorContextRequired{
Missing: []string{missingFieldName},
},
},
Messages: new(text.Messages).Add(text.NewValidationErrorRequired(missingFieldName)),
})
}
func NewTOTPVerifierWrongError(instancePtr string) error {
t := text.NewErrorValidationTOTPVerifierWrong()
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: t.Text,
InstancePtr: instancePtr,
},
Messages: new(text.Messages).Add(t),
})
}
func NewWebAuthnVerifierWrongError(instancePtr string) error {
t := text.NewErrorValidationWebAuthnVerifierWrong()
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: t.Text,
InstancePtr: instancePtr,
},
Messages: new(text.Messages).Add(t),
})
}
func NewDeviceAuthnVerifierWrongError(instancePtr string) error {
t := text.NewErrorValidationDeviceAuthnVerifierWrong()
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: t.Text,
InstancePtr: instancePtr,
},
Messages: new(text.Messages).Add(t),
})
}
func NewDeviceAuthnRelaxedAttestationNoLongerValidError(instancePtr string) error {
t := text.NewErrorValidationDeviceAuthnRelaxedAttestationNoLongerValid()
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: t.Text,
InstancePtr: instancePtr,
},
Messages: new(text.Messages).Add(t),
})
}
func NewDeviceAuthnKeyReenrollmentRequiredError(instancePtr string) error {
t := text.NewErrorValidationDeviceAuthnKeyReenrollmentRequired()
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: t.Text,
InstancePtr: instancePtr,
},
Messages: new(text.Messages).Add(t),
})
}
func NewLookupAlreadyUsed() error {
t := text.NewErrorValidationLookupAlreadyUsed()
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: t.Text,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(t),
})
}
func NewErrorValidationLookupInvalid() error {
t := text.NewErrorValidationLookupInvalid()
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: t.Text,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(t),
})
}
type ValidationErrorContextPasswordPolicyViolation struct {
Reason string
}
func (r *ValidationErrorContextPasswordPolicyViolation) AddContext(_, _ string) {}
func (r *ValidationErrorContextPasswordPolicyViolation) FinishInstanceContext() {}
func NewPasswordPolicyViolationError(instancePtr string, message *text.Message) error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: fmt.Sprintf("the password does not fulfill the password policy because: %s", message.Text),
InstancePtr: instancePtr,
Context: &ValidationErrorContextPasswordPolicyViolation{
Reason: message.Text,
},
},
Messages: new(text.Messages).Add(message),
})
}
func NewMissingIdentifierError() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: "could not find any identifiers",
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationIdentifierMissing()),
})
}
type ValidationErrorContextInvalidCredentialsError struct{}
func (r *ValidationErrorContextInvalidCredentialsError) AddContext(_, _ string) {}
func (r *ValidationErrorContextInvalidCredentialsError) FinishInstanceContext() {}
func NewInvalidCredentialsError() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `the provided credentials are invalid, check for spelling mistakes in your password or username, email address, or phone number`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationInvalidCredentials()),
})
}
func NewAccountNotFoundError() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: "this account does not exist or has no login method configured",
InstancePtr: "#/identifier",
},
Messages: new(text.Messages).Add(text.NewErrorValidationAccountNotFound()),
})
}
type ValidationErrorContextDuplicateCredentialsError struct {
AvailableCredentials []string `json:"available_credential_types"`
AvailableOIDCProviders []string `json:"available_oidc_providers"`
IdentifierHint string `json:"credential_identifier_hint"`
}
func (r *ValidationErrorContextDuplicateCredentialsError) AddContext(_, _ string) {}
func (r *ValidationErrorContextDuplicateCredentialsError) FinishInstanceContext() {}
type DuplicateCredentialsHinter interface {
AvailableCredentials() []string
AvailableOIDCProviders() []string
IdentifierHint() string
HasHints() bool
}
func NewDuplicateCredentialsError(err error) error {
if hinter := DuplicateCredentialsHinter(nil); errors.As(err, &hinter) && hinter.HasHints() {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `an account with the same identifier (email, phone, username, ...) exists already`,
InstancePtr: "#/",
Context: &ValidationErrorContextDuplicateCredentialsError{
AvailableCredentials: hinter.AvailableCredentials(),
AvailableOIDCProviders: hinter.AvailableOIDCProviders(),
IdentifierHint: hinter.IdentifierHint(),
},
},
Messages: new(text.Messages).Add(text.NewErrorValidationDuplicateCredentialsWithHints(hinter.AvailableCredentials(), hinter.AvailableOIDCProviders(), hinter.IdentifierHint())),
})
}
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `an account with the same identifier (email, phone, username, ...) exists already`,
InstancePtr: "#/",
Context: &ValidationErrorContextDuplicateCredentialsError{},
},
Messages: new(text.Messages).Add(text.NewErrorValidationDuplicateCredentials()),
})
}
func NewNoLoginStrategyResponsible() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `could not find a strategy to login with`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationLoginNoStrategyFound()),
})
}
func NewNoRegistrationStrategyResponsible() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `could not find a strategy to sign up with`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationRegistrationNoStrategyFound()),
})
}
func NewNoSettingsStrategyResponsible() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `could not find a strategy to update settings with`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationSettingsNoStrategyFound()),
})
}
func NewNoRecoveryStrategyResponsible() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `could not find a strategy to recover your account with`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationRecoveryNoStrategyFound()),
})
}
func NewNoVerificationStrategyResponsible() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `could not find a strategy to verify your account with`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationVerificationNoStrategyFound()),
})
}
func NewAddressNotVerifiedError() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `account address not yet verified`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationAddressNotVerified()),
})
}
func NewNoTOTPDeviceRegistered() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `you have no TOTP device set up`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationNoTOTPDevice()),
})
}
func NewNoLookupDefined() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `you have no backup recovery codes set up`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationNoLookup()),
})
}
func NewNoWebAuthnRegistered() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `you have no WebAuthn device set up`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationNoWebAuthnDevice()),
})
}
func NewNoDeviceAuthnRegistered() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `you have no DeviceAuthn device set up`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationNoDeviceAuthnDevice()),
})
}
func NewHookValidationError(instancePtr, message string, messages text.Messages) *ValidationError {
return &ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: message,
InstancePtr: instancePtr,
},
Messages: messages,
}
}
type ValidationListError struct {
Validations []*ValidationError
}
func (e ValidationListError) Error() string {
var detailError string
for pos, validationErr := range e.Validations {
detailError = detailError + fmt.Sprintf("\n(%d) %s", pos, validationErr.Error())
}
return fmt.Sprintf("%d validation errors occurred:%s", len(e.Validations), detailError)
}
func (e *ValidationListError) Add(v *ValidationError) {
e.Validations = append(e.Validations, v)
}
func (e ValidationListError) HasErrors() bool {
return len(e.Validations) > 0
}
func (e *ValidationListError) WithError(instancePtr, message string, details text.Messages) {
e.Validations = append(e.Validations, &ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: message,
InstancePtr: instancePtr,
},
Messages: details,
})
}
func NewValidationListError(errs []*ValidationError) error {
return errors.WithStack(&ValidationListError{Validations: errs})
}
func NewNoWebAuthnCredentials() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `account does not exist or has no security key set up`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationSuchNoWebAuthnUser()),
})
}
func NewNoCodeAuthnCredentials() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `account does not exist or has not setup up sign in with code`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationNoCodeUser()),
})
}
func NewTraitsMismatch() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `the submitted form data has changed from the previous submission`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationTraitsMismatch()),
})
}
func NewRegistrationCodeInvalid() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `the provided code is invalid or has already been used`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationRegistrationCodeInvalidOrAlreadyUsed()),
})
}
func NewLoginCodeInvalid() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `the provided code is invalid or has already been used`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationLoginCodeInvalidOrAlreadyUsed()),
})
}
func NewLinkedCredentialsDoNotMatch() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `linked credentials do not match; please start a new flow`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationLoginLinkedCredentialsDoNotMatch()),
})
}
func NewUnknownAddressError() error {
return errors.WithStack(&ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `the supplied address does not match any known addresses.`,
InstancePtr: "#/",
},
Messages: new(text.Messages).Add(text.NewErrorValidationAddressUnknown()),
},
)
}

39
schema/errors_test.go Normal file
View File

@@ -0,0 +1,39 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/ory/jsonschema/v3"
"github.com/ory/kratos/text"
)
func TestListValidationErrors(t *testing.T) {
testErr := ValidationListError{}
assert.False(t, testErr.HasErrors())
testErr.WithError("#/traits/password", "error message", new(text.Messages).Add(text.NewErrorValidationDuplicateCredentials()))
assert.True(t, testErr.HasErrors())
assert.Len(t, testErr.Validations, 1)
validationError := &ValidationError{
ValidationError: &jsonschema.ValidationError{
Message: `the provided credentials are invalid, check for spelling mistakes in your password or username, email address, or phone number`,
InstancePtr: "#/",
Context: &ValidationErrorContextPasswordPolicyViolation{},
},
Messages: new(text.Messages).Add(text.NewErrorValidationInvalidCredentials()),
}
testErr.Add(validationError)
assert.Len(t, testErr.Validations, 2)
assert.Equal(t, "2 validation errors occurred:"+
"\n(0) I[#/traits/password] S[] error message"+
"\n(1) I[#/] S[] the provided credentials are invalid, check for spelling mistakes in your password or username, email address, or phone number",
testErr.Error())
}

184
schema/extension.go Normal file
View File

@@ -0,0 +1,184 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"bytes"
"context"
"encoding/json"
"github.com/pkg/errors"
"github.com/ory/jsonschema/v3"
"github.com/ory/kratos/embedx"
"github.com/ory/x/jsonschemax"
)
const (
ExtensionName string = "ory.sh/kratos"
)
type (
ExtensionConfig struct {
Credentials struct {
Password struct {
Identifier bool `json:"identifier"`
} `json:"password"`
WebAuthn struct {
Identifier bool `json:"identifier"`
} `json:"webauthn"`
Passkey struct {
DisplayName bool `json:"display_name"`
} `json:"passkey"`
TOTP struct {
AccountName bool `json:"account_name"`
} `json:"totp"`
Code struct {
Identifier bool `json:"identifier"`
Via string `json:"via"`
} `json:"code"`
} `json:"credentials"`
Verification struct {
Via string `json:"via"`
} `json:"verification"`
Recovery struct {
Via string `json:"via"`
} `json:"recovery"`
Organization struct {
Matcher string `json:"matcher"`
} `json:"organizations"`
RawSchema map[string]interface{} `json:"-"`
}
ValidateExtension interface {
Run(ctx jsonschema.ValidationContext, config ExtensionConfig, value interface{}) error
Finish() error
}
CompileExtension interface {
Run(ctx jsonschema.CompilerContext, config ExtensionConfig, rawSchema map[string]interface{}) error
}
ExtensionRunner struct {
meta *jsonschema.Schema
compile func(ctx jsonschema.CompilerContext, rawSchema map[string]interface{}) (interface{}, error)
validate func(ctx jsonschema.ValidationContext, s interface{}, v interface{}) error
validateRunners []ValidateExtension
compileRunners []CompileExtension
}
ExtensionRunnerOption func(*ExtensionRunner)
)
func (e *ExtensionConfig) EnhancePath(path jsonschemax.Path) map[string]any {
props := path.CustomProperties
if props == nil {
props = make(map[string]any)
}
props[ExtensionName] = e
return props
}
func WithValidateRunners(runners ...ValidateExtension) ExtensionRunnerOption {
return func(r *ExtensionRunner) {
r.validateRunners = append(r.validateRunners, runners...)
}
}
func WithCompileRunners(runners ...CompileExtension) ExtensionRunnerOption {
return func(r *ExtensionRunner) {
r.compileRunners = append(r.compileRunners, runners...)
}
}
func NewExtensionRunner(ctx context.Context, opts ...ExtensionRunnerOption) (*ExtensionRunner, error) {
var err error
r := new(ExtensionRunner)
// The extension runner compiles the bundled identity-extension meta
// schema, which contains no external refs. The disallow flag has no
// effect here, but we pass false to keep behavior strictly equivalent
// to the legacy jsonschema.NewCompiler().
c := NewCompiler(false)
if err = embedx.AddSchemaResources(c, embedx.IdentityExtension); err != nil {
return nil, errors.WithStack(err)
}
r.meta, err = c.Compile(ctx, embedx.IdentityExtension.GetSchemaID())
if err != nil {
return nil, errors.WithStack(err)
}
r.compile = func(ctx jsonschema.CompilerContext, m map[string]interface{}) (interface{}, error) {
if raw, ok := m[ExtensionName]; ok {
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(raw); err != nil {
return nil, errors.WithStack(err)
}
var e ExtensionConfig
if err := json.NewDecoder(&b).Decode(&e); err != nil {
return nil, errors.WithStack(err)
}
for _, runner := range r.compileRunners {
if err := runner.Run(ctx, e, m); err != nil {
return nil, err
}
}
e.RawSchema = m
return &e, nil
}
return nil, nil
}
r.validate = func(ctx jsonschema.ValidationContext, s interface{}, v interface{}) error {
c, ok := s.(*ExtensionConfig)
if !ok {
return nil
}
for _, runner := range r.validateRunners {
if err := runner.Run(ctx, *c, v); err != nil {
return err
}
}
return nil
}
for _, opt := range opts {
opt(r)
}
return r, nil
}
func (r *ExtensionRunner) Register(compiler *jsonschema.Compiler) *ExtensionRunner {
compiler.Extensions[ExtensionName] = r.Extension()
return r
}
func (r *ExtensionRunner) Extension() jsonschema.Extension {
return jsonschema.Extension{
Meta: r.meta,
Compile: r.compile,
Validate: r.validate,
}
}
func (r *ExtensionRunner) AddRunner(run ValidateExtension) *ExtensionRunner {
r.validateRunners = append(r.validateRunners, run)
return r
}
func (r *ExtensionRunner) Finish() error {
for _, runner := range r.validateRunners {
if err := runner.Finish(); err != nil {
return err
}
}
return nil
}

89
schema/extension_test.go Normal file
View File

@@ -0,0 +1,89 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"bytes"
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/jsonschema/v3"
_ "github.com/ory/jsonschema/v3/fileloader"
)
type extensionStub struct {
identifiers []string
accountNames []string
}
func (r *extensionStub) Run(ctx jsonschema.ValidationContext, config ExtensionConfig, value interface{}) error {
if config.Credentials.Password.Identifier {
r.identifiers = append(r.identifiers, fmt.Sprintf("%s", value))
}
if config.Credentials.TOTP.AccountName {
r.accountNames = append(r.accountNames, fmt.Sprintf("%s", value))
}
return nil
}
func (r *extensionStub) Finish() error {
return nil
}
var ctx = context.Background()
func TestExtensionRunner(t *testing.T) {
t.Run("method=get identifier", func(t *testing.T) {
for k, tc := range []struct {
expectErr error
schema string
doc string
expect []string
}{
{
doc: `{"email":"foo@ory.sh"}`,
schema: "file://./stub/extension/schema.json",
expect: []string{"foo@ory.sh"},
},
{
doc: `{"emails":["foo@ory.sh","bar@ory.sh"]}`,
schema: "file://./stub/extension/schema.nested.json",
expect: []string{"foo@ory.sh", "bar@ory.sh"},
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
c := jsonschema.NewCompiler()
runner, err := NewExtensionRunner(ctx)
require.NoError(t, err)
r := new(extensionStub)
runner.AddRunner(r).Register(c)
err = c.MustCompile(ctx, tc.schema).Validate(bytes.NewBufferString(tc.doc))
if tc.expectErr != nil {
require.EqualError(t, err, tc.expectErr.Error())
}
assert.EqualValues(t, tc.expect, r.identifiers)
assert.EqualValues(t, tc.expect, r.accountNames)
})
}
})
t.Run("method=applies meta schema", func(t *testing.T) {
c := jsonschema.NewCompiler()
runner, err := NewExtensionRunner(ctx)
require.NoError(t, err)
runner.Register(c)
_, err = c.Compile(ctx, "file://./stub/extension/invalid.schema.json")
assert.Error(t, err)
assert.Contains(t, err.Error(), "expected boolean, but got number")
})
}

265
schema/handler.go Normal file
View File

@@ -0,0 +1,265 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"strings"
"github.com/hashicorp/go-retryablehttp"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/x"
"github.com/ory/kratos/x/nosurfx"
"github.com/ory/kratos/x/redir"
"github.com/ory/x/errorsx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/pagination/migrationpagination"
)
type (
handlerDependencies interface {
httpx.WriterProvider
logrusx.Provider
IdentitySchemaProvider
nosurfx.CSRFProvider
config.Provider
otelx.Provider
httpx.ClientProvider
}
Handler struct {
r handlerDependencies
}
HandlerProvider interface {
SchemaHandler() *Handler
}
)
func NewHandler(r handlerDependencies) *Handler {
return &Handler{r: r}
}
const (
SchemasPath string = "schemas"
maxSchemaSize = 1024 * 1024 // 1 MB
)
func (h *Handler) RegisterPublicRoutes(public *httprouterx.RouterPublic) {
h.r.CSRFHandler().IgnoreGlobs(
"/"+SchemasPath+"/*",
httprouterx.AdminPrefix+"/"+SchemasPath+"/*",
)
public.GET(fmt.Sprintf("/%s/{id}", SchemasPath), h.getIdentitySchema)
public.GET(fmt.Sprintf("/%s", SchemasPath), h.getAll)
public.GET(fmt.Sprintf("%s/%s/{id}", httprouterx.AdminPrefix, SchemasPath), h.getIdentitySchema)
public.GET(fmt.Sprintf("%s/%s", httprouterx.AdminPrefix, SchemasPath), h.getAll)
}
func (h *Handler) RegisterAdminRoutes(admin *httprouterx.RouterAdmin) {
admin.GET(fmt.Sprintf("/%s/{id}", SchemasPath), redir.RedirectToPublicRoute(h.r))
admin.GET(fmt.Sprintf("/%s", SchemasPath), redir.RedirectToPublicRoute(h.r))
}
// Raw JSON Schema
//
// swagger:model identitySchema
type _ json.RawMessage
// Get Identity JSON Schema Response
//
// swagger:parameters getIdentitySchema
type _ struct {
// ID must be set to the ID of schema you want to get
//
// required: true
// in: path
ID string `json:"id"`
}
// swagger:route GET /schemas/{id} identity getIdentitySchema
//
// # Get Identity JSON Schema
//
// Return a specific identity schema.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: identitySchema
// 404: errorGeneric
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-medium
func (h *Handler) getIdentitySchema(w http.ResponseWriter, r *http.Request) {
ctx, span := h.r.Tracer(r.Context()).Tracer().Start(r.Context(), "schema.Handler.getIdentitySchema")
defer span.End()
ss, err := h.r.IdentityTraitsSchemas(ctx)
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
id := r.PathValue("id")
s, err := ss.GetByID(id)
if err != nil {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrNotFound().WithReasonf("Identity schema `%s` could not be found.", id)))
return
}
raw, err := h.ReadSchema(ctx, s.URL)
if err != nil {
code, ok := errorsx.GetCodeFromHerodotError(err)
if errors.Is(err, fs.ErrNotExist) || (ok && code == http.StatusNotFound) {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrMisconfiguration().WithReason("The file for this JSON Schema ID could not be found/fetched. This is a configuration issue.").WithDebugf("%+v", err)))
} else if ok && code == http.StatusBadGateway {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrUpstreamError().WithReason("The file for this JSON Schema ID could not be fetched. This is an upstream issue.").WithDebugf("%+v", err)))
} else {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrInternalServerError().WithReason("The file for this JSON Schema ID could not be read. This is an I/O issue.").WithDebugf("%+v", err)))
}
return
}
w.Header().Add("Content-Type", "application/json")
h.r.Writer().Write(w, r, json.RawMessage(raw))
}
// List of Identity JSON Schemas
//
// swagger:model identitySchemas
type IdentitySchemas []identitySchemaContainer
// An Identity JSON Schema Container
//
// swagger:model identitySchemaContainer
type identitySchemaContainer struct {
// The ID of the Identity JSON Schema
// required: true
ID string `json:"id"`
// The actual Identity JSON Schema
// required: true
Schema json.RawMessage `json:"schema"`
}
// List Identity JSON Schemas Response
//
// swagger:parameters listIdentitySchemas
type _ struct {
migrationpagination.RequestParameters
}
// List Identity JSON Schemas Response
//
// swagger:response identitySchemas
type _ struct {
migrationpagination.ResponseHeaderAnnotation
// in: body
Body IdentitySchemas
}
// swagger:route GET /schemas identity listIdentitySchemas
//
// # Get all Identity Schemas
//
// Returns a list of all identity schemas currently in use.
//
// Produces:
// - application/json
//
// Schemes: http, https
//
// Responses:
// 200: identitySchemas
// default: errorGeneric
//
// Extensions:
// x-ory-ratelimit-bucket: kratos-admin-medium
func (h *Handler) getAll(w http.ResponseWriter, r *http.Request) {
ctx, span := h.r.Tracer(r.Context()).Tracer().Start(r.Context(), "schema.Handler.getAll")
defer span.End()
page, itemsPerPage := x.ParsePagination(r)
allSchemas, err := h.r.IdentityTraitsSchemas(r.Context())
if err != nil {
h.r.Writer().WriteError(w, r, err)
return
}
total := allSchemas.Total()
schemas := allSchemas.List(page, itemsPerPage)
ss := make(IdentitySchemas, len(schemas))
for i, schema := range schemas {
raw, err := h.ReadSchema(ctx, schema.URL)
if err != nil {
h.r.Writer().WriteError(w, r, errors.WithStack(herodot.ErrMisconfiguration().WithReasonf("The file for a JSON Schema ID could not be found or opened. This is a configuration issue.").WithWrap(err)))
return
}
ss[i] = identitySchemaContainer{
ID: schema.ID,
Schema: raw,
}
}
x.PaginationHeader(w, *r.URL, int64(total), page, itemsPerPage)
h.r.Writer().Write(w, r, ss)
}
func (h *Handler) ReadSchema(ctx context.Context, uri *url.URL) (data []byte, err error) {
ctx, span := h.r.Tracer(ctx).Tracer().Start(ctx, "schema.Handler.ReadSchema")
defer otelx.End(span, &err)
switch uri.Scheme {
case "file":
data, err = os.ReadFile(uri.Host + uri.Path) //nolint:gosec
if err != nil {
return nil, errors.WithStack(fmt.Errorf("could not read schema file: %w", err))
}
case "base64":
data, err = base64.StdEncoding.DecodeString(strings.TrimPrefix(uri.String(), "base64://"))
if err != nil {
return nil, errors.WithStack(fmt.Errorf("could not decode schema file: %w", err))
}
default:
req, err := retryablehttp.NewRequestWithContext(ctx, http.MethodGet, uri.String(), nil)
if err != nil {
return nil, errors.WithStack(fmt.Errorf("could not create request: %w", err))
}
resp, err := h.r.HTTPClient(ctx).Do(req)
if err != nil {
return nil, errors.WithStack(herodot.ErrUpstreamError().WithReason("could not fetch schema").WithError(err.Error()).WithDetail("uri", uri))
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return nil, herodot.ErrNotFound().WithDetail("url", uri)
}
return nil, errors.WithStack(herodot.ErrUpstreamError().WithError("upstream error").WithDetail("status_code", resp.StatusCode).WithDetail("uri", uri))
}
data, err = io.ReadAll(io.LimitReader(resp.Body, maxSchemaSize))
if err != nil {
return nil, errors.WithStack(herodot.ErrUpstreamError().WithReason("could not read schema response").WithError(err.Error()).WithDetail("uri", uri))
}
}
return data, nil
}

232
schema/handler_test.go Normal file
View File

@@ -0,0 +1,232 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema_test
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/client-go"
_ "github.com/ory/jsonschema/v3/fileloader"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/pkg"
"github.com/ory/x/configx"
"github.com/ory/x/contextx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/urlx"
)
func TestHandler(t *testing.T) {
router := httprouterx.NewRouterPublic()
ts := contextx.NewConfigurableTestServer(router)
t.Cleanup(ts.Close)
mux := http.NewServeMux()
mux.HandleFunc("GET /identity.schema.json", func(w http.ResponseWriter, r *http.Request) {
file, err := os.Open("./stub/identity.schema.json")
require.NoError(t, err)
_, err = io.Copy(w, file)
require.NoError(t, err)
})
mux.HandleFunc("GET /500", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
})
fileServer := httptest.NewServer(mux)
t.Cleanup(fileServer.Close)
schemas := map[string]struct {
uri string
getRaw func() ([]byte, error)
expectedHttpResponseCode int
}{
"default": {
uri: "file://./stub/identity.schema.json",
getRaw: func() ([]byte, error) { return os.ReadFile("./stub/identity.schema.json") },
expectedHttpResponseCode: http.StatusOK,
},
"identity2": {
uri: "file://./stub/identity-2.schema.json",
getRaw: func() ([]byte, error) { return os.ReadFile("./stub/identity-2.schema.json") },
expectedHttpResponseCode: http.StatusOK,
},
"base64": {
uri: "base64://ewogICIkc2NoZW1hIjogImh0dHA6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQtMDcvc2NoZW1hIyIsCiAgInR5cGUiOiAib2JqZWN0IiwKICAicHJvcGVydGllcyI6IHsKICAgICJiYXIiOiB7CiAgICAgICJ0eXBlIjogInN0cmluZyIKICAgIH0KICB9LAogICJyZXF1aXJlZCI6IFsKICAgICJiYXIiCiAgXQp9",
getRaw: func() ([]byte, error) {
return base64.StdEncoding.DecodeString("ewogICIkc2NoZW1hIjogImh0dHA6Ly9qc29uLXNjaGVtYS5vcmcvZHJhZnQtMDcvc2NoZW1hIyIsCiAgInR5cGUiOiAib2JqZWN0IiwKICAicHJvcGVydGllcyI6IHsKICAgICJiYXIiOiB7CiAgICAgICJ0eXBlIjogInN0cmluZyIKICAgIH0KICB9LAogICJyZXF1aXJlZCI6IFsKICAgICJiYXIiCiAgXQp9")
},
expectedHttpResponseCode: http.StatusOK,
},
"unreachable": {
uri: "http://127.0.0.1:12345/unreachable-schema",
getRaw: func() ([]byte, error) {
return nil, fmt.Errorf("connection refused")
},
expectedHttpResponseCode: http.StatusBadGateway,
},
"no-file": {
uri: "file://./stub/does-not-exist.schema.json",
getRaw: func() ([]byte, error) { return nil, fmt.Errorf("no such file or directory") },
expectedHttpResponseCode: http.StatusInternalServerError,
},
"directory": {
uri: "file://./stub",
getRaw: func() ([]byte, error) { return nil, fmt.Errorf("is a directory") },
// On an existing directory, `open(2)` succeeds but `read(2)` fails so it looks like an I/O error.
expectedHttpResponseCode: http.StatusInternalServerError,
},
"file-network": {
uri: fileServer.URL + "/identity.schema.json",
getRaw: func() ([]byte, error) { return os.ReadFile("./stub/identity.schema.json") },
expectedHttpResponseCode: http.StatusOK,
},
"file-network-not-found": {
uri: fileServer.URL + "/not-found",
getRaw: func() ([]byte, error) { return nil, fmt.Errorf("could not be found") },
expectedHttpResponseCode: http.StatusInternalServerError,
},
"file-network-500": {
uri: fileServer.URL + "/500",
getRaw: func() ([]byte, error) { return nil, fmt.Errorf("giving up") },
expectedHttpResponseCode: http.StatusBadGateway,
},
"preset://email": {
uri: "file://./stub/identity-2.schema.json",
getRaw: func() ([]byte, error) { return os.ReadFile("./stub/identity-2.schema.json") },
expectedHttpResponseCode: http.StatusOK,
},
}
configSchemas := make(config.Schemas, 0, len(schemas))
for id, s := range schemas {
configSchemas = append(configSchemas, config.Schema{
ID: id,
URL: s.uri,
})
}
_, reg := pkg.NewFastRegistryWithMocks(t, configx.WithValues(map[string]any{
config.ViperKeyPublicBaseURL: ts.URL,
config.ViperKeyDefaultIdentitySchemaID: "default",
config.ViperKeyIdentitySchemas: configSchemas,
}))
reg.SchemaHandler().RegisterPublicRoutes(router)
getReq := func(ctx context.Context, t *testing.T, path string, expectCode int) []byte {
res, err := ts.Client(ctx).Get(ts.URL + path)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
require.NoError(t, err)
require.NoError(t, res.Body.Close())
require.EqualValuesf(t, expectCode, res.StatusCode, "%s", body)
return body
}
for id, s := range schemas {
t.Run(fmt.Sprintf("case=get %s schema", id), func(t *testing.T) {
_, err := s.getRaw()
actual := getReq(t.Context(), t, fmt.Sprintf("/schemas/%s", url.PathEscape(id)), s.expectedHttpResponseCode)
require.True(t, json.Valid(actual), string(actual))
switch s.expectedHttpResponseCode {
case http.StatusOK:
require.NoError(t, err)
case http.StatusInternalServerError, http.StatusBadGateway:
default:
panic("unreachable")
}
})
}
t.Run("case=get schema with base64 encoded ID", func(t *testing.T) {
t.Parallel()
expected, err := schemas["preset://email"].getRaw()
require.NoError(t, err)
actual := getReq(t.Context(), t, "/schemas/"+base64.RawURLEncoding.EncodeToString([]byte("preset://email")), http.StatusOK)
require.JSONEq(t, string(expected), string(actual))
})
t.Run("case=get all schemas", func(t *testing.T) {
t.Parallel()
defaultSchema, err := configSchemas.FindSchemaByID("default")
require.NoError(t, err)
identity2Schema, err := configSchemas.FindSchemaByID("identity2")
require.NoError(t, err)
ctx := contextx.WithConfigValue(t.Context(), config.ViperKeyIdentitySchemas, config.Schemas{*defaultSchema, *identity2Schema})
getSchemasPaginated := func(t *testing.T, page, perPage, expectCode int) []byte {
return getReq(ctx, t, fmt.Sprintf("/schemas?page=%d&per_page=%d", page, perPage), expectCode)
}
body := getSchemasPaginated(t, 0, 10, http.StatusOK)
var result []client.IdentitySchemaContainer
require.NoErrorf(t, json.Unmarshal(body, &result), "%s", body)
var actualIDs []string
for _, s := range result {
actualIDs = append(actualIDs, s.Id)
}
assert.Equal(t, []string{defaultSchema.ID, identity2Schema.ID}, actualIDs)
assertCorrectSchema := func(t *testing.T, r client.IdentitySchemaContainer) {
expected, err := schemas[r.Id].getRaw()
require.NoError(t, err)
actual, err := json.Marshal(r.Schema)
require.NoError(t, err)
assert.JSONEq(t, string(expected), string(actual))
}
for _, r := range result {
assertCorrectSchema(t, r)
}
for page := range 2 {
t.Run(fmt.Sprintf("page=%d", page), func(t *testing.T) {
body := getSchemasPaginated(t, page, 1, http.StatusOK)
var result []client.IdentitySchemaContainer
require.NoError(t, json.Unmarshal(body, &result))
require.Len(t, result, 1)
assert.Equal(t, actualIDs[page], result[0].Id)
assertCorrectSchema(t, result[0])
})
}
})
t.Run("case=read schema", func(t *testing.T) {
t.Parallel()
for _, s := range schemas {
expected, expectedErr := s.getRaw()
actual, err := reg.SchemaHandler().ReadSchema(t.Context(), urlx.ParseOrPanic(s.uri))
if expectedErr == nil {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, expectedErr.Error()) // not using error.is because some of the errors are not accessible
}
if expectedErr == nil {
require.JSONEq(t, string(expected), string(actual))
}
}
})
}

108
schema/loader.go Normal file
View File

@@ -0,0 +1,108 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/url"
_ "github.com/ory/jsonschema/v3/base64loader"
_ "github.com/ory/jsonschema/v3/fileloader"
"github.com/ory/jsonschema/v3/httploader"
"github.com/pkg/errors"
"github.com/ory/jsonschema/v3"
"github.com/ory/x/httpx"
)
// loadRefURL resolves the URL of a `$ref` inside a schema. It enforces the
// scheme allowlist and then delegates to the jsonschema package's global
// loader table. The global `file` loader remains registered so that
// operator-configured top-level schema URLs (resolved outside the compiler)
// keep working.
func loadRefURL(ctx context.Context, raw string) (io.ReadCloser, error) {
u, err := url.Parse(raw)
if err != nil {
return nil, fmt.Errorf("invalid $ref URL %q: %w", raw, err)
}
if u.Scheme != "base64" {
return nil, fmt.Errorf("$ref scheme %q is not permitted in identity schemas", u.Scheme)
}
return jsonschema.LoadURL(ctx, raw)
}
// NewCompiler returns a jsonschema.Compiler. When disallowRefs is true, the
// compiler rejects `file://` URLs (and any other non-allowlisted scheme) in
// `$ref` values, preventing an attacker-supplied schema from reading local
// files on the Kratos host. When disallowRefs is false, the compiler uses
// the jsonschema library's default loader table, which preserves legacy
// behavior for operators who intentionally reference local files.
//
// The flag is controlled by `security.disallow_ref_in_identity_schemas`.
// Ory Network forces it on.
func NewCompiler(disallowRefs bool) *jsonschema.Compiler {
c := jsonschema.NewCompiler()
if disallowRefs {
c.LoadURL = loadRefURL
}
return c
}
// NewCompilerWithURL returns a NewCompiler with the top-level schema at
// schemaURL already loaded and registered via the trusted global loader
// (which always supports `file://` for operator-configured top-level URLs,
// regardless of disallowRefs). See NewCompiler for the semantics of
// disallowRefs.
func NewCompilerWithURL(ctx context.Context, schemaURL string, disallowRefs bool) (*jsonschema.Compiler, error) {
ctx = ensureGuardedHTTPClient(ctx)
resource, err := jsonschema.LoadURL(ctx, schemaURL)
if err != nil {
return nil, errors.WithStack(err)
}
defer func() { _ = resource.Close() }()
// Read the body with a hard size cap so a malicious schema URL cannot
// OOM kratos by returning a multi-GB body.
data, err := io.ReadAll(io.LimitReader(resource, MaxSchemaBodyBytes+1))
if err != nil {
return nil, errors.WithStack(err)
}
if len(data) > MaxSchemaBodyBytes {
return nil, errors.Errorf("identity schema rejected: body exceeds %d bytes", MaxSchemaBodyBytes)
}
// Decode once for structural prevalidation. The upstream compiler will
// decode again from the same bytes — accepting a small CPU duplication
// in exchange for the security gate.
var doc any
if err := json.Unmarshal(data, &doc); err != nil {
return nil, errors.WithStack(err)
}
if err := preValidateSchema(doc); err != nil {
return nil, errors.WithStack(err)
}
c := NewCompiler(disallowRefs)
if err := c.AddResource(schemaURL, bytes.NewReader(data)); err != nil {
return nil, errors.WithStack(err)
}
return c, nil
}
var defaultHTTPClient = httpx.NewResilientClient(httpx.ResilientClientDisallowInternalIPs())
// ensureGuardedHTTPClient guarantees that the jsonschema httploader will
// pick up a client whose dialer rejects internal IP ranges.
func ensureGuardedHTTPClient(ctx context.Context) context.Context {
if ctx.Value(httploader.ContextKey) != nil {
return ctx
}
return context.WithValue(ctx, httploader.ContextKey, defaultHTTPClient)
}

173
schema/prevalidate.go Normal file
View File

@@ -0,0 +1,173 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"fmt"
"regexp"
"slices"
"strconv"
"strings"
)
// MaxSchemaBodyBytes caps the raw byte size of an identity schema fetched
// from any source. Documents above this size cannot be cached or compiled.
// The cap protects against memory exhaustion from a customer-controlled URL
// returning an arbitrarily large body. It is independent of the schema's
// internal structure: realistic identity schemas are well under this limit.
const MaxSchemaBodyBytes = 1 << 20 // 1 MiB.
// preValidateSchema walks a parsed identity schema document and rejects
// patterns that would let a customer-supplied schema crash kratos at
// compile or validate time.
//
// Specifically, this function rejects:
//
// - Any cycle in the document's `$ref` chain graph: a sequence of `$ref`
// nodes P₀ → P₁ → … → Pₙ → P₀ in which every step is a `$ref`. The
// upstream compiler memoizes ref resolution to terminate compilation,
// but the resulting `*Schema` graph contains the cycle, and at
// validate time `s.Ref.validate(v)` recurses indefinitely and crashes
// the process via fatal stack overflow (jsonschema/v3
// schema.go:155-169 has no validate-time cycle guard, and stack
// overflow is unrecoverable).
// - A `pattern` or `patternProperties` key whose value is not a valid
// regular expression — the upstream compiler uses regexp.MustCompile,
// which panics, and the compile path has no panic recovery.
//
// Cycles whose intermediate nodes have non-`$ref` validation (e.g. a
// `$ref` to an ancestor that contains a `properties` map) are NOT
// rejected: each cycle iteration consumes at least one level of input, so
// recursion is bounded by JSON parser depth. Detecting the dangerous
// pure-ref subgraph is the goal.
//
// This function does not bound the schema's structural size (nesting
// depth, key count, array arity). Realistic identity schemas vary widely,
// and bounding them at the loader layer rejected legitimate documents.
// The body size cap (MaxSchemaBodyBytes) plus Go stdlib's JSON nesting
// limit (10000) provide the structural backstop.
func preValidateSchema(doc any) error {
v := &preValidator{refs: map[string]string{}}
if err := v.walk(doc, ""); err != nil {
return err
}
return v.detectRefCycles()
}
type preValidator struct {
// refs maps each `$ref` location's JSON-pointer path to its target
// JSON-pointer path. Targets that cannot be resolved as in-document
// fragments (external URLs, malformed refs) are excluded — those go
// through the loadRefURL scheme allowlist.
refs map[string]string
}
func (p *preValidator) walk(v any, path string) error {
switch v := v.(type) {
case map[string]any:
// Record `$ref` for cycle detection in detectRefCycles. Root
// pointers (`#`, `#/`, empty) map to the empty path. Anything
// without a `#/` prefix is external — out of scope here;
// loadRefURL handles scheme enforcement.
if ref, ok := v["$ref"].(string); ok {
switch {
case ref == "" || ref == "#" || ref == "#/":
p.refs[path] = ""
case strings.HasPrefix(ref, "#/"):
p.refs[path] = strings.TrimPrefix(ref, "#")
}
}
// Pre-compile `pattern` regexes so an invalid one returns a
// kratos-side error instead of panicking deep in
// regexp.MustCompile during the upstream compile.
if pat, ok := v["pattern"].(string); ok {
if _, err := regexp.Compile(pat); err != nil {
return fmt.Errorf("identity schema rejected: invalid regex in pattern: %w", err)
}
}
// patternProperties keys are themselves regexes.
if patternProps, ok := v["patternProperties"].(map[string]any); ok {
for raw := range patternProps {
if _, err := regexp.Compile(raw); err != nil {
return fmt.Errorf("identity schema rejected: invalid regex in patternProperties key %q: %w", raw, err)
}
}
}
for k, sub := range v {
if err := p.walk(sub, path+"/"+escapeJSONPointer(k)); err != nil {
return err
}
}
case []any:
for i, sub := range v {
if err := p.walk(sub, path+"/"+strconv.Itoa(i)); err != nil {
return err
}
}
}
return nil
}
// detectRefCycles reports an error if the document's `$ref` chain graph
// contains a cycle. Walking from each `$ref` location, follow the target
// path. If the target is itself a `$ref` location, continue. If the chain
// revisits a location, the resulting `*Schema` graph has a cycle that
// crashes Validate via stack overflow.
//
// The chain ends as soon as it reaches a node that is not itself a `$ref`
// — that node has its own validation logic (`properties`, `type`, etc.)
// which consumes input on each cycle iteration, so the recursion is
// bounded. Only pure `$ref` chains form unbounded loops.
func (p *preValidator) detectRefCycles() error {
for start := range p.refs {
visited := map[string]struct{}{}
cur := start
var chain []string
for {
if _, ok := visited[cur]; ok {
idx := slices.Index(chain, cur)
cycle := append(chain[idx:], cur)
return fmt.Errorf("identity schema rejected: self-referential $ref cycle: %s",
formatRefCycle(cycle))
}
visited[cur] = struct{}{}
chain = append(chain, cur)
next, ok := p.refs[cur]
if !ok {
break
}
cur = next
}
}
return nil
}
// escapeJSONPointer encodes a property name as a JSON-pointer reference
// token (RFC 6901): `~` → `~0`, `/` → `~1`. The order matters — `~` must be
// escaped first so a literal `/` does not collide with the escape produced
// for `~`.
func escapeJSONPointer(s string) string {
s = strings.ReplaceAll(s, "~", "~0")
s = strings.ReplaceAll(s, "/", "~1")
return s
}
// formatRefCycle renders a path slice as a human-readable arrow chain,
// rendering the empty (root) path as "#".
func formatRefCycle(paths []string) string {
parts := make([]string, len(paths))
for i, p := range paths {
if p == "" {
parts[i] = "#"
} else {
parts[i] = "#" + p
}
}
return strings.Join(parts, " → ")
}

178
schema/schema.go Normal file
View File

@@ -0,0 +1,178 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"cmp"
"context"
"encoding/base64"
"io"
"net/url"
"strings"
"sync"
"github.com/pkg/errors"
"github.com/tidwall/gjson"
"github.com/ory/herodot"
"github.com/ory/jsonschema/v3"
"github.com/ory/kratos/driver/config"
"github.com/ory/x/pagination"
"github.com/ory/x/urlx"
)
var _ IdentitySchemaList = (*Schemas)(nil)
type Schemas []Schema
type IdentitySchemaProvider interface {
IdentityTraitsSchemas(ctx context.Context) (IdentitySchemaList, error)
}
type deps interface {
config.Provider
}
type DefaultIdentitySchemaProvider struct {
d deps
}
func NewDefaultIdentityTraitsProvider(d deps) *DefaultIdentitySchemaProvider {
return &DefaultIdentitySchemaProvider{d: d}
}
func (d *DefaultIdentitySchemaProvider) IdentityTraitsSchemas(ctx context.Context) (IdentitySchemaList, error) {
ms, err := d.d.Config().IdentityTraitsSchemas(ctx)
if err != nil {
return nil, err
}
var ss Schemas
for _, s := range ms {
surl, err := url.Parse(s.URL)
if err != nil {
return nil, errors.WithStack(err)
}
ss = append(ss, Schema{
ID: s.ID,
URL: surl,
RawURL: s.URL,
})
}
return ss, nil
}
type IdentitySchemaList interface {
GetByID(id string) (*Schema, error)
Total() int
List(page, perPage int) Schemas
}
func (s Schemas) GetByID(id string) (*Schema, error) {
id = cmp.Or(id, config.DefaultIdentityTraitsSchemaID)
if ss, ok := s.findSchemaByID(id); ok {
return ss, nil
}
if decodedID, ok := TryDecodeID(id); ok {
if ss, ok := s.findSchemaByID(decodedID); ok {
return ss, nil
}
}
return nil, errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unable to find JSON Schema ID: %s", id))
}
func (s Schemas) Total() int {
return len(s)
}
func (s Schemas) List(page, perPage int) Schemas {
if page < 0 {
page = 0
}
if perPage < 1 {
perPage = 1
}
start, end := pagination.Index((page+1)*perPage, page*perPage, len(s))
return s[start:end]
}
func (s Schemas) findSchemaByID(id string) (*Schema, bool) {
for _, ss := range s {
if ss.ID == id {
return &ss, true
}
}
return nil, false
}
var (
orderedKeyCacheMutex sync.RWMutex
orderedKeyCache map[string][]string
)
func init() {
orderedKeyCache = make(map[string][]string)
}
func computeKeyPositions(schema []byte, dest *[]string, parents []string) {
switch gjson.GetBytes(schema, "type").String() {
case "object":
gjson.GetBytes(schema, "properties").ForEach(func(key, value gjson.Result) bool {
computeKeyPositions([]byte(value.Raw), dest, append(parents, strings.ReplaceAll(key.String(), ".", "\\.")))
return true
})
default:
*dest = append(*dest, strings.Join(parents, "."))
}
}
func GetKeysInOrder(ctx context.Context, schemaRef string) ([]string, error) {
orderedKeyCacheMutex.RLock()
keysInOrder, ok := orderedKeyCache[schemaRef]
orderedKeyCacheMutex.RUnlock()
if !ok {
sio, err := jsonschema.LoadURL(ctx, schemaRef)
if err != nil {
return nil, errors.WithStack(err)
}
schema, err := io.ReadAll(io.LimitReader(sio, 1024*1024))
if err != nil {
return nil, errors.WithStack(err)
}
computeKeyPositions(schema, &keysInOrder, []string{})
orderedKeyCacheMutex.Lock()
orderedKeyCache[schemaRef] = keysInOrder
orderedKeyCacheMutex.Unlock()
}
return keysInOrder, nil
}
type Schema struct {
ID string `json:"id"`
URL *url.URL `json:"-"`
// RawURL contains the raw URL value as it was passed in the configuration. URL parsing can break base64 encoded URLs.
RawURL string `json:"url"`
}
func (s *Schema) SchemaURL(host *url.URL) *url.URL {
return IDToURL(host, s.ID)
}
func IDToURL(host *url.URL, id string) *url.URL {
return urlx.AppendPaths(host, SchemasPath, base64.RawURLEncoding.EncodeToString([]byte(id)))
}
func TryDecodeID(encoded string) (string, bool) {
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return "", false
}
return string(decoded), true
}

43
schema/schema_test.go Normal file
View File

@@ -0,0 +1,43 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema_test
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/pkg"
"github.com/ory/kratos/schema"
schematest "github.com/ory/kratos/schema/test"
)
func TestDefaultIdentityTraitsProvider(t *testing.T) {
_, reg := pkg.NewFastRegistryWithMocks(t)
schematest.TestIdentitySchemaProvider(t, schema.NewDefaultIdentityTraitsProvider(reg))
}
func TestGetKeysInOrder(t *testing.T) {
for i, tc := range []struct {
schemaRef string
keys []string
path string
}{
{schemaRef: "file://./stub/identity.schema.json", keys: []string{"bar", "email"}},
{schemaRef: "file://./stub/complex.schema.json", keys: []string{
"meal.name", "meal.chef", "traits.email",
"traits.stringy", "traits.numby", "traits.booly", "traits.should_big_number", "traits.should_long_string",
"fruits", "vegetables",
}},
} {
t.Run(fmt.Sprintf("case=%d schemaRef=%s", i, tc.schemaRef), func(t *testing.T) {
actual, err := schema.GetKeysInOrder(context.Background(), tc.schemaRef)
require.NoError(t, err)
assert.Equal(t, tc.keys, actual)
})
}
}

View File

@@ -0,0 +1,87 @@
{
"$id": "https://example.com/complex.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["meal"],
"properties": {
"meal": {
"type": "object",
"required": ["name"],
"properties": {
"name": {
"type": "string"
},
"chef": {
"type": "string"
}
}
},
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
},
"verification": {
"via": "email"
}
}
},
"stringy": {
"type": "string"
},
"numby": {
"type": "number"
},
"booly": {
"type": "boolean"
},
"should_big_number": {
"type": "number",
"minimum": 1200
},
"should_long_string": {
"type": "string",
"minLength": 25
}
}
},
"fruits": {
"type": "array",
"items": {
"type": "string"
}
},
"vegetables": {
"type": "array",
"items": {
"$ref": "#/definitions/veggie"
}
}
},
"definitions": {
"veggie": {
"type": "object",
"required": [
"veggieName",
"veggieLike"
],
"properties": {
"veggieName": {
"type": "string"
},
"veggieLike": {
"type": "boolean"
},
"veggieAmount": {
"type": "number"
}
}
}
}
}

View File

@@ -0,0 +1,16 @@
{
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": 0
}
}
}
}
}
}

View File

@@ -0,0 +1,19 @@
{
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"totp": {
"account_name": true
}
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
{
"type": "object",
"properties": {
"emails": {
"type": "array",
"items": {
"type": "string",
"format": "email",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
},
"totp": {
"account_name": true
}
}
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"bar": {
"type": "string"
}
}
}

View File

@@ -0,0 +1,21 @@
{
"$id": "https://example.com/registration.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"bar": {
"type": "string"
},
"email": {
"type": "string",
"ory.sh/kratos": {
"credentials": {
"password": {
"identifier": true
}
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
{
"$id": "https://example.com/firstName.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"firstName": {
"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
}

View File

@@ -0,0 +1,22 @@
{
"$id": "https://example.com/whatever.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Person",
"type": "object",
"properties": {
"whatever": {
"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
}

View File

@@ -0,0 +1,103 @@
// Copyright © 2025 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package test
import (
"encoding/base64"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/schema"
"github.com/ory/x/contextx"
"github.com/ory/x/urlx"
)
func TestIdentitySchemaProvider(t *testing.T, provider schema.IdentitySchemaProvider) {
urlFromID := func(id string) string {
return fmt.Sprintf("http://%s.com", id)
}
schemas := schema.Schemas{
{ID: "foo"},
{ID: "preset://email"},
{ID: config.DefaultIdentityTraitsSchemaID},
}
for i := range schemas {
raw := urlFromID(schemas[i].ID)
schemas[i].RawURL = raw
schemas[i].URL = urlx.ParseOrPanic(raw)
}
ctx := contextx.WithConfigValues(t.Context(), map[string]any{
config.ViperKeyIdentitySchemas: func() (cs config.Schemas) {
for _, s := range schemas {
cs = append(cs, config.Schema{
ID: s.ID,
URL: s.RawURL,
})
}
return cs
}(),
})
list, err := provider.IdentityTraitsSchemas(ctx)
require.NoError(t, err)
t.Run("GetByID", func(t *testing.T) {
t.Run("case=get with raw schemaID", func(t *testing.T) {
for _, schema := range schemas {
actual, err := list.GetByID(schema.ID)
require.NoError(t, err)
require.Equal(t, schema, *actual)
}
})
t.Run("case=get with encoded schemaID", func(t *testing.T) {
for _, schema := range schemas {
encodedID := base64.RawURLEncoding.EncodeToString([]byte(schema.ID))
actual, err := list.GetByID(encodedID)
require.NoError(t, err)
require.Equal(t, schema, *actual)
}
})
t.Run("case=get default schema", func(t *testing.T) {
s1, err := list.GetByID("")
require.NoError(t, err)
s2, err := list.GetByID(config.DefaultIdentityTraitsSchemaID)
require.NoError(t, err)
assert.Equal(t, &schemas[2], s1)
assert.Equal(t, &schemas[2], s2)
})
t.Run("case=should return error on not existing id", func(t *testing.T) {
s, err := list.GetByID("not existing id")
require.Error(t, err)
assert.Equal(t, (*schema.Schema)(nil), s)
})
})
t.Run("List", func(t *testing.T) {
t.Run("case=get all schemas", func(t *testing.T) {
p0 := list.List(0, 4)
assert.Equal(t, schemas, p0)
})
t.Run("case=smaller pages", func(t *testing.T) {
p0, p1 := list.List(0, 2), list.List(1, 2)
assert.Equal(t, schemas, append(p0, p1...))
})
t.Run("case=indexes out of range", func(t *testing.T) {
p0 := list.List(-1, 10)
assert.Equal(t, schemas, p0)
})
})
}

91
schema/validator.go Normal file
View File

@@ -0,0 +1,91 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"bytes"
"context"
"encoding/json"
"sync"
"github.com/pkg/errors"
"github.com/ory/herodot"
"github.com/ory/jsonschema/v3"
)
type Validator struct {
sync.RWMutex
}
type ValidationProvider interface {
SchemaValidator() *Validator
}
func NewValidator() *Validator {
return &Validator{}
}
type validatorOptions struct {
e *ExtensionRunner
disallowRefs bool
}
func WithExtensionRunner(e *ExtensionRunner) func(*validatorOptions) {
return func(o *validatorOptions) {
o.e = e
}
}
// WithDisallowRefs toggles the restriction on `$ref` URL schemes. When true,
// the compiler rejects `$ref` values whose scheme is not `http`, `https`, or
// `base64`, preventing local-file reads via `file://`. Callers should forward
// the `security.disallow_ref_in_identity_schemas` config value here.
func WithDisallowRefs(disallow bool) func(*validatorOptions) {
return func(o *validatorOptions) {
o.disallowRefs = disallow
}
}
func (v *Validator) Validate(
ctx context.Context,
href string,
document json.RawMessage,
opts ...func(*validatorOptions),
) error {
var o validatorOptions
for _, opt := range opts {
opt(&o)
}
compiler, err := NewCompilerWithURL(ctx, href, o.disallowRefs)
if err != nil {
return errors.WithStack(herodot.ErrMisconfiguration().WithReasonf("Unable to load or parse the JSON schema.").WithWrap(err).WithDebugf("%s", err))
}
if o.e != nil {
o.e.Register(compiler)
}
schema, err := compiler.Compile(ctx, href)
if err != nil {
return errors.WithStack(herodot.ErrMisconfiguration().WithReasonf("Unable to parse validate JSON object against JSON schema.").WithWrap(err).WithDebugf("%s", err))
}
// we decode explicitly here, so we can handle the error, and it is not lost in the schema validation
dec, err := jsonschema.DecodeJSON(bytes.NewBuffer(document))
if err != nil {
return errors.WithStack(herodot.ErrBadRequest().WithReasonf("Unable to parse validate JSON object against JSON schema.").WithWrap(err).WithDebugf("%s", err))
}
if err := schema.ValidateInterface(dec); err != nil {
return errors.WithStack(err)
}
if o.e != nil {
return o.e.Finish()
}
return nil
}

416
schema/validator_test.go Normal file
View File

@@ -0,0 +1,416 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package schema
import (
"bytes"
"cmp"
"context"
"encoding/base64"
"encoding/json"
stderrors "errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"github.com/ory/herodot"
"github.com/ory/jsonschema/v3/httploader"
"github.com/ory/x/httpx"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSchemaValidator(t *testing.T) {
router := http.NewServeMux()
fs := http.StripPrefix("/schema", http.FileServer(http.Dir("stub/validator")))
router.HandleFunc("/schema/{name}", func(w http.ResponseWriter, r *http.Request) {
fs.ServeHTTP(w, r)
})
ts := httptest.NewServer(router)
defer ts.Close()
ctx := context.WithValue(ctx, httploader.ContextKey, httpx.NewResilientClient())
for k, tc := range []struct {
i json.RawMessage
err string
u string
}{
{
i: json.RawMessage(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
},
{
i: json.RawMessage(`{ "firstName": "first-name", "lastName": "last-name", "age": -1 }`),
err: "I[#/age] S[#/properties/age/minimum] must be >= 1 but found -1",
},
{
i: json.RawMessage(`{ "whatever": "first-name", "lastName": "last-name", "age": 1 }`),
err: `I[#] S[#/additionalProperties] additionalProperties "whatever" not allowed`,
},
{
u: ts.URL + "/schema/whatever.schema.json",
i: json.RawMessage(`{ "whatever": "first-name", "lastName": "last-name", "age": 1 }`),
},
{
u: ts.URL + "/schema/whatever.schema.json",
i: json.RawMessage(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
err: `I[#] S[#/additionalProperties] additionalProperties "firstName" not allowed`,
},
{
u: ts.URL,
i: json.RawMessage(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
err: "Invalid configuration",
},
{
u: "not-a-url",
i: json.RawMessage(`{ "firstName": "first-name", "lastName": "last-name", "age": 1 }`),
err: "Invalid configuration",
},
} {
t.Run(fmt.Sprintf("case=%d", k), func(t *testing.T) {
err := NewValidator().Validate(ctx, cmp.Or(tc.u, ts.URL+"/schema/firstName.schema.json"), tc.i)
if tc.err == "" {
require.NoError(t, err)
} else {
require.EqualError(t, err, tc.err)
}
})
}
}
// TestSchemaValidator_FileRefExfiltration asserts that identity schemas
// cannot exfiltrate server-side files via `$ref: "file://..."`. The jsonschema
// `file` loader is registered process-wide so operator-configured top-level
// `file://` schemas still work, but per-compiler $ref resolution must reject
// the `file` scheme.
func TestSchemaValidator_FileRefExfiltration(t *testing.T) {
t.Parallel()
dir := t.TempDir()
secretContents := `"LEAKED_COOKIE_SECRET_VALUE"`
schemaWithRef := func(refURL string) string {
return fmt.Sprintf(`{
"$id": "https://test.example.com/probe.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": {
"password": {"identifier": true}
}
}
},
"field": {"$ref": %q}
},
"required": ["email"]
}
}
}`, refURL)
}
serveSchema := func(t *testing.T, body string) string {
mux := http.NewServeMux()
mux.HandleFunc("/schema", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
return ts.URL + "/schema"
}
ctx := context.WithValue(context.Background(), httploader.ContextKey, httpx.NewResilientClient())
t.Run("case=file ref is rejected before any file access", func(t *testing.T) {
t.Parallel()
// Fragment that would pass validation if the file were read, and fail
// if not. Its contents are a valid JSON-schema fragment.
schemaFragment := fmt.Sprintf(`{"type": "string", "const": %s}`, secretContents)
fragmentPath := filepath.Join(dir, "fragment.json")
require.NoError(t, os.WriteFile(fragmentPath, []byte(schemaFragment), 0o600))
u := serveSchema(t, schemaWithRef("file://"+fragmentPath))
t.Run("with disallowRefs=true", func(t *testing.T) {
err := NewValidator().Validate(ctx, u,
json.RawMessage(`{"traits": {"email": "a@b.c", "field": "wrong"}}`),
WithDisallowRefs(true))
require.Error(t, err)
// The file scheme must be rejected, and the file contents must not
// leak into the error.
var he *herodot.DefaultError
require.True(t, stderrors.As(err, &he))
assert.Contains(t, he.Debug(), `"file"`, "rejected scheme should appear in cause")
assert.NotContains(t, he.Error(), "LEAKED_COOKIE_SECRET_VALUE")
assert.NotContains(t, he.Debug(), "LEAKED_COOKIE_SECRET_VALUE")
})
t.Run("with disallowRefs=false preserves legacy exfiltration", func(t *testing.T) {
// When the feature flag is off (default for existing deployments),
// the legacy behavior must remain: the $ref is dereferenced and
// the file contents flow into validation errors. This documents
// the intentional opt-in nature of the mitigation.
err := NewValidator().Validate(ctx, u,
json.RawMessage(`{"traits": {"email": "a@b.c", "field": "wrong"}}`))
require.Error(t, err)
assert.Contains(t, err.Error(), "LEAKED_COOKIE_SECRET_VALUE",
"with the flag off, the file contents should still be reachable — this test documents the exploitable default")
})
})
}
// TestSchemaValidator_RejectsSelfReferentialRef asserts that an identity
// schema whose `$ref` resolves back to the same schema (e.g. `{"$ref":"#"}`)
// is rejected at compile time.
//
// Background: github.com/ory/jsonschema/v3 has compile-time cycle detection
// (compiler.go:139-202 caches the *Schema in r.schemas[ref] before recursing,
// so compilation terminates), but the resulting *Schema has its `Ref` field
// pointing at itself. At validate time, schema.go:155-169 unconditionally
// invokes s.Ref.validate(v) — for the self-pointer case this recurses
// indefinitely until the goroutine stack hits Go's 1GB limit and the runtime
// raises `fatal error: stack overflow`. That fatal is **not recoverable**
// via recover(); it terminates the entire process. A customer who can
// upload an identity schema with a self-pointer can therefore crash any
// kratos pod that compiles and validates against it.
//
// The compile path is the safe place to test this. Calling
// NewValidator().Validate would crash the test binary — see the comment in
// the body. The only legitimate fix is a kratos-side pre-parse gate that
// walks the parsed schema tree, resolves each `$ref`, and rejects refs whose
// target is the same schema (or any schema ancestor that produces an
// equivalent self-cycle).
func TestSchemaValidator_RejectsSelfReferentialRef(t *testing.T) {
t.Parallel()
const schemaJSON = `{"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#"}`
schemaURL := "base64://" + base64.StdEncoding.EncodeToString([]byte(schemaJSON))
ctx := context.WithValue(t.Context(), httploader.ContextKey, httpx.NewResilientClient())
// The kratos-side prevalidate gate (schema/prevalidate.go) rejects the
// schema at NewCompilerWithURL time, before the upstream compiler
// produces a *Schema with Ref pointing at itself. That is the safest
// place to fail: every caller of NewCompilerWithURL is protected, and
// no path can construct the self-pointer that crashes Validate.
_, err := NewCompilerWithURL(ctx, schemaURL, true)
require.Error(t, err,
"self-referential $ref ({\"$ref\":\"#\"}) must be rejected at the loader, "+
"before producing a *Schema whose Ref field points at itself")
assert.Contains(t, err.Error(), "self-referential $ref")
// End-to-end sanity: Validate must also reject and must not crash the
// process via fatal stack overflow. With the gate in place, Validate's
// internal call to NewCompilerWithURL fails before reaching the validate
// path that would otherwise infinite-recurse on s.Ref == s.
err = NewValidator().Validate(ctx, schemaURL,
json.RawMessage(`{}`),
WithDisallowRefs(true))
require.Error(t, err)
}
// TestSchemaValidator_PreValidate exercises the kratos-side pre-parse gate
// for identity schemas. Each subtest constructs a schema that hits one of
// the documented checks in prevalidate.go and asserts that NewValidator()
// rejects it before the upstream compiler runs.
//
// The gate enforces $ref-cycle detection and pattern-regex pre-compilation
// only. Structural counters (nesting depth, key count, array arity, total
// nodes) are intentionally not enforced here — realistic identity schemas
// vary widely and any limit would either be too tight for legitimate
// documents or too loose to add value above Go stdlib's JSON depth cap.
func TestSchemaValidator_PreValidate(t *testing.T) {
t.Parallel()
loadCtx := func(t *testing.T) context.Context {
t.Helper()
return context.WithValue(t.Context(), httploader.ContextKey, httpx.NewResilientClient())
}
cases := []struct {
name string
schemaJSON string
errFragment string
}{
{
name: "self-referential $ref to root",
schemaJSON: `{"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#"}`,
errFragment: "self-referential $ref",
},
{
name: "self-referential $ref to root, second attempt",
schemaJSON: `{
"properties": {
"traits": {
"properties": {
"name": {
"$ref": "#/properties/traits/properties/name"
}
}
}
},
"$schema": "http://json-schema.org/draft-07/schema#"
}`,
errFragment: "self-referential $ref",
},
{
name: "indirect $ref cycle a → b → a",
schemaJSON: `{
"$schema": "http://json-schema.org/draft-07/schema#",
"$defs": {
"a": {"$ref": "#/$defs/b"},
"b": {"$ref": "#/$defs/a"}
},
"$ref": "#/$defs/a"
}`,
errFragment: "self-referential $ref cycle",
},
{
name: "invalid regex in pattern",
schemaJSON: `{"$schema":"http://json-schema.org/draft-07/schema#","type":"string","pattern":"(*invalid"}`,
errFragment: "invalid regex in pattern",
},
{
name: "invalid regex in patternProperties key",
schemaJSON: `{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","patternProperties":{"(*invalid":{"type":"string"}}}`,
errFragment: "invalid regex in patternProperties",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
schemaURL := "base64://" + base64.StdEncoding.EncodeToString([]byte(c.schemaJSON))
err := NewValidator().Validate(loadCtx(t), schemaURL,
json.RawMessage(`{"traits":{"name":"value"}}`),
WithDisallowRefs(true))
require.Error(t, err)
assert.Contains(t, fmt.Sprintf("%+v", err), c.errFragment,
"expected error to mention %q, got %v", c.errFragment, err)
})
}
}
// TestSchemaValidator_AcceptsRealisticDeepSchema asserts that a schema with
// realistic structural complexity is accepted. Earlier hardening rejected
// documents above 32 nesting levels / 1024 keys / 128 array elements; that
// proved too tight for legitimate operator schemas. This test pins the
// looser policy: the gate must allow these dimensions, and only fail on
// the targeted unsafe shapes (cycles, invalid regex).
func TestSchemaValidator_AcceptsRealisticDeepSchema(t *testing.T) {
t.Parallel()
const depth = 64
var sb strings.Builder
sb.WriteString(`{"$schema":"http://json-schema.org/draft-07/schema#","type":"object","properties":{"traits":`)
for range depth {
sb.WriteString(`{"type":"object","properties":{"x":`)
}
sb.WriteString(`{"type":"string"}`)
for range depth {
sb.WriteString(`}}`)
}
sb.WriteString(`}}`)
schemaURL := "base64://" + base64.StdEncoding.EncodeToString([]byte(sb.String()))
ctx := context.WithValue(t.Context(), httploader.ContextKey, httpx.NewResilientClient())
_, err := NewCompilerWithURL(ctx, schemaURL, true)
require.NoError(t, err,
"schema with %d nesting levels must be accepted; the prevalidate gate "+
"intentionally does not enforce a depth cap", depth)
}
// TestSchemaValidator_BodyTooLarge asserts that a schema URL whose response
// body exceeds MaxSchemaBodyBytes is rejected before any parse or compile
// runs. This guards against memory exhaustion when a customer-controlled
// URL serves a multi-megabyte (or larger) body.
func TestSchemaValidator_BodyTooLarge(t *testing.T) {
t.Parallel()
body := bytes.Repeat([]byte("X"), MaxSchemaBodyBytes+1024)
mux := http.NewServeMux()
mux.HandleFunc("/big.schema.json", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
// httptest binds 127.0.0.1 — that is a private IP for the SSRF guard.
// Use a non-guarded resilient client so we exercise the size cap, not
// the SSRF guard.
ctx := context.WithValue(t.Context(), httploader.ContextKey, httpx.NewResilientClient())
err := NewValidator().Validate(ctx, ts.URL+"/big.schema.json",
json.RawMessage(`{}`),
WithDisallowRefs(true))
require.Error(t, err)
assert.Contains(t, fmt.Sprintf("%+v", err), "body exceeds")
}
// TestLoadRefURL pins the actual scheme allowlist enforced by loadRefURL.
// The implementation only accepts base64. Any future relaxation must
// update this test.
func TestLoadRefURL(t *testing.T) {
t.Parallel()
cases := []struct {
name string
raw string
wantSub string
}{
{"file scheme rejected", "file:///etc/passwd", `scheme "file"`},
{"http scheme rejected", "http://example.com/schema.json", `scheme "http"`},
{"https scheme rejected", "https://example.com/schema.json", `scheme "https"`},
{"data scheme rejected", "data:application/json,{}", `scheme "data"`},
{"empty scheme rejected", "/relative/path", `scheme ""`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Parallel()
_, err := loadRefURL(t.Context(), c.raw)
require.Error(t, err)
assert.Contains(t, err.Error(), c.wantSub)
})
}
}
// TestEnsureGuardedHTTPClient_PrivateIPRejected verifies that a context
// without an attached HTTP client gets a default SSRF-guarded one when
// passed to NewCompilerWithURL. A schema URL pointing at httptest's
// loopback listener (127.0.0.1) must be rejected by the dialer before any
// HTTP request hits the test server.
func TestEnsureGuardedHTTPClient_PrivateIPRejected(t *testing.T) {
t.Parallel()
var hit atomic.Bool
mux := http.NewServeMux()
mux.HandleFunc("/schema.json", func(w http.ResponseWriter, r *http.Request) {
hit.Store(true)
_, _ = w.Write([]byte(`{"type":"object"}`))
})
ts := httptest.NewServer(mux)
t.Cleanup(ts.Close)
// Bare context — no httploader.ContextKey attached. NewCompilerWithURL
// must attach a guarded default client and refuse the loopback dial.
_, err := NewCompilerWithURL(t.Context(), ts.URL+"/schema.json", true)
require.Error(t, err, "fetch via SSRF-guarded default client must reject loopback addresses")
assert.False(t, hit.Load(),
"server handler was invoked — SSRF guard did not block the request")
}