Closes #22427: Validate JSONFilter.path; add JSONStringLookup with regex

- Add _validate_json_path(): each __-separated path segment must match
  [A-Za-z0-9_][A-Za-z0-9_-]* (allows leading underscores per Jeremy's
  suggestion; ORM operator names like 'date'/'regex' are valid JSON keys
  and are not blocked — the trailing __ JSONFilter appends makes them
  key traversal steps, not ORM transforms)
- Add JSONStringLookup: explicit string-filter type for JSONLookup.
  regex/i_regex are included (they offer no additional oracle power
  beyond starts_with, which is also present, per Jeremy's observation)
- JSONFilter.filter() validates self.path and returns empty Q() on
  invalid input rather than passing untrusted user input to the ORM
- 19 unit tests for path validation and JSONStringLookup field presence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Brian Tiemann 2026-06-16 09:11:06 -04:00
parent f46090076d
commit 88711b3cb2
2 changed files with 163 additions and 3 deletions

View File

@ -1,3 +1,4 @@
import re
from enum import Enum
from typing import Generic, TypeVar
@ -13,12 +14,46 @@ from strawberry_django import (
ComparisonFilterLookup,
FilterLookup,
RangeLookup,
StrFilterLookup,
process_filters,
)
from netbox.graphql.scalars import BigInt
# ------------------------------------------------------------------
# JSON path validation (VM-323)
# ------------------------------------------------------------------
# Each segment of a JSON path may only contain alphanumerics, underscores, and
# hyphens. Hyphens are included because JSON keys commonly use them; leading
# underscores are permitted (e.g. _foo is a valid key name).
_JSON_PATH_SEGMENT_RE = re.compile(r'^[A-Za-z0-9_][A-Za-z0-9_-]*$')
def _validate_json_path(path: str) -> str:
"""Validate a JSON traversal path for use in ORM lookups.
Each ``__``-separated segment must match ``[A-Za-z0-9_][A-Za-z0-9_-]*``.
Raises ``ValueError`` on an empty path, empty segment, or segment with
disallowed characters.
ORM operator names (``date``, ``regex``, etc.) are intentionally *not*
blocked here: ``JSONFilter.filter()`` always appends ``__`` to the path
before handing it to ``process_filters``, so a segment named ``regex``
becomes another level of JSON key traversal (``data__key__regex__exact``),
not the ORM regex transform (``data__key__regex=``).
"""
if not path:
raise ValueError("JSON path cannot be empty")
for segment in path.split('__'):
if not segment:
raise ValueError("JSON path contains consecutive or trailing '__'")
if not _JSON_PATH_SEGMENT_RE.match(segment):
raise ValueError(f"Invalid JSON path segment: {segment!r}")
return path
__all__ = (
'ArrayLookup',
'BigIntegerLookup',
@ -28,6 +63,8 @@ __all__ = (
'IntegerLookup',
'IntegerRangeArrayLookup',
'JSONFilter',
'JSONLookup',
'JSONStringLookup',
'StringArrayLookup',
'TreeNodeFilter',
)
@ -78,9 +115,33 @@ class JSONDatetimeFilterLookup(ComparisonFilterLookup[str]):
time: ComparisonFilterLookup[int] | None = strawberry.UNSET
@strawberry.input(description='String lookups for JSON field values.')
class JSONStringLookup:
"""
String-filter type for use inside JSONLookup.
Equivalent to ``StrFilterLookup`` but defined explicitly so that the type
name remains stable and any future per-field restrictions are easy to add.
``regex`` / ``i_regex`` are included: they provide no additional oracle
power beyond ``starts_with``, which is also present.
"""
exact: str | None = strawberry_django.filter_field()
i_exact: str | None = strawberry_django.filter_field()
contains: str | None = strawberry_django.filter_field()
i_contains: str | None = strawberry_django.filter_field()
starts_with: str | None = strawberry_django.filter_field()
i_starts_with: str | None = strawberry_django.filter_field()
ends_with: str | None = strawberry_django.filter_field()
i_ends_with: str | None = strawberry_django.filter_field()
in_: list[str] | None = strawberry_django.filter_field()
isnull: bool | None = strawberry_django.filter_field()
regex: str | None = strawberry_django.filter_field()
i_regex: str | None = strawberry_django.filter_field()
@strawberry.input(one_of=True, description='Lookup for JSON field. Only one of the lookup fields can be set.')
class JSONLookup:
string_lookup: StrFilterLookup | None = strawberry_django.filter_field()
string_lookup: JSONStringLookup | None = strawberry_django.filter_field()
int_range_lookup: RangeLookup[int] | None = strawberry_django.filter_field()
int_comparison_lookup: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
float_range_lookup: RangeLookup[float] | None = strawberry_django.filter_field()
@ -158,7 +219,12 @@ class JSONFilter:
if not filters:
return queryset, Q()
json_path = f'{prefix}{self.path}__'
try:
safe_path = _validate_json_path(self.path)
except ValueError:
return queryset, Q()
json_path = f'{prefix}{safe_path}__'
return process_filters(filters=filters, queryset=queryset, info=info, prefix=json_path)

View File

@ -505,3 +505,97 @@ class GraphQLAPITestCase(APITestCase):
data = json.loads(response.content)
self.assertIn('errors', data)
self.assertEqual(data['errors'][0]['message'], 'Cannot specify both `start` and `offset` in pagination.')
class JSONPathValidationTestCase(TestCase):
"""Unit tests for _validate_json_path (VM-323 security fix)."""
def setUp(self):
from netbox.graphql.filter_lookups import _validate_json_path
self.validate = _validate_json_path
# --- Valid paths ---
def test_single_key(self):
self.assertEqual(self.validate('key'), 'key')
def test_nested_key(self):
self.assertEqual(self.validate('parent__child'), 'parent__child')
def test_deeply_nested(self):
self.assertEqual(self.validate('a__b__c'), 'a__b__c')
def test_key_with_underscores(self):
self.assertEqual(self.validate('my_key'), 'my_key')
def test_key_with_hyphens(self):
self.assertEqual(self.validate('my-key'), 'my-key')
def test_numeric_array_index(self):
self.assertEqual(self.validate('items__0'), 'items__0')
def test_alphanumeric_segment(self):
self.assertEqual(self.validate('key123'), 'key123')
def test_key_with_leading_underscore(self):
# JSON keys may start with underscore (e.g. _foo)
self.assertEqual(self.validate('_key'), '_key')
def test_orm_operator_name_as_key(self):
# 'date', 'regex' etc. are valid JSON key names; the path validator
# must not block them. The ORM injection risk is neutralised by the
# trailing __ that JSONFilter always appends before process_filters.
self.assertEqual(self.validate('date'), 'date')
self.assertEqual(self.validate('key__regex'), 'key__regex')
self.assertEqual(self.validate('key__exact'), 'key__exact')
# --- Invalid paths ---
def test_rejects_empty_string(self):
with self.assertRaises(ValueError):
self.validate('')
def test_rejects_all_underscores(self):
# '___' splits into segments ['', '', ''] via '__' — empty segments rejected
with self.assertRaises(ValueError):
self.validate('___')
def test_accepts_trailing_single_underscore(self):
# A single trailing underscore is a valid JSON key character
self.assertEqual(self.validate('key_'), 'key_')
def test_rejects_trailing_double_underscore(self):
with self.assertRaises(ValueError):
self.validate('key__')
def test_rejects_leading_double_underscore(self):
with self.assertRaises(ValueError):
self.validate('__key')
def test_rejects_consecutive_double_underscores(self):
with self.assertRaises(ValueError):
self.validate('key1____key2')
def test_rejects_segment_starting_with_special_char(self):
with self.assertRaises(ValueError):
self.validate('$secret')
def test_rejects_path_with_spaces(self):
with self.assertRaises(ValueError):
self.validate('key one')
def test_rejects_path_with_dot(self):
with self.assertRaises(ValueError):
self.validate('key.subkey')
class JSONStringLookupTestCase(TestCase):
"""Verify JSONStringLookup exposes the expected set of string operators."""
def test_string_operators_present(self):
from netbox.graphql.filter_lookups import JSONStringLookup
field_names = {f.name for f in JSONStringLookup.__strawberry_definition__.fields}
for expected in ('exact', 'i_exact', 'contains', 'i_contains',
'starts_with', 'i_starts_with', 'ends_with', 'i_ends_with',
'in_', 'isnull', 'regex', 'i_regex'):
self.assertIn(expected, field_names, f"{expected!r} must be present on JSONStringLookup")