Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/spelling-wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ ValidationError
# 0th, sigh...
th
amongst
backreference
backreferences
callables
# non-codeblocked cls from autoapi
cls
Expand All @@ -26,6 +28,7 @@ iterable
iteratively
Javascript
jsonschema
lookahead
majorly
metaschema
online
Expand Down
146 changes: 146 additions & 0 deletions docs/validate.rst
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,15 @@ The JSON Schema specification `recommends (but does not require) <https://json-s

Given that there is no current library in Python capable of supporting the ECMA 262 dialect, the ``regex`` format will instead validate *Python* regular expressions, which are the ones used by this implementation for other keywords like :kw:`pattern` or :kw:`patternProperties`.

.. note::

The ``regex`` format checker currently uses Python's :mod:`re` module
regardless of any custom `regex provider <regex-providers>` that may be
in use. A pattern accepted by Python's :mod:`re` but rejected by a
custom engine — such as a backreference when using RE2 — will pass the
format check but fail at match time. See :ref:`regex-providers` for
details.

email
^^^^^

Expand All @@ -307,3 +316,140 @@ Since in most cases "validating" an email address is an attempt instead to confi
The same applies to the ``idn-email`` format.

If you indeed want a particular well-specified set of emails to be considered valid, you can use `FormatChecker.checks` to provide your specific definition.


.. _regex-providers:

Regex Providers
---------------

``jsonschema`` uses regular expressions when validating schemas.
By default, these operations use Python's built-in :mod:`re` module.

Python's :mod:`re` engine is vulnerable to catastrophic (or "pathological") backtracking,
which occurs when a regular expression and an input
cause the :mod:`re` module to run for exponentially long periods of time.
If you are validating user-submitted input against user-supplied schemas,
you may want to use a regular expression engine that isn't vulnerable.


The ``RegexProvider`` Protocol
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

A regex provider is any class that satisfies the
`jsonschema.protocols.RegexProvider` protocol.

Two methods are required, as well as a class attribute named ``raises``:

``raises: tuple[type[Exception], ...]``
A tuple of exception classes that might be raised
when interacting with the regex provider.

``compile(pattern: str)``
Compile ``pattern`` and return the result.

This is expected to be equivalent to the behavior of
the builtin ``re.compile()`` function.

``search(pattern, text: str)``
Search for ``text`` in the ``pattern``.

``pattern`` may be a string, or the result returned by ``compile()``, above.

This is expected to be equivalent to the behavior of
the builtin ``re.search()`` function.

Depending on your use case and the features or drawbacks of the regex engine you're using,
it may be beneficial to use caching.


Installing a Provider at the Class Level
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Pass a regex provider instance to `jsonschema.validators.extend`
to derive a validator class that uses your regex provider:

.. code-block:: python

from jsonschema import validators

SafeValidator = validators.extend(
validators.Draft202012Validator,
regex_provider=MyProvider(),
)

All instances of ``SafeValidator`` will use ``MyProvider`` by default.
The same argument is accepted by `jsonschema.validators.create`
when building a validator class from scratch.


Per-Instance Override
~~~~~~~~~~~~~~~~~~~~~

Pass a provider instance to the validator constructor
to override the default for a single validator instance:

.. code-block:: python

validator = Draft202012Validator(schema, regex_provider=MyProvider())


Critical pitfalls
~~~~~~~~~~~~~~~~~

The ``{"format": "regex"}`` format checker in ``jsonschema``
is hard-coded to use Python's :mod:`re` module
to determine whether a string is a valid regular expression.
It currently cannot access the regex provider you designate,
so its validity check may not align with what your regex engine supports.

At the current time, the only solution is to override
the default ``"regex"`` format checker with a custom function.

You cannot use the ``extend`` function's ``format_checker`` argument
because when ``Validator.check_schema`` is called
``jsonschema`` creates a new validator class
that will not honor that argument ``format_checker``.
Therefore, always override the class attribute ``FORMAT_CHECKER`` directly:

.. code-block:: python

def is_regex(instance: str) -> bool:
if not isinstance(instance, str):
return True
return bool(re2.compile(_instance))

MyValidator.FORMAT_CHECKER.checkers["regex"] = (is_regex, (MyException,))


Example: ``google-re2``
~~~~~~~~~~~~~~~~~~~~~~~

`Google RE2 <https://github.com/google/re2>`_ is a regex engine
that is not vulnerable to catastrophic backtracking
(it simply doesn't support features that require backtracking,
like backreferences or lookahead assertions).
It is available as the ``google-re2`` package on PyPI
and exposes a :mod:`re`-compatible Python API.

.. code-block:: python

import jsonschema
import re2

class Re2Provider(jsonschema.protocols.RegexProvider):
raises = (re2.error,)
compile = staticmethod(re2.compile)
search = staticmethod(re2.search)

SafeValidator = jsonschema.validators.extend(
jsonschema.validators.Draft202012Validator,
regex_provider=Re2Provider(),
)

def is_regex(instance: str) -> bool:
if not isinstance(instance, str):
return True
return bool(re2.compile(instance))

SafeValidator.FORMAT_CHECKER.checkers["regex"] = (is_regex, Re2Provider.raises)
7 changes: 3 additions & 4 deletions jsonschema/_keywords.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from fractions import Fraction
import re

