Repository: netbox-community/netbox

Issue:
Invalid "address" query param causes server error
### NetBox version

v3.2.9 (confirmed on v3.4.2 as well)

### Python version

3.10

### Steps to Reproduce

1. Via API or GUI, do an HTTP GET on the `IPAddress` endpoint with `?address=/32` (For example: `https://demo.netbox.dev/ipam/ip-addresses/?address=/32`)

### Expected Behavior

I would expect an empty results list, or perhaps a more gracefully handled error message like `Invalid IP address`.

### Observed Behavior

An error is generated that references the raw SQL query that is attempted

```
Internal Server Error: /api/ipam/ip-addresses/

DataError at /api/ipam/ip-addresses/
invalid input syntax for type inet: "/32"
LINE 1: ..."ipam_ipaddress" WHERE "ipam_ipaddress"."address" IN ('/32')
```

Example from the demo site

```Server Error
There was a problem with your request. Please contact an administrator.

The complete exception is provided below:

<class 'django.db.utils.DataError'>

invalid input syntax for type inet: "/32"
LINE 1: ..."ipam_ipaddress" WHERE "ipam_ipaddress"."address" IN ('/32')
                                                                 ^
Python version: 3.8.10
NetBox version: 3.4.2
```


Discussion:
Seems like the lookup's `get_prep_lookup()` method might be the culprit here:

https://github.com/netbox-community/netbox/blob/8d9e151030c2708e1c103e8d0676d0621171dcbf/netbox/ipam/lookups.py#L106-L108

Interestingly this seems to be a problem only when the offending value contains a slash, because it sidesteps calling `HOST()` when formulating the SQL query.
My first thought would be to add checks for a valid IP (with or without mask) using `netaddr`, but not familiar enough with `Lookup` logic to say if that's even a good place for it.

Seems like any invalid IP address generates the error.

- https://demo.netbox.dev/ipam/ip-addresses/?address=112222.11.11.22211/24
- https://demo.netbox.dev/ipam/ip-addresses/?address=192.0.2.1/2455

and while an error makes sense, I think the current output could be a little clearer/cleaner and probably not an HTTP 500 :D
Would a `netaddr` validation make sense in `filtersets.py`? The `ipam` filtersets already use `netaddr` and `AddrFormatError` checks so this seems more natural than going in the `lookups`.

Current:

https://github.com/netbox-community/netbox/blob/d7c37d9dd6556654dcfa9ec940c3f1af9d4d3c6e/netbox/ipam/filtersets.py#L588-L592

New:

```python
    def filter_address(self, queryset, name, value):
        try:
            valid_ip_obj = netaddr.IPNetwork(value)
            return queryset.filter(address__net_in=value)
        except (AddrFormatError, ValidationError, TypeError):
            return queryset.none()
```

`TypeError` was being raised passing just `/32` to `netaddr.IPNetwork` so I'm handling that as well. Just a mask alone gets parsed as a list by `django`

<img width="780" alt="image" src="https://user-images.githubusercontent.com/17618971/212568300-e46574d5-b102-44e1-b698-8396e6ae7361.png">

Hi guys, I've been poking around Netbox on and off for the last two weeks or so and would love to contribute bug fixes as a way to get more active.

Agreed with Jeremy that the current bug is in the lookup logic, in particular in how we detect addresses with or without masks at:

(`rhs_params` value is `['/32']` (as evaluated by `get_db_prep_lookup`) which makes `as_sql` take the first branch in the if statement)

```
    def as_sql(self, qn, connection):
        lhs, lhs_params = self.process_lhs(qn, connection)
        rhs, rhs_params = self.process_rhs(qn, connection)
        with_mask, without_mask = [], []
        for address in rhs_params[0]:
            if '/' in address:
                with_mask.append(address)
            else:
                without_mask.append(address)
```

That being said, IMO input validation of this kind is better done at the API layer. I'm really happy to add something along the lines of what Tyler is proposing (leveraging the `netaddr` package for validation) and throw in a few unit tests.

Would this be OK as a first contribution from a rookie like me?

