Repository: netbox-community/netbox

Issue:
Insufficent validation of boolean filters
### NetBox version

v3.4.2

### Python version

3.8

### Steps to Reproduce

```
curl -vsS -H "Authorization: Token $TOKEN" "$NETBOX/api/dcim/devices/?name__empty=asdf"
```

### Expected Behavior

The request to be rejected with a 4xx status code (probably 400)

### Observed Behavior

An exception was raised, returned with a 500 status code and E-mailed to admins.

```
Internal Server Error: /api/dcim/devices/

DataError at /api/dcim/devices/
invalid input syntax for type boolean: "asdf"
LINE 1: ... CAST(LENGTH("dcim_device"."name") AS BOOLEAN) != 'asdf') su...
                                                             ^
...
Traceback (most recent call last):
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/db/backends/utils.py", line 89, in _execute
    return self.cursor.execute(sql, params)
  File "/opt/netbox/venv/lib/python3.8/site-packages/django_prometheus/db/common.py", line 71, in execute
    return super().execute(*args, **kwargs)

The above exception (invalid input syntax for type boolean: "asdf"
LINE 1: ... CAST(LENGTH("dcim_device"."name") AS BOOLEAN) != 'asdf') su...
                                                             ^
) was the direct cause of the following exception:
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/core/handlers/exception.py", line 55, in inner
    response = get_response(request)
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/core/handlers/base.py", line 197, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/views/decorators/csrf.py", line 54, in wrapped_view
    return view_func(*args, **kwargs)
  File "/opt/netbox/venv/lib/python3.8/site-packages/rest_framework/viewsets.py", line 125, in view
    return self.dispatch(request, *args, **kwargs)
  File "/opt/netbox/netbox/netbox/api/viewsets/__init__.py", line 118, in dispatch
    return super().dispatch(request, *args, **kwargs)
  File "/opt/netbox/venv/lib/python3.8/site-packages/rest_framework/views.py", line 509, in dispatch
    response = self.handle_exception(exc)
  File "/opt/netbox/venv/lib/python3.8/site-packages/rest_framework/views.py", line 469, in handle_exception
    self.raise_uncaught_exception(exc)
  File "/opt/netbox/venv/lib/python3.8/site-packages/rest_framework/views.py", line 480, in raise_uncaught_exception
    raise exc
  File "/opt/netbox/venv/lib/python3.8/site-packages/rest_framework/views.py", line 506, in dispatch
    response = handler(request, *args, **kwargs)
  File "/opt/netbox/netbox/netbox/api/viewsets/__init__.py", line 149, in list
    return super().list(request, *args, **kwargs)
  File "/opt/netbox/venv/lib/python3.8/site-packages/rest_framework/mixins.py", line 40, in list
    page = self.paginate_queryset(queryset)
  File "/opt/netbox/venv/lib/python3.8/site-packages/rest_framework/generics.py", line 171, in paginate_queryset
    return self.paginator.paginate_queryset(queryset, self.request, view=self)
  File "/opt/netbox/netbox/netbox/api/pagination.py", line 19, in paginate_queryset
    self.count = self.get_queryset_count(queryset)
  File "/opt/netbox/netbox/netbox/api/pagination.py", line 85, in get_queryset_count
    return cloned_queryset.count()
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/db/models/query.py", line 621, in count
    return self.query.get_count(using=self.db)
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/db/models/sql/query.py", line 559, in get_count
    return obj.get_aggregation(using, ["__count"])["__count"]
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/db/models/sql/query.py", line 544, in get_aggregation
    result = compiler.execute_sql(SINGLE)
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/db/models/sql/compiler.py", line 1398, in execute_sql
    cursor.execute(sql, params)
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/db/backends/utils.py", line 67, in execute
    return self._execute_with_wrappers(
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/db/backends/utils.py", line 80, in _execute_with_wrappers
    return executor(sql, params, many, context)
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/db/backends/utils.py", line 89, in _execute
    return self.cursor.execute(sql, params)
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/db/utils.py", line 91, in __exit__
    raise dj_exc_value.with_traceback(traceback) from exc_value
  File "/opt/netbox/venv/lib/python3.8/site-packages/django/db/backends/utils.py", line 89, in _execute
    return self.cursor.execute(sql, params)
  File "/opt/netbox/venv/lib/python3.8/site-packages/django_prometheus/db/common.py", line 71, in execute
    return super().execute(*args, **kwargs)

Exception Type: DataError at /api/dcim/devices/
Exception Value: invalid input syntax for type boolean: "asdf"
LINE 1: ... CAST(LENGTH("dcim_device"."name") AS BOOLEAN) != 'asdf') su...
                                                             ^

Raised during: dcim.api.views.DeviceViewSet
```


Discussion:

Resolving patch:
diff --git a/netbox/extras/lookups.py b/netbox/extras/lookups.py
--- a/netbox/extras/lookups.py
+++ b/netbox/extras/lookups.py
@@ -6,12 +6,14 @@ class Empty(Lookup):
     Filter on whether a string is empty.
     """
     lookup_name = 'empty'
+    prepare_rhs = False
 
-    def as_sql(self, qn, connection):
-        lhs, lhs_params = self.process_lhs(qn, connection)
-        rhs, rhs_params = self.process_rhs(qn, connection)
-        params = lhs_params + rhs_params
-        return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
+    def as_sql(self, compiler, connection):
+        sql, params = compiler.compile(self.lhs)
+        if self.rhs:
+            return f"CAST(LENGTH({sql}) AS BOOLEAN) IS NOT TRUE", params
+        else:
+            return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
 
 
 CharField.register_lookup(Empty)
diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py
--- a/netbox/netbox/filtersets.py
+++ b/netbox/netbox/filtersets.py
@@ -177,7 +177,8 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter):
                     # create the new filter with the same type because there is no guarantee the defined type
                     # is the same as the default type for the field
                     resolve_field(field, lookup_expr)  # Will raise FieldLookupError if the lookup is invalid
-                    new_filter = type(existing_filter)(
+                    filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter)
+                    new_filter = filter_cls(
                         field_name=field_name,
                         lookup_expr=lookup_expr,
                         label=existing_filter.label,
@@ -224,6 +225,14 @@ def get_filters(cls):
 
         return filters
 
+    @classmethod
+    def filter_for_lookup(cls, field, lookup_type):
+
+        if lookup_type == 'empty':
+            return django_filters.BooleanFilter, {}
+
+        return super().filter_for_lookup(field, lookup_type)
+
 
 class ChangeLoggedModelFilterSet(BaseFilterSet):
     """


Test patch: