Skip to content

Commit cfe16c9

Browse files
committed
Limit OpenAPI 3.0 discovery to reachable reference scopes
1 parent 96ae4fe commit cfe16c9

2 files changed

Lines changed: 319 additions & 54 deletions

File tree

‎openapi_schema_validator/_references.py‎

Lines changed: 159 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
from typing import Mapping
55
from typing import Sequence
66
from typing import cast
7+
from urllib.parse import urldefrag
8+
from urllib.parse import urljoin
79
from urllib.parse import urlsplit
810

911
from jsonschema.exceptions import SchemaError
@@ -33,19 +35,26 @@ def _checked_oas30_scope_id(contents: Any) -> str | None:
3335
return DRAFT4.id_of(contents)
3436

3537

36-
_SCOPED_DRAFT4_NAME = "openapi-3.0-scoped-draft4"
3738
_SCHEMA_MAP_KEYS = {
3839
"definitions",
3940
"dependencies",
4041
"patternProperties",
4142
"properties",
4243
}
4344
_SCHEMA_ARRAY_KEYS = {"allOf", "anyOf", "oneOf"}
45+
_OAS30_APPLICATORS = _SCHEMA_ARRAY_KEYS | {
46+
"additionalProperties",
47+
"items",
48+
"not",
49+
"properties",
50+
}
4451

4552

4653
def _uses_draft4(resource: Resource[Any]) -> bool:
4754
specification = resource._specification
48-
return specification is DRAFT4 or specification.name == _SCOPED_DRAFT4_NAME
55+
return specification is DRAFT4 or isinstance(
56+
specification, _OAS30DiscoverySpecification
57+
)
4958

5059

5160
def _discoverable_id(contents: Any) -> str:
@@ -73,43 +82,68 @@ def _discoverable_subresources(
7382
and (key not in _SCHEMA_ARRAY_KEYS or isinstance(value, (list, tuple)))
7483
}
7584
for child in DRAFT4.subresources_of(shaped):
76-
if not isinstance(child, Mapping):
77-
continue
78-
if "$schema" in child:
79-
if "$ref" in child:
80-
child = dict(child)
81-
del child["$schema"]
82-
elif not isinstance(child["$schema"], str):
83-
continue
84-
yield child
85+
if isinstance(child, Mapping) and (
86+
"$ref" in child
87+
or "$schema" not in child
88+
or isinstance(child["$schema"], str)
89+
):
90+
yield child
8591

8692

87-
def _scoped_resource(
88-
contents: Any, *, id_already_applied: bool
89-
) -> Resource[Any]:
90-
# Crawling a resource registered at its resolved URI must not apply
91-
# its relative id again. Ignore malformed metadata during discovery.
92-
def id_of(schema: Any) -> str | None:
93+
class _ForeignDialectDiscovery(Exception):
94+
"""Preflight cannot establish scope through another dialect's crawl."""
95+
96+
97+
class _OAS30DiscoverySpecification:
98+
"""Keep Draft 4 discovery guarded without changing schema contents."""
99+
100+
name = "openapi-3.0-discovery"
101+
subresources_of = staticmethod(_discoverable_subresources)
102+
maybe_in_subresource = staticmethod(DRAFT4.maybe_in_subresource)
103+
104+
def __init__(
105+
self, contents: Any, id_already_applied: bool, preflight: bool
106+
) -> None:
107+
self._scoped_contents = contents if id_already_applied else None
108+
self.preflight = preflight
109+
110+
def id_of(self, schema: Any) -> str | None:
93111
identifier = _discoverable_id(schema)
94112
if (
95-
(id_already_applied and schema is contents)
113+
schema is self._scoped_contents
96114
or not identifier
97115
or identifier.startswith("#")
98116
or "$ref" in schema
99117
):
100118
return None
101119
return identifier
102120

