CVE-2022-4595: Improper Neutralization (CWE-707)
value: A vulnerability classified as problematic has been found in django-openipam. This affects an unknown part of the file openipam/report/templates/report/exposed_hosts.html. The manipulation of the argument description leads to cross site scripting. It is possible to initiate the attack remotely. The name of the patch is a6223a1150d60cd036106ba6a8e676c1bfc3cc85. It is recommended to apply a patch to fix this issue. The identifier VDB-216189 was assigned to this vulnerability.
CWE description: The product does not ensure or incorrectly ensures that structured messages or data are well-formed and that certain security properties are met before being read from an upstream component or sent to a downstream component.
CVSS v3 base score: 6.1
Published: 2022-12-18T11:15Z
Repository: https://github.com/openipam/django-openipam
Language: Python
Files changed: openipam/api/views/report.py, openipam/report/templates/report/exposed_hosts.html
Commit date: 2022-11-11 10:55:54 -0700
Commit message: Exposed Hosts Improvements (#205)

* Fixed XSS issue by rendering description as text. Also, drastically improved performance of exposed host report via a ridiculous sql query.

* Run black & remove unused imports

* Remove trailing whitespace

* There was still a trailing whitespace trailing behind
Diff: diff --git a/openipam/api/views/report.py b/openipam/api/views/report.py
index 7abe57b5..4b9c46ae 100644
--- a/openipam/api/views/report.py
+++ b/openipam/api/views/report.py
@@ -9,30 +9,29 @@ from rest_framework.renderers import (
 from rest_framework.views import APIView
 from rest_framework.decorators import api_view, renderer_classes, permission_classes
 
 from rest_framework_csv.renderers import CSVRenderer
 
+from django.db import connection
 from django.db.models.aggregates import Count
 from django.http import HttpResponse
-from django.contrib.contenttypes.models import ContentType
 from django.contrib.auth import get_user_model
+from django.contrib.auth.models import Permission
 from django.apps import apps
-from django.db.models import Q, F
+from django.db.models import Q
 from django.utils import timezone
 
-from openipam.hosts.models import Host
+from openipam.hosts.models import Host, Attribute
 from openipam.report.models import Ports
 from openipam.report.models import database_connect, database_close
 from openipam.network.models import Network, Lease, Address
 from openipam.dns.models import DnsRecord
-from openipam.conf.ipam_settings import CONFIG, CONFIG_DEFAULTS
+from openipam.conf.ipam_settings import CONFIG
 from openipam.conf.settings import get_buildingmap_data
 
 from functools import reduce
 
-from guardian.models import UserObjectPermission, GroupObjectPermission
-
 import copy
 
 import qsstats
 
 import operator
@@ -422,71 +421,59 @@ class DashboardAPIView(APIView):
 class ServerHostCSVRenderer(CSVRenderer):
     header = [
         "hostname",
         "mac",
         "description",
-        "master_ip_address",
+        "addresses",
         "user_owners",
         "group_owners",
+        "nac_profiles",
     ]
 
 
 class ServerHostView(APIView):
     permission_classes = (AllowAny,)
     renderer_classes = (BrowsableAPIRenderer, JSONRenderer, ServerHostCSVRenderer)
 
     def get(self, request, format=None, **kwargs):
-        hosts = (
-            Host.objects.prefetch_related("addresses")
-            .filter(
-                structured_attributes__structured_attribute_value__attribute__name="nac-profile",
-                structured_attributes__structured_attribute_value__value__startswith=CONFIG_DEFAULTS[
-                    "NAC_PROFILE_IS_SERVER_PREFIX"
-                ],
-            )
-            .annotate(
-                nac_profile=F(
-                    "structured_attributes__structured_attribute_value__value"
-                ),
-            )
-        )
-
-        user_perms_prefetch = UserObjectPermission.objects.select_related(
-            "permission", "user"
-        ).filter(
-            content_type=ContentType.objects.get_for_model(Host),
-            object_pk__in=[str(host.mac) for host in hosts],
-            permission__codename="is_owner_host",
-        )
-        group_perms_prefetch = GroupObjectPermission.objects.select_related(
-            "permission", "group"
-        ).filter(
-            content_type=ContentType.objects.get_for_model(Host),
-            object_pk__in=[str(host.mac) for host in hosts],
-            permission__codename="is_owner_host",
+        nac_profile_attribute = Attribute.objects.get(name="nac-profile")
+        host_owner_permission = Permission.objects.get(codename="is_owner_host")
+
+        cursor = connection.cursor()
+        cursor.execute(
+            """
+            SELECT hosts.hostname AS hostname,
+                   hosts.mac AS mac,
+                   hosts.description AS description,
+                   STRING_AGG(DISTINCT((SELECT CAST(addresses.address AS VARCHAR))), ', ') AS addresses,
+                   STRING_AGG(DISTINCT(users.username), ', ') AS users,
+                   STRING_AGG(DISTINCT(groups.name), ', ') AS groups,
+                   STRING_AGG(DISTINCT(host_attr_vals.value), ', ') AS nac_profiles
+            FROM hosts
+                JOIN structured_attributes_to_hosts AS host_attrs ON hosts.mac=host_attrs.mac
+                JOIN structured_attribute_values AS host_attr_vals ON host_attrs.avid=host_attr_vals.id
+                LEFT JOIN guardian_userobjectpermission AS uop ON uop.object_pk=(SELECT CAST(hosts.mac AS VARCHAR)) AND uop.permission_id = %s
+                LEFT JOIN guardian_groupobjectpermission AS gop ON gop.object_pk=(SELECT CAST(hosts.mac AS VARCHAR)) AND gop.permission_id=%s
+                LEFT JOIN addresses ON hosts.mac=addresses.mac
+                LEFT JOIN users ON uop.user_id=users.id
+                LEFT JOIN groups ON gop.group_id=groups.id
+            WHERE host_attr_vals.aid = %s
+            AND STARTS_WITH(host_attr_vals.value, %s)
+            GROUP BY hosts.mac, hosts.hostname, hosts.description
+            """,
+            [
+                host_owner_permission.id,
+                host_owner_permission.id,
+                nac_profile_attribute.id,
+                CONFIG["NAC_PROFILE_IS_SERVER_PREFIX"],
+            ],
         )
 
-        data = []
-        for host in hosts:
-            owners = host.get_owners(
-                name_only=True,
-                user_perms_prefetch=user_perms_prefetch,
-                group_perms_prefetch=group_perms_prefetch,
-            )
-            data.append(
-                {
-                    "hostname": host.hostname,
-                    "mac": str(host.mac),
-                    "description": host.description,
-                    "master_ip_address": host.ip_addresses[0]
-                    if host.ip_addresses
-                    else None,
-                    "user_owners": ", ".join(owners[0]),
-                    "group_owners": ", ".join(owners[1]),
-                    "nac_profile": host.nac_profile,
-                }
-            )
+        data = [
+            dict(zip([col[0] for col in cursor.description], row))
+            for row in cursor.fetchall()
+        ]
 
         if request.accepted_renderer.format == "json":
             return Response({"data": data}, status=status.HTTP_200_OK)
         else:
             return Response(data, status=status.HTTP_200_OK)
diff --git a/openipam/report/templates/report/exposed_hosts.html b/openipam/report/templates/report/exposed_hosts.html
index b24d33b4..92e1aeb5 100644
--- a/openipam/report/templates/report/exposed_hosts.html
+++ b/openipam/report/templates/report/exposed_hosts.html
@@ -17,40 +17,47 @@
 <script type="text/javascript" src="{% static 'datatables/media/js/jquery.dataTables.min.js' %}"></script>
 <script type="text/javascript" src="{% static 'datatables-responsive/js/dataTables.responsive.js' %}"></script>
 <script type="text/javascript" src="{% static 'core/js/dataTables.bootstrap.js' %}"></script>
 
 <script type="text/javascript">
+  const escapeHtml = (t) => $('<div />').text(t).html();  
+
   $(function () {
     $("#server_link").addClass("active");
     $("#reports_dropdown").addClass("active");
 
     $("#result_list").DataTable({
       ajax: "/api/reports/serverhosts?format=json",
       columns: [{
-          data: "hostname"
+          data: "hostname",
+          orderable: true,
         },
         {
-          data: "mac"
+          data: "mac",
+          orderable: true,
+          render: (t) => `<a href="/hosts/${escapeHtml(t).replace(/\:/g, "")}/detail">${escapeHtml(t)}</a>`,
         },
         {
           data: "description",
-          orderable: false
+          orderable: false,
+          render: escapeHtml,
         },
         {
-          data: "master_ip_address"
+          data: "addresses",
+          orderable: true,
         },
         {
-          data: "user_owners",
-          orderable: false
+          data: "users",
+          orderable: false,
         },
         {
-          data: "group_owners",
-          orderable: false
+          data: "groups",
+          orderable: false,
         },
         {
-          data: "nac_profile",
-          orderable: false
+          data: "nac_profiles",
+          orderable: false,
         },
       ],
       paging: false,
       dom: '<"paginator"lpi<"clear">>',
     });
@@ -70,14 +77,14 @@
       <tr>
         <thead>
           <th scope="col">Host</th>
           <th scope="col">Mac Address</th>
           <th scope="col">Description</th>
-          <th scope="col">IP Address</th>
-          <th scope="col">User Owners</th>
-          <th scope="col">Group Owners</th>
-          <th scope="col">NAC Profile</th>
+          <th scope="col">IP Address(es)</th>
+          <th scope="col">User Owner(s)</th>
+          <th scope="col">Group Owner(s)</th>
+          <th scope="col">NAC Profile(s)</th>
         </thead>
       </tr>
       <tbody></tbody>
     </table>
   </div>

Vulnerable code: from django.contrib.contenttypes.models import ContentType
from django.db.models import Q, F
from openipam.hosts.models import Host
from openipam.conf.ipam_settings import CONFIG, CONFIG_DEFAULTS
from guardian.models import UserObjectPermission, GroupObjectPermission

        "master_ip_address",
        hosts = (
            Host.objects.prefetch_related("addresses")
            .filter(
                structured_attributes__structured_attribute_value__attribute__name="nac-profile",
                structured_attributes__structured_attribute_value__value__startswith=CONFIG_DEFAULTS[
                    "NAC_PROFILE_IS_SERVER_PREFIX"
                ],
            )
            .annotate(
                nac_profile=F(
                    "structured_attributes__structured_attribute_value__value"
                ),
            )
        )

        user_perms_prefetch = UserObjectPermission.objects.select_related(
            "permission", "user"
        ).filter(
            content_type=ContentType.objects.get_for_model(Host),
            object_pk__in=[str(host.mac) for host in hosts],
            permission__codename="is_owner_host",
        )
        group_perms_prefetch = GroupObjectPermission.objects.select_related(
            "permission", "group"
        ).filter(
            content_type=ContentType.objects.get_for_model(Host),
            object_pk__in=[str(host.mac) for host in hosts],
            permission__codename="is_owner_host",
        data = []
        for host in hosts:
            owners = host.get_owners(
                name_only=True,
                user_perms_prefetch=user_perms_prefetch,
                group_perms_prefetch=group_perms_prefetch,
            )
            data.append(
                {
                    "hostname": host.hostname,
                    "mac": str(host.mac),
                    "description": host.description,
                    "master_ip_address": host.ip_addresses[0]
                    if host.ip_addresses
                    else None,
                    "user_owners": ", ".join(owners[0]),
                    "group_owners": ", ".join(owners[1]),
                    "nac_profile": host.nac_profile,
                }
            )
          data: "hostname"
          data: "mac"
          orderable: false
          data: "master_ip_address"
          data: "user_owners",
          orderable: false
          data: "group_owners",
          orderable: false
          data: "nac_profile",
          orderable: false
          <th scope="col">IP Address</th>
          <th scope="col">User Owners</th>
          <th scope="col">Group Owners</th>
          <th scope="col">NAC Profile</th>
Fixed code: from django.db import connection
from django.contrib.auth.models import Permission
from django.db.models import Q
from openipam.hosts.models import Host, Attribute
from openipam.conf.ipam_settings import CONFIG
        "addresses",
        "nac_profiles",
        nac_profile_attribute = Attribute.objects.get(name="nac-profile")
        host_owner_permission = Permission.objects.get(codename="is_owner_host")

        cursor = connection.cursor()
        cursor.execute(
            """
            SELECT hosts.hostname AS hostname,
                   hosts.mac AS mac,
                   hosts.description AS description,
                   STRING_AGG(DISTINCT((SELECT CAST(addresses.address AS VARCHAR))), ', ') AS addresses,
                   STRING_AGG(DISTINCT(users.username), ', ') AS users,
                   STRING_AGG(DISTINCT(groups.name), ', ') AS groups,
                   STRING_AGG(DISTINCT(host_attr_vals.value), ', ') AS nac_profiles
            FROM hosts
                JOIN structured_attributes_to_hosts AS host_attrs ON hosts.mac=host_attrs.mac
                JOIN structured_attribute_values AS host_attr_vals ON host_attrs.avid=host_attr_vals.id
                LEFT JOIN guardian_userobjectpermission AS uop ON uop.object_pk=(SELECT CAST(hosts.mac AS VARCHAR)) AND uop.permission_id = %s
                LEFT JOIN guardian_groupobjectpermission AS gop ON gop.object_pk=(SELECT CAST(hosts.mac AS VARCHAR)) AND gop.permission_id=%s
                LEFT JOIN addresses ON hosts.mac=addresses.mac
                LEFT JOIN users ON uop.user_id=users.id
                LEFT JOIN groups ON gop.group_id=groups.id
            WHERE host_attr_vals.aid = %s
            AND STARTS_WITH(host_attr_vals.value, %s)
            GROUP BY hosts.mac, hosts.hostname, hosts.description
            """,
            [
                host_owner_permission.id,
                host_owner_permission.id,
                nac_profile_attribute.id,
                CONFIG["NAC_PROFILE_IS_SERVER_PREFIX"],
            ],
        data = [
            dict(zip([col[0] for col in cursor.description], row))
            for row in cursor.fetchall()
        ]
  const escapeHtml = (t) => $('<div />').text(t).html();  

          data: "hostname",
          orderable: true,
          data: "mac",
          orderable: true,
          render: (t) => `<a href="/hosts/${escapeHtml(t).replace(/\:/g, "")}/detail">${escapeHtml(t)}</a>`,
          orderable: false,
          render: escapeHtml,
          data: "addresses",
          orderable: true,
          data: "users",
          orderable: false,
          data: "groups",
          orderable: false,
          data: "nac_profiles",
          orderable: false,
          <th scope="col">IP Address(es)</th>
          <th scope="col">User Owner(s)</th>
          <th scope="col">Group Owner(s)</th>
          <th scope="col">NAC Profile(s)</th>