Thanks!
In addition of supporting `IPNetwork` we could also validate for `IPAddress`. Going over the [netaddr docs there's a few options](https://netaddr.readthedocs.io/en/latest/api.html?highlight=validation#validation-functions).

Here's what I propose which supports networks, valid addresses and also casts netmasks in the bit form to digit which is what's [supported by the inet type](https://www.postgresql.org/docs/current/datatype-net-types.html#DATATYPE-INET) in postgres.

```
netbox % git diff
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index c30064ff1..5dc1823cf 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -16,6 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface
 from .choices import *
 from .models import *
 
+from rest_framework import serializers
 
 __all__ = (
     'AggregateFilterSet',
@@ -585,8 +586,19 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
                 return queryset.none()
         return queryset.filter(q)
 
+    def parse_inet_address(self, value):
+        try:
+            addr = value[0]
+            if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr):
+                return value
+            network = netaddr.IPNetwork(addr)
+            return [str(network)]
+        except:
+            raise serializers.ValidationError(f'Invalid address {addr}. It must be a valid IPv4 or IPv6 address or network')
+
     def filter_address(self, queryset, name, value):
         try:
+            value = self.parse_inet_address(value)
             return queryset.filter(address__net_in=value)
         except ValidationError:
             return queryset.none()
```

Below is the behavior I get on my local instance with those changes:

```
% curl -H "Authorization: Token $(./print-token.sh)" "http://localhost:${NETBOX_PORT}/api/ipam/ip-addresses/?address=/32"                      
["Invalid address /32. It must be a valid IPv4 or IPv6 address or network"]%                                                                                                                                
% curl -H "Authorization: Token $(./print-token.sh)" "http://localhost:${NETBOX_PORT}/api/ipam/ip-addresses/?address=32"                       
{"count":0,"next":null,"previous":null,"results":[]}%                                                                                                                                                       
% curl -H "Authorization: Token $(./print-token.sh)" "http://localhost:${NETBOX_PORT}/api/ipam/ip-addresses/?address=192.168.0.1"              
{"count":0,"next":null,"previous":null,"results":[]}%                                                                                                                                                       
% curl -H "Authorization: Token $(./print-token.sh)" "http://localhost:${NETBOX_PORT}/api/ipam/ip-addresses/?address=192.168.0.1/32"           
{"count":0,"next":null,"previous":null,"results":[]}%                                                                                                                                                       
% curl -H "Authorization: Token $(./print-token.sh)" "http://localhost:${NETBOX_PORT}/api/ipam/ip-addresses/?address=192.168.0.1/255.255.255.0"
{"count":0,"next":null,"previous":null,"results":[]}%    
```

Let me know if this is inline with what you guys had in mind, as mentioned before, I'm happy to throw in a few unit tests and go through the standard CR process to get this fix in :)

@jeremystretch @tyler-8 any thoughts on this?
@rmanyari assigning to you per our Slack conversation.

Resolving patch:
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -16,6 +16,7 @@
 from .choices import *
 from .models import *
 
+from rest_framework import serializers
 
 __all__ = (
     'AggregateFilterSet',
@@ -599,7 +600,33 @@ def search_by_parent(self, queryset, name, value):
                 return queryset.none()
         return queryset.filter(q)
 
+    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.
+        '''
+        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
+
     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
+        value = self.parse_inet_addresses(value)
+        if (len(value) == 0):
+            return queryset.none()
+
         try:
             return queryset.filter(address__net_in=value)
         except ValidationError:


Test patch:
diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py
--- a/netbox/ipam/tests/test_filtersets.py
+++ b/netbox/ipam/tests/test_filtersets.py
@@ -10,6 +10,7 @@
 from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
 from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
 from tenancy.models import Tenant, TenantGroup
+from rest_framework import serializers
 
 
 class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -851,6 +852,26 @@ def test_filter_address(self):
         params = {'address': ['2001:db8::1/64', '2001:db8::1/65']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+        # Check for valid edge cases. Note that Postgres inet type
+        # only accepts netmasks in the int form, so the filterset
+        # casts netmasks in the xxx.xxx.xxx.xxx format.
+        params = {'address': ['24']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+        params = {'address': ['10.0.0.1/255.255.255.0']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+        params = {'address': ['10.0.0.1/255.255.255.0', '10.0.0.1/25']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+        # Check for invalid input.
+        params = {'address': ['/24']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+        params = {'address': ['10.0.0.1/255.255.999.0']}  # Invalid netmask
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
+
+        # Check for partially invalid input.
+        params = {'address': ['10.0.0.1', '/24', '10.0.0.10/24']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
     def test_mask_length(self):
         params = {'mask_length': '24'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)