103-
specification: Specification[Any] = cast(Any, Specification)(
104-
name=_SCOPED_DRAFT4_NAME,
105-
id_of=id_of,
106-
subresources_of=_discoverable_subresources,
107-
anchors_in=lambda _, schema: (
108-
DRAFT4.anchors_in(schema)
109-
if _discoverable_id(schema).startswith("#")
110-
else ()
111-
),
112-
maybe_in_subresource=DRAFT4.maybe_in_subresource,
121+
def anchors_in(self, schema: Any) -> Any:
122+
if _discoverable_id(schema).startswith("#"):
123+
return DRAFT4.anchors_in(schema)
124+
return ()
125+
126+
def detect(self, contents: Mapping[str, Any]) -> Any:
127+
if "$ref" in contents:
128+
return self
129+
detected = DRAFT4.detect(contents)
130+
if detected is DRAFT4:
131+
return self
132+
if self.preflight:
133+
raise _ForeignDialectDiscovery
134+
return detected
135+
136+
def create_resource(self, contents: Any) -> Resource[Any]:
137+
# Resource.from_contents calls detect() and then create_resource().
138+
create_resource = cast(Callable[[Any, Any], Resource[Any]], Resource)
139+
return create_resource(contents, self)
140+
141+
142+
def _scoped_resource(
143+
contents: Any, *, id_already_applied: bool, preflight: bool = False
144+
) -> Resource[Any]:
145+
specification = _OAS30DiscoverySpecification(
146+
contents, id_already_applied, preflight
113147
)
114148
return specification.create_resource(contents)
115149

@@ -120,6 +154,7 @@ def prepare_oas30_resolver(
120154
schema: Any,
121155
*,
122156
id_already_applied: bool = False,
157+
preflight: bool = False,
123158
) -> Any:
124159
uri = resolver._base_uri
125160
resource = scoped._registry.get(uri)
@@ -134,7 +169,10 @@ def prepare_oas30_resolver(
134169
# Existing resources may be registered at retrieval aliases, even if
135170
# the alias equals the raw relative id. Only owned scopes suppress it.
136171
registry = scoped._registry.with_resource(
137-
uri, _scoped_resource(schema, id_already_applied=id_already_applied)
172+
uri,
173+
_scoped_resource(
174+
schema, id_already_applied=id_already_applied, preflight=preflight
175+
),
138176
)
139177
return scoped._evolve(base_uri=scoped._base_uri, registry=registry)
140178

@@ -146,8 +184,17 @@ def _register_scope(resolver: Any, scoped: Any, contents: Any) -> Any:
146184

147185
# Register the reached scope so local lookups can use it without
148186
# discovering resources in ignored siblings.
187+
parent = resolver._registry.get(resolver._base_uri)
188+
specification = parent._specification if parent is not None else None
189+
preflight = (
190+
isinstance(specification, _OAS30DiscoverySpecification)
191+
and specification.preflight
192+
)
149193
registry = scoped._registry.with_resource(
150-
uri, _scoped_resource(contents, id_already_applied=True)
194+
uri,
195+
_scoped_resource(
196+
contents, id_already_applied=True, preflight=preflight
197+
),
151198
)
152199
return scoped._evolve(base_uri=uri, registry=registry)
153200

@@ -199,7 +246,10 @@ def _enter_subresource(resolver: Any, schema: Mapping[str, Any]) -> Any:
199246
)
200247

201248

202-
def _resolve_pointer(resolver: Any, ref: str) -> Any:
249+
def _resolve_reference(resolver: Any, ref: str) -> Any:
250+
if not ref.startswith("#/"):
251+
return resolver.lookup(ref)
252+
203253
document = resolver.lookup("#")
204254
resource = document.resolver._registry[document.resolver._base_uri]
205255
if _uses_draft4(resource):
@@ -209,6 +259,40 @@ def _resolve_pointer(resolver: Any, ref: str) -> Any:
209259
return resource.pointer(ref[1:], document.resolver)
210260

211261

262+
def _preflight_discovery(
263+
resolver: Any,
264+
ref: str,
265+
cache: dict[int, tuple[Registry[Any], Registry[Any] | None]],
266+
) -> Any:
267+
registry = resolver._registry
268+
if ref.startswith("#/") or not registry._uncrawled:
269+
return resolver
270+
271+
base_uri = resolver._base_uri
272+
if ref.startswith("#"):
273+
uri, fragment = base_uri, ref[1:]
274+
else:
275+
uri, fragment = urldefrag(urljoin(base_uri, ref))
276+
if uri in registry and (
277+
not fragment
278+
or fragment.startswith("/")
279+
or (uri, fragment) in registry._anchors
280+
):
281+
return resolver
282+
283+
# An unresolved lookup drops its crawled registry. Retain discovery so
284+
# multiple unresolved refs do not each scan the same document.
285+
identity = id(registry)
286+
if identity not in cache:
287+
try:
288+
crawled = registry.crawl()
289+
except _ForeignDialectDiscovery:
290+
crawled = None
291+
cache[identity] = (registry, crawled)
292+
_, crawled = cache[identity]
293+
return None if crawled is None else crawled.resolver(base_uri)
294+
295+
212296
def declares_other_schema_dialect(schema: Any) -> bool:
213297
# OAS 3.0 validators are not registered with jsonschema. A recognized
214298
# dialect declaration changes the runtime class unless it is a ref sibling.
@@ -249,7 +333,7 @@ def check_oas30_local_references(
249333
check_schema: Callable[[Any], None],
250334
registry: Registry[Any] | None,
251335
) -> None:
252-
# A target can be stored under an ignored sibling. Follow local pointers
336+
# A target can be stored under an ignored sibling. Follow local refs
253337
# separately from structural validation, using the untouched document.
254338
# Use an isolated registry for the walk. The caller's registry is only
255339
# consulted for ownership of already-registered scope URIs.
@@ -258,46 +342,70 @@ def check_oas30_local_references(
258342
DRAFT4.create_resource(schema)
259343
)
260344
resolver = prepare_oas30_resolver(
261-
resolver, resolver, schema, id_already_applied=True
345+
resolver, resolver, schema, id_already_applied=True, preflight=True
262346
)
263347
root_uri = resolver._base_uri
264-
pending = [(schema, resolver)]
265-
visited: set[tuple[int, str]] = set()
348+
pending: list[
349+
tuple[Mapping[str, Any], Any, tuple[Mapping[str, Any], ...]]
350+
] = [(schema, resolver, ())]
351+
visited: set[tuple[int, str, tuple[int, ...]]] = set()
352+
discovery: dict[int, tuple[Registry[Any], Registry[Any] | None]] = {}
266353
checked: set[int] = set()
267354
_mark_checked_schemas(schema, checked)
268355

269356
while pending:
270-
current, resolver = pending.pop()
271-
key = (id(current), resolver._base_uri)
357+
current, resolver, scopes = pending.pop()
358+
key = (id(current), resolver._base_uri, tuple(map(id, scopes)))
272359
if key in visited:
273360
continue
274361
visited.add(key)
275362

276363
if "$ref" in current:
277364
ref = current["$ref"]
278-
# A caller-owned scope wins at runtime and is checked by its owner.
279-
if not ref.startswith("#/") or (
280-
resolver._base_uri != root_uri
281-
and registry is not None
282-
and resolver._base_uri in registry
283-
):
365+
# Registry discovery for anchors and URI refs can choose a
366+
# caller-owned resource. Only check those refs in isolation.
367+
if registry and not ref.startswith("#/"):
284368
continue
285369
try:
286-
target = _resolve_pointer(resolver, ref)
287-
except (Unresolvable, TypeError, ValueError):
370+
for scope in scopes:
371+
resolver = _enter_subresource(resolver, scope)
372+
# A caller-owned scope wins at runtime.
373+
if (
374+
resolver._base_uri != root_uri
375+
and registry is not None
376+
and resolver._base_uri in registry
377+
):
378+
continue
379+
resolver = _preflight_discovery(resolver, ref, discovery)
380+
if resolver is None:
381+
continue
382+
target = _resolve_reference(resolver, ref)
383+
except (
384+
Unresolvable,
385+
TypeError,
386+
ValueError,
387+
_ForeignDialectDiscovery,
388+
):
288389
# Resolution errors belong to instance validation.
289390
continue
290391
if declares_other_schema_dialect(target.contents):
291392
continue
292393
if id(target.contents) not in checked:
293394
check_schema(target.contents)
294395
_mark_checked_schemas(target.contents, checked)
295-
pending.append((target.contents, target.resolver))
396+
pending.append((target.contents, target.resolver, ()))
296397
else:
297-
pending.extend(
298-
(child, _enter_subresource(resolver, child))
299-
for child in _oas30_schema_children(current)
300-
)
398+
# Definitions become reachable through refs, not by being present.
399+
applicators = {
400+
key: value
401+
for key, value in current.items()
402+
if key in _OAS30_APPLICATORS
403+
}
404+
for child in _oas30_schema_children(applicators):
405+
child_scopes = (
406+
scopes if DRAFT4.id_of(child) is None else (*scopes, child)
407+
)
408+
pending.append((child, resolver, child_scopes))
301409

302410

303411
def validate_oas30_reference(
@@ -313,10 +421,7 @@ def validate_oas30_reference(
313421
validator._resolver, validator._resolver, validator.schema
314422
)
315423
try:
316-
if ref.startswith("#/"):
317-
target = _resolve_pointer(resolver, ref)
318-
else:
319-
target = resolver.lookup(ref)
424+
target = _resolve_reference(resolver, ref)
320425
except Unresolvable as error:
321426
raise _WrappedReferencingError(error) from error
322427
return cast(

0 commit comments

Comments
 (0)