from jsonschema._utils import (
ensure_list,
Expand All @@ -19,7 +18,7 @@ def patternProperties(validator, patternProperties, instance, schema):

for pattern, subschema in patternProperties.items():
for k, v in instance.items():
if re.search(pattern, k):
if validator.regex_provider.search(pattern, k):
yield from validator.descend(
v, subschema, path=k, schema_path=pattern,
)
Expand All @@ -37,7 +36,7 @@ def additionalProperties(validator, aP, instance, schema):
if not validator.is_type(instance, "object"):
return

extras = set(find_additional_properties(instance, schema))
extras = set(find_additional_properties(validator, instance, schema))

if validator.is_type(aP, "object"):
for extra in extras:
Expand Down Expand Up @@ -215,7 +214,7 @@ def uniqueItems(validator, uI, instance, schema):
def pattern(validator, patrn, instance, schema):
if (
validator.is_type(instance, "string")
and not re.search(patrn, instance)
and not validator.regex_provider.search(patrn, instance)
):
yield ValidationError(f"{instance!r} does not match {patrn!r}")

Expand Down
4 changes: 1 addition & 3 deletions jsonschema/_legacy_keywords.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import re

from referencing.jsonschema import lookup_recursive_ref

from jsonschema import _utils
Expand Down Expand Up @@ -380,7 +378,7 @@ def find_evaluated_property_keys_by_schema(validator, instance, schema):
if "patternProperties" in schema:
for property in instance:
for pattern in schema["patternProperties"]:
if re.search(pattern, property):
if validator.regex_provider.search(pattern, property):
evaluated_keys.append(property)

if "dependentSchemas" in schema:
Expand Down
9 changes: 9 additions & 0 deletions jsonschema/_regex_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import re

from jsonschema.protocols import RegexProvider


class PythonRegexProvider(RegexProvider):
raises = (re.error,)
compile = staticmethod(re.compile) # type: ignore[assignment]
search = staticmethod(re.search) # type: ignore[assignment]
8 changes: 4 additions & 4 deletions jsonschema/_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from collections.abc import Mapping, MutableMapping, Sequence
from urllib.parse import urlsplit
import re

# Module-level sentinels so recursive `_uniq_key` calls produce comparable
# keys for nested True/False (function-default sentinels would also work, but
Expand Down Expand Up @@ -72,7 +71,7 @@ def format_as_index(container, indices):
return f"{container}[{']['.join(repr(index) for index in indices)}]"


def find_additional_properties(instance, schema):
def find_additional_properties(validator, instance, schema):
"""
Return the set of additional properties for the given ``instance``.

Expand All @@ -83,9 +82,10 @@ def find_additional_properties(instance, schema):
"""
properties = schema.get("properties", {})
patterns = "|".join(schema.get("patternProperties", {}))
_search = validator.regex_provider.search
for property in instance:
if property not in properties:
if patterns and re.search(patterns, property):
if patterns and _search(patterns, property):
continue
yield property

Expand Down Expand Up @@ -370,7 +370,7 @@ def find_evaluated_property_keys_by_schema(validator, instance, schema):
if "patternProperties" in schema:
for property in instance:
for pattern in schema["patternProperties"]:
if re.search(pattern, property):
if validator.regex_provider.search(pattern, property):
evaluated_keys.append(property)

if "dependentSchemas" in schema:
Expand Down
42 changes: 42 additions & 0 deletions jsonschema/protocols.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ class Validator(Protocol):
its `extra (optional) dependencies <index:extras>` when
invoking ``pip``.

regex_provider:

if provided, this is the `RegexProvider` that will be used
when validating regex-based constructs, like ``patternProperties``.

.. deprecated:: v4.12.0

Subclassing validator classes now explicitly warns this is not part of
Expand All @@ -99,6 +104,10 @@ class Validator(Protocol):
#: :kw:`format` keywords in JSON schemas.
FORMAT_CHECKER: ClassVar[jsonschema.FormatChecker]

#: A `jsonschema.protocols.RegexProvider` that will be used
#: when handling
REGEX_PROVIDER: ClassVar[jsonschema.protocols.RegexProvider]

#: A function which given a schema returns its ID.
ID_OF: _typing.id_of

Expand All @@ -112,6 +121,7 @@ def __init__(
format_checker: jsonschema.FormatChecker | None = None,
*,
registry: referencing.jsonschema.SchemaRegistry = ...,
regex_provider: jsonschema.protocols.RegexProvider = ...,
) -> None: ...

@classmethod
Expand Down Expand Up @@ -228,3 +238,35 @@ def evolve(self, **kwargs) -> Validator:
... )
Draft7Validator(schema=..., format_checker=None)
"""


class Match(Protocol):
"""Protocol for a regex match result."""

def __bool__(self) -> bool: ...


class Pattern(Protocol):
"""Protocol for a compiled regex pattern."""

def __bool__(self) -> bool: ...


class RegexProvider(Protocol):
"""Protocol for a regular expression engine provider."""

raises: ClassVar[tuple[type[Exception], ...]]

def compile(self, pattern: str, /) -> Pattern:
"""
Compile the given pattern into a Pattern object.

Any exception raised must be listed in the *raises* attribute.
"""

def search(self, pattern: str | Pattern, text: str, /) -> Match | None:
"""
Find text in the given pattern, or return None.

Any exception raised must be listed in the *raises* attribute.
"""
Loading