Forráskód Böngészése

Fixes #23328: Return empty results or a 400 for malformed filter values (#23333)

Several filters passed unvalidated values through to the database,
raising an unhandled exception (HTTP 500) on malformed input:

- vlan_id/vlan (device & VM interfaces): parse the value as an integer
  and return no results if it is invalid.
- wwn: catch ValidationError, as MultiValueMACAddressFilter does (#11544).
- time_zone: introduce MultiValueTimeZoneFilter, which does the same.
- unit (rack reservations): bound values to the range of
  PositiveSmallIntegerField, so out-of-range values return a 400.
- start_address/end_address (IP ranges): discard invalid addresses
  before querying, as the IP address filter does (#11470). The
  parse_inet_addresses() helper moves to ipam.utils to be shared.
Jeremy Stretch 2 napja
szülő
commit
21d9223cfe

+ 23 - 19
netbox/dcim/filtersets.py

@@ -32,6 +32,7 @@ from utilities.filters import (
     MultiValueContentTypeFilter,
     MultiValueMACAddressFilter,
     MultiValueNumberFilter,
+    MultiValueTimeZoneFilter,
     MultiValueWWNFilter,
     NumericArrayFilter,
     TreeNodeMultipleChoiceFilter,
@@ -217,7 +218,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS
         queryset=ASN.objects.all(),
         label=_('AS (ID)'),
     )
-    time_zone = MultiValueCharFilter()
+    time_zone = MultiValueTimeZoneFilter()
 
     class Meta:
         model = Site
@@ -623,7 +624,9 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     )
     unit = NumericArrayFilter(
         field_name='units',
-        lookup_expr='contains'
+        lookup_expr='contains',
+        min_value=0,
+        max_value=32767
     )
     unit_count_min = django_filters.NumberFilter(
         field_name='unit_count',
@@ -2347,12 +2350,14 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
         distinct=False,
         label=_('802.1Q Mode')
     )
-    vlan_id = django_filters.CharFilter(
+    vlan_id = django_filters.NumberFilter(
         method='filter_vlan_id',
+        decimal_places=0,
         label=_('Assigned VLAN')
     )
-    vlan = django_filters.CharFilter(
+    vlan = django_filters.NumberFilter(
         method='filter_vlan',
+        decimal_places=0,
         label=_('Assigned VID')
     )
     vrf_id = django_filters.ModelMultipleChoiceFilter(
@@ -2394,24 +2399,23 @@ class CommonInterfaceFilterSet(django_filters.FilterSet):
     )
 
     def filter_vlan_id(self, queryset, name, value):
-        value = value.strip()
-        if not value:
-            return queryset
-        return queryset.filter(
-            Q(untagged_vlan_id=value) |
-            Q(tagged_vlans=value) |
-            Q(qinq_svlan=value)
-        )
+        return self._filter_assigned_vlan(queryset, 'id', value)
 
     def filter_vlan(self, queryset, name, value):
-        value = value.strip()
-        if not value:
-            return queryset
+        return self._filter_assigned_vlan(queryset, 'vid', value)
+
+    @staticmethod
+    def _filter_assigned_vlan(queryset, field, value):
+        """
+        Filter by any assigned VLAN (untagged, tagged, or Q-in-Q SVLAN) matching the given integer value on the
+        specified VLAN field.
+        """
+        value = int(value)
         return queryset.filter(
-            Q(untagged_vlan_id__vid=value) |
-            Q(tagged_vlans__vid=value) |
-            Q(qinq_svlan__vid=value)
-        )
+            Q(**{f'untagged_vlan__{field}': value}) |
+            Q(**{f'tagged_vlans__{field}': value}) |
+            Q(**{f'qinq_svlan__{field}': value})
+        ).distinct()
 
 
 @register_filterset

+ 122 - 0
netbox/dcim/tests/test_filtersets.py

@@ -296,6 +296,7 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
                 facility='Facility 1',
                 latitude=10,
                 longitude=10,
+                time_zone='America/New_York',
                 description='foobar1',
             ),
             Site(
@@ -308,6 +309,7 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
                 facility='Facility 2',
                 latitude=20,
                 longitude=20,
+                time_zone='Europe/London',
                 description='foobar2',
             ),
             Site(
@@ -343,6 +345,39 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'facility': ['Facility 1', 'Facility 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_time_zone(self):
+        params = {'time_zone': ['America/New_York', 'Europe/London']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'time_zone': ['America/New_York']}
+        self.assertQuerySetEqual(
+            self.filterset(params, self.queryset).qs, ['Site 1'], transform=lambda site: site.name
+        )
+        params = {'time_zone__n': ['America/New_York']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+        # Reject invalid filter values
+        for params in (
+            {'time_zone': ['no-such-value']},
+            {'time_zone': ['America/New_York', 'no-such-value']},
+            {'time_zone__n': ['no-such-value']},
+            {'time_zone': ['America/']},
+            {'time_zone': ['../x']},
+            {'time_zone': ['x' * 300]},
+        ):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
+        # The null choice value matches sites without a time zone
+        for params, count in (({'time_zone': ['null']}, 1), ({'time_zone__n': ['null']}, 2)):
+            filterset = self.filterset(params, self.queryset)
+            self.assertTrue(filterset.is_valid(), msg=params)
+            self.assertEqual(filterset.qs.count(), count, msg=params)
+
+        # Partial matches are not validated
+        params = {'time_zone__ic': ['no-such-value']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+        params = {'time_zone__ic': ['america']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
     def test_asn(self):
         params = {'asn': ['64512', '64513']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -1302,6 +1337,35 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_unit(self):
+        params = {'unit': 3}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'unit': '3.0'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'unit': 0}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+        params = {'unit__n': 0}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
+        # The "empty" lookup takes a boolean value; units are never null. Non-boolean values are ignored.
+        for value, expected, count in (
+            ('true', True, 0),
+            ('True', True, 0),
+            ('false', False, 3),
+            ('False', False, 3),
+            ('1', None, 3),
+            ('0', None, 3),
+        ):
+            filterset = self.filterset({'unit__empty': value}, self.queryset)
+            self.assertTrue(filterset.is_valid(), msg=value)
+            self.assertIs(filterset.form.cleaned_data['unit__empty'], expected, msg=value)
+            self.assertEqual(filterset.qs.count(), count, msg=value)
+
+        # A value outside the range of a reservation unit, or not a whole number, is rejected
+        for lookup in ('unit', 'unit__n', 'unit__gt', 'unit__lt'):
+            for value in (-1, 32768, 2147483000, '2.5', '3.7'):
+                self.assertFalse(self.filterset({lookup: value}, self.queryset).is_valid(), msg=(lookup, value))
+
     def test_unit_count(self):
         params = {'unit_count_min': 3}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -6080,6 +6144,28 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLogge
         params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_wwn(self):
+        params = {'wwn': ['00:00:00:00:00:00:00:01']}
+        self.assertTrue(self.filterset(params, self.queryset).is_valid())
+
+        # Reject invalid filter values
+        for params in (
+            {'wwn': ['no-such-value']},
+            {'wwn': ['00:00:00:00:00:00:00:01', 'no-such-value']},
+            {'wwn__n': ['no-such-value']},
+        ):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
+        # The null choice value matches interfaces without a WWN (all of them, in this test data)
+        for params, count in (({'wwn': ['null']}, self.queryset.count()), ({'wwn__n': ['null']}, 0)):
+            filterset = self.filterset(params, self.queryset)
+            self.assertTrue(filterset.is_valid(), msg=params)
+            self.assertEqual(filterset.qs.count(), count, msg=params)
+
+        # Partial matches are not validated
+        params = {'wwn__ic': ['no-such-value']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
     def test_type(self):
         params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -6133,6 +6219,24 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLogge
         params = {'vlan': vlan.vid}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
+        # A non-integer value is rejected
+        for value in ('no-such-value', '1.5'):
+            self.assertFalse(self.filterset({'vlan_id': value}, self.queryset).is_valid(), msg=value)
+            self.assertFalse(self.filterset({'vlan': value}, self.queryset).is_valid(), msg=value)
+
+        # An interface matching more than one of its VLAN assignments is returned only once
+        vlans = VLAN.objects.bulk_create((
+            VLAN(name='VLAN 4001', vid=4001),
+            VLAN(name='VLAN 4002', vid=4002),
+        ))
+        interface = self.queryset.first()
+        Interface.objects.filter(pk=interface.pk).update(untagged_vlan=vlans[0])
+        interface.tagged_vlans.set(vlans)
+        params = {'vlan_id': vlans[0].pk}
+        self.assertEqual(list(self.filterset(params, self.queryset).qs), [interface])
+        params = {'vlan': vlans[0].vid}
+        self.assertEqual(list(self.filterset(params, self.queryset).qs), [interface])
+
     def test_vlan_translation_policy(self):
         vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2]
         params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]}
@@ -8917,6 +9021,24 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'mac_address': ['00-00-00-01-01-01', '00-00-00-02-01-01']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+        # Reject invalid filter values
+        for params in (
+            {'mac_address': ['no-such-value']},
+            {'mac_address': ['00-00-00-01-01-01', 'no-such-value']},
+            {'mac_address__n': ['no-such-value']},
+        ):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
+        # The null choice value is accepted (a MAC address object always has an address)
+        for params, count in (({'mac_address': ['null']}, 0), ({'mac_address__n': ['null']}, self.queryset.count())):
+            filterset = self.filterset(params, self.queryset)
+            self.assertTrue(filterset.is_valid(), msg=params)
+            self.assertEqual(filterset.qs.count(), count, msg=params)
+
+        # Partial matches are not validated
+        params = {'mac_address__ic': ['00:00:00:01']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
     def test_device(self):
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}

+ 1 - 2
netbox/extras/filters.py

@@ -41,8 +41,7 @@ class MissingKeyAwareFilterMixin:
 
     * filter() is reimplemented rather than delegated to, so any custom filter() on the base class
       is bypassed. Do not mix this into a class which overrides filter() (e.g.
-      MultiValueMACAddressFilter, MultiValueContentTypeFilter).
-      missing_key_aware_filter_factory() rejects such classes.
+      MultiValueContentTypeFilter). missing_key_aware_filter_factory() rejects such classes.
     * `conjoined` is not honored: multiple values are always OR'ed. Passing it raises TypeError.
     """
     def __init__(self, *args, **kwargs):

+ 3 - 3
netbox/extras/tests/test_customfields.py

@@ -40,7 +40,7 @@ from netbox.context import query_cache
 from netbox.context_managers import event_tracking
 from netbox.tables.columns import CustomFieldColumn
 from utilities.exceptions import AbortRequest
-from utilities.filters import MultiValueCharFilter, MultiValueMACAddressFilter
+from utilities.filters import MultiValueCharFilter, MultiValueContentTypeFilter
 from utilities.testing import APITestCase, TestCase
 from virtualization.models import VirtualMachine
 
@@ -2542,9 +2542,9 @@ class MissingKeyAwareFilterTestCase(TestCase):
     set rather than an error.
     """
     def test_factory_rejects_a_class_which_defines_filter(self):
-        # MultiValueMACAddressFilter overrides filter() to swallow ValidationError
+        # MultiValueContentTypeFilter overrides filter() to match on app label and model
         with self.assertRaises(TypeError):
-            missing_key_aware_filter_factory(MultiValueMACAddressFilter)
+            missing_key_aware_filter_factory(MultiValueContentTypeFilter)
 
         # BooleanFilter does not inherit MultipleChoiceFilter.filter() at all
         with self.assertRaises(TypeError):

+ 11 - 28
netbox/ipam/filtersets.py

@@ -1,7 +1,6 @@
 import django_filters
 import netaddr
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ValidationError
 from django.db.models import Q
 from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
@@ -31,6 +30,7 @@ from vpn.models import L2VPN
 from .choices import *
 from .models import *
 from .utils import normalize_port_mapping, port_mapping_q
+from .utils import parse_inet_addresses as utils_parse_inet_addresses
 
 __all__ = (
     'ASNFilterSet',
@@ -579,10 +579,11 @@ class IPRangeFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt
             return queryset.none()
 
     def filter_address(self, queryset, name, value):
-        try:
-            return queryset.filter(**{f'{name}__net_in': value})
-        except ValidationError:
+        # Discard any invalid addresses. If none remain, return an empty queryset.
+        addresses = utils_parse_inet_addresses(value)
+        if not addresses:
             return queryset.none()
+        return queryset.filter(**{f'{name}__net_in': addresses})
 
     def search_by_parent(self, queryset, name, value):
         if not value:
@@ -750,37 +751,19 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
                 return queryset.none()
         return queryset.filter(q)
 
+    # TODO: Remove in v5.0
     def parse_inet_addresses(self, value):
         """
-        Parse networks or IP addresses and cast to a format
-        acceptable by the Postgres inet type.
-
-        Skips invalid values.
+        Deprecated: Use ipam.utils.parse_inet_addresses() instead. Retained for backward compatibility.
         """
-        parsed = []
-        for addr in value:
-            if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr):
-                parsed.append(addr)
-                continue
-            try:
-                network = netaddr.IPNetwork(addr)
-                parsed.append(str(network))
-            except (AddrFormatError, ValueError):
-                continue
-        return parsed
+        return utils_parse_inet_addresses(value)
 
     def filter_address(self, queryset, name, value):
-        # Let's first parse the addresses passed
-        # as argument. If they are all invalid,
-        # we return an empty queryset
+        # Discard any invalid addresses. If none remain, return an empty queryset.
         value = self.parse_inet_addresses(value)
-        if len(value) == 0:
-            return queryset.none()
-
-        try:
-            return queryset.filter(address__net_in=value)
-        except ValidationError:
+        if not value:
             return queryset.none()
+        return queryset.filter(address__net_in=value)
 
     @extend_schema_field(OpenApiTypes.STR)
     def filter_present_in_vrf(self, queryset, name, vrf):

+ 34 - 0
netbox/ipam/tests/test_filtersets.py

@@ -1058,10 +1058,26 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'start_address': ['10.0.1.100', '10.0.2.100']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+        # Invalid addresses are discarded
+        params = {'start_address': ['no-such-value']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+        params = {'start_address': ['10.0.1.100', 'no-such-value']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+        # Surrounding whitespace is ignored
+        params = {'start_address': [' 10.0.1.100 ']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
     def test_end_address(self):
         params = {'end_address': ['10.0.1.199', '10.0.2.199']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+        # Invalid addresses are discarded
+        params = {'end_address': ['no-such-value']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+        params = {'end_address': ['10.0.1.199', 'no-such-value']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
     def test_contains(self):
         params = {'contains': '10.0.1.150/24'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
@@ -1390,6 +1406,24 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'address': ['10.0.0.1', '/24', '10.0.0.10/24']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+        # Surrounding whitespace is ignored
+        params = {'address': [' 10.0.0.1/24 ']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+        # The parse_inet_addresses() method is retained for backward compatibility
+        self.assertEqual(
+            self.filterset().parse_inet_addresses(['10.0.0.1', '/24', '10.0.0.1/255.255.255.0']),
+            ['10.0.0.1', '10.0.0.1/24']
+        )
+
+        # An override of parse_inet_addresses() is honored when filtering
+        class CustomIPAddressFilterSet(IPAddressFilterSet):
+            def parse_inet_addresses(self, value):
+                return ['10.0.0.1/24']
+
+        params = {'address': ['no-such-value']}
+        self.assertEqual(CustomIPAddressFilterSet(params, self.queryset).qs.count(), 1)
+
     def test_mask_length(self):
         params = {'mask_length': [24]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)

+ 21 - 0
netbox/ipam/utils.py

@@ -21,6 +21,7 @@ __all__ = (
     'group_port_mappings',
     'legacy_protocol_and_ports',
     'normalize_port_mapping',
+    'parse_inet_addresses',
     'port_mapping_q',
     'rebuild_prefixes',
     'sorted_int_ports',
@@ -285,6 +286,26 @@ def get_next_available_prefix(ipset, prefix_size):
     return None
 
 
+def parse_inet_addresses(value):
+    """
+    Parse networks or IP addresses and cast to a format
+    acceptable by the Postgres inet type.
+
+    Skips invalid values.
+    """
+    parsed = []
+    for addr in value:
+        if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr):
+            parsed.append(addr)
+            continue
+        try:
+            network = netaddr.IPNetwork(addr)
+            parsed.append(str(network))
+        except (netaddr.AddrFormatError, ValueError):
+            continue
+    return parsed
+
+
 #
 # Service port mappings
 #

+ 0 - 1
netbox/netbox/filtersets.py

@@ -188,7 +188,6 @@ class BaseFilterSet(django_filters.FilterSet):
             django_filters.ChoiceFilter,
             django_filters.MultipleChoiceFilter,
             filters.MultiValueCharFilter,
-            filters.MultiValueMACAddressFilter
         )):
             return FILTER_CHAR_BASED_LOOKUP_MAP
 

+ 50 - 12
netbox/utilities/filters.py

@@ -3,7 +3,9 @@ from django import forms
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
+from django.utils.translation import gettext as _
 from django_filters.constants import EMPTY_VALUES
+from django_filters.utils import get_model_field
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 
@@ -21,6 +23,7 @@ __all__ = (
     'MultiValueMACAddressFilter',
     'MultiValueNumberFilter',
     'MultiValueTimeFilter',
+    'MultiValueTimeZoneFilter',
     'MultiValueWWNFilter',
     'NullableCharFieldFilter',
     'NumericArrayFilter',
@@ -110,20 +113,53 @@ class MultiValueArrayFilter(django_filters.MultipleChoiceFilter):
         return super().get_filter_predicate(v)
 
 
+class InvalidValueFilterMixin:
+    """
+    Validate values with the model field for lookups that prepare their inputs.
+    Partial-match lookups bypass this validation.
+    """
+    @property
+    def field(self):
+        field = super().field
+        # The model is assigned only once the filter has been bound to a FilterSet instance
+        if getattr(self, 'model', None) is not None and not getattr(self, '_model_field_validated', False):
+            self._model_field_validated = True
+            model_field = get_model_field(self.model, self.field_name)
+            lookup = model_field.get_lookup(self.lookup_expr) if model_field is not None else None
+            if lookup is not None and lookup.prepare_rhs:
+                field.validators.append(self._get_validator(model_field))
+        return field
+
+    def _get_validator(self, model_field):
+        null_value = self.null_value
+
+        def validator(value):
+            # The null choice value is translated to None when filtering
+            if value == null_value:
+                return
+            try:
+                model_field.get_prep_value(value)
+            except (OSError, TypeError, ValueError):
+                # Some values are rejected with an exception other than ValidationError (e.g. zoneinfo raises a
+                # ValueError for a time zone key which is not a normalized path)
+                raise ValidationError(_('Invalid value: {value}').format(value=value))
+
+        return validator
+
+
 @extend_schema_field(OpenApiTypes.STR)
-class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter):
-    field_class = multivalue_field_factory(forms.CharField)
+class MultiValueMACAddressFilter(InvalidValueFilterMixin, MultiValueCharFilter):
+    pass
 
-    def filter(self, qs, value):
-        try:
-            return super().filter(qs, value)
-        except ValidationError:
-            return qs.none()
+
+@extend_schema_field(OpenApiTypes.STR)
+class MultiValueTimeZoneFilter(InvalidValueFilterMixin, MultiValueCharFilter):
+    pass
 
 
 @extend_schema_field(OpenApiTypes.STR)
-class MultiValueWWNFilter(django_filters.MultipleChoiceFilter):
-    field_class = multivalue_field_factory(forms.CharField)
+class MultiValueWWNFilter(InvalidValueFilterMixin, MultiValueCharFilter):
+    pass
 
 
 @extend_schema_field(OpenApiTypes.STR)
@@ -157,10 +193,12 @@ class NumericArrayFilter(django_filters.NumberFilter):
     """
     Filter based on the presence of an integer within an ArrayField.
     """
+    field_class = forms.IntegerField
+
     def filter(self, qs, value):
-        if value:
-            value = [value]
-        return super().filter(qs, value)
+        if value is None:
+            return qs
+        return super().filter(qs, [value])
 
 
 class ContentTypeFilter(django_filters.CharFilter):

+ 18 - 0
netbox/virtualization/tests/test_filtersets.py

@@ -872,6 +872,24 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'vlan': vlan.vid}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
+        # A non-integer value is rejected
+        for value in ('no-such-value', '1.5'):
+            self.assertFalse(self.filterset({'vlan_id': value}, self.queryset).is_valid(), msg=value)
+            self.assertFalse(self.filterset({'vlan': value}, self.queryset).is_valid(), msg=value)
+
+        # An interface matching more than one of its VLAN assignments is returned only once
+        vlans = VLAN.objects.bulk_create((
+            VLAN(name='VLAN 4001', vid=4001),
+            VLAN(name='VLAN 4002', vid=4002),
+        ))
+        interface = self.queryset.first()
+        VMInterface.objects.filter(pk=interface.pk).update(untagged_vlan=vlans[0])
+        interface.tagged_vlans.set(vlans)
+        params = {'vlan_id': vlans[0].pk}
+        self.assertEqual(list(self.filterset(params, self.queryset).qs), [interface])
+        params = {'vlan': vlans[0].vid}
+        self.assertEqual(list(self.filterset(params, self.queryset).qs), [interface])
+
     def test_vlan_translation_policy(self):
         vlan_translation_policies = VLANTranslationPolicy.objects.all()[:2]
         params = {'vlan_translation_policy_id': [vlan_translation_policies[0].pk, vlan_translation_policies[1].pk]}