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
174 lines
5.9 KiB
Go
174 lines
5.9 KiB
Go
// 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, " → ")
|
|
}
|