CVE-2023-50263 (published 2023-12-12T23:15Z)

Nautobot is a Network Source of Truth and Network Automation Platform built as a web application atop the Django Python framework with a PostgreSQL or MySQL database. In Nautobot 1.x and 2.0.x prior to 1.6.7 and 2.0.6, the URLs `/files/get/?name=...` and `/files/download/?name=...` are used to provide admin access to files that have been uploaded as part of a run request for a Job that has FileVar inputs. Under normal operation these files are ephemeral and are deleted once the Job in question runs. \n\nIn the default implementation used in Nautobot, as provided by `django-db-file-storage`, these URLs do not by default require any user authentication to access; they should instead be restricted to only users who have permissions to view Nautobot's `FileProxy` model instances.\n\nNote that no URL mechanism is provided for listing or traversal of the available file `name` values, so in practice an unauthenticated user would have to guess names to discover arbitrary files for download, but if a user knows the file name/path value, they can access it without authenticating, so we are considering this a vulnerability.\n\nFixes are included in Nautobot 1.6.7 and Nautobot 2.0.6. No known workarounds are available other than applying the patches included in those versions.

CVSS v3 base score: 5.3, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N)

Weakness information:
cwe_id: CWE-306
cwe_name: Missing Authentication for Critical Function
description: The product does not perform any authentication for functionality that requires a provable user identity or consumes a significant amount of resources.
extended_description: 
            
            
         
         

Fix information:
repo_url: https://github.com/nautobot/nautobot
commit_details: repo_url: https://github.com/nautobot/nautobot
author: Leo Kirchner
author_date: 2023-12-12 21:03:54+01:00
msg: requires authentication on DB file storage views (#4959)

* requires authentication on DB file storage views

* Update change fragment, add object permissions enforcement

---------

Co-authored-by: Glenn Matthews <glenn.matthews@networktocode.com>
repository: repo_url: https://github.com/nautobot/nautobot
repo_name: nautobot/nautobot
description: Network Source of Truth & Network Automation Platform
repo_language: Python
owner: nautobot
file_changes: filename: 4959.security
change_type: ModificationType.ADD
diff: @@ -0,0 +1 @@
+Enforce authentication and object permissions on DB file storage views (https://github.com/nautobot/nautobot/security/advisories/GHSA-75mc-3pjc-727q).

programming_language: Markdown
filename: test_views.py
change_type: ModificationType.MODIFY
diff: @@ -3,17 +3,21 @@ from unittest import mock
 import urllib.parse
 
 from django.contrib.contenttypes.models import ContentType
+from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import RequestFactory, override_settings
 from django.test.utils import override_script_prefix
 from django.urls import get_script_prefix, reverse
 from prometheus_client.parser import text_string_to_metric_families
 
 from nautobot.core.testing import TestCase
+from nautobot.core.utils.permissions import get_permission_for_model
 from nautobot.core.views.mixins import GetReturnURLMixin
 from nautobot.dcim.models.locations import Location
 from nautobot.extras.choices import CustomFieldTypeChoices
+from nautobot.extras.models import FileProxy
 from nautobot.extras.models.customfields import CustomField, CustomFieldChoice
 from nautobot.extras.registry import registry
+from nautobot.users.models import ObjectPermission
 
 
 class GetReturnURLMixinTestCase(TestCase):
@@ -476,3 +480,59 @@ class ErrorPagesTestCase(TestCase):
         self.assertNotContains(response, "Network to Code", status_code=500)
         response_content = response.content.decode(response.charset)
         self.assertInHTML("Hello world!", response_content)
+
+
+class DBFileStorageViewTestCase(TestCase):
+    """Test authentication/permission enforcement for django_db_file_storage views."""
+
+    def setUp(self):
+        super().setUp()
+        self.test_file_1 = SimpleUploadedFile(name="test_file_1.txt", content=b"I am content.\n")
+        self.file_proxy_1 = FileProxy.objects.create(name=self.test_file_1.name, file=self.test_file_1)
+        self.test_file_2 = SimpleUploadedFile(name="test_file_2.txt", content=b"I am content.\n")
+        self.file_proxy_2 = FileProxy.objects.create(name=self.test_file_2.name, file=self.test_file_2)
+        self.urls = [
+            f"{reverse('db_file_storage.download_file')}?name={self.file_proxy_1.file.name}",
+            f"{reverse('db_file_storage.get_file')}?name={self.file_proxy_1.file.name}",
+        ]
+
+    def test_get_file_anonymous(self):
+        self.client.logout()
+        for url in self.urls:
+            with self.subTest(url):
+                response = self.client.get(url)
+                self.assertHttpStatus(response, 403)
+
+    def test_get_file_without_permission(self):
+        for url in self.urls:
+            with self.subTest(url):
+                response = self.client.get(url)
+                self.assertHttpStatus(response, 403)
+
+    def test_get_object_with_permission(self):
+        self.add_permissions(get_permission_for_model(FileProxy, "view"))
+        for url in self.urls:
+            with self.subTest(url):
+                response = self.client.get(url)
+                self.assertHttpStatus(response, 200)
+
+    def test_get_object_with_constrained_permission(self):
+        obj_perm = ObjectPermission(
+            name="Test permission",
+            constraints={"pk": self.file_proxy_1.pk},
+            actions=["view"],
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ContentType.objects.get_for_model(FileProxy))
+        for url in self.urls:
+            with self.subTest(url):
+                response = self.client.get(url)
+                self.assertHttpStatus(response, 200)
+        for url in [
+            f"{reverse('db_file_storage.download_file')}?name={self.file_proxy_2.file.name}",
+            f"{reverse('db_file_storage.get_file')}?name={self.file_proxy_2.file.name}",
+        ]:
+            with self.subTest(url):
+                response = self.client.get(url)
+                self.assertHttpStatus(response, 404)

programming_language: Python
filename: urls.py
change_type: ModificationType.MODIFY
diff: @@ -1,9 +1,16 @@
 from django.conf import settings
-from django.conf.urls import include
+from django.conf.urls import include, url
 from django.urls import path
 from django.views.static import serve
 
-from nautobot.core.views import CustomGraphQLView, HomeView, StaticMediaFailureView, SearchView, nautobot_metrics_view
+from nautobot.core.views import (
+    CustomGraphQLView,
+    HomeView,
+    StaticMediaFailureView,
+    SearchView,
+    nautobot_metrics_view,
+    get_file_with_authorization,
+)
 from nautobot.extras.plugins.urls import (
     plugin_admin_patterns,
     plugin_patterns,
@@ -45,7 +52,18 @@ urlpatterns = [
     # django-health-check
     path(r"health/", include("health_check.urls")),
     # FileProxy attachments download/get URLs used in admin views only
-    path("files/", include("db_file_storage.urls")),
+    url(
+        "files/download/",
+        get_file_with_authorization,
+        {"add_attachment_headers": True},
+        name="db_file_storage.download_file",
+    ),
+    url(
+        "files/get/",
+        get_file_with_authorization,
+        {"add_attachment_headers": False},
+        name="db_file_storage.get_file",
+    ),
 ]
 
 

programming_language: Python
filename: __init__.py
change_type: ModificationType.MODIFY
diff: @@ -3,12 +3,14 @@ import platform
 import sys
 import time
 
+from db_file_storage.views import get_file
 from django.apps import apps
 import prometheus_client
 from django.conf import settings
+from django.contrib.auth.decorators import permission_required
 from django.contrib.auth.mixins import AccessMixin
 from django.http import HttpResponseServerError, JsonResponse, HttpResponseForbidden, HttpResponse
-from django.shortcuts import redirect, render
+from django.shortcuts import get_object_or_404, redirect, render
 from django.template import loader, RequestContext, Template
 from django.template.exceptions import TemplateDoesNotExist
 from django.urls import resolve, reverse
@@ -27,7 +29,8 @@ from nautobot.core.forms import SearchForm
 from nautobot.core.releases import get_latest_release
 from nautobot.core.utils.config import get_settings_or_config
 from nautobot.core.utils.lookup import get_route_for_model
-from nautobot.extras.models import GraphQLQuery
+from nautobot.core.utils.permissions import get_permission_for_model
+from nautobot.extras.models import GraphQLQuery, FileProxy
 from nautobot.extras.registry import registry
 from nautobot.extras.forms import GraphQLQueryForm
 
@@ -294,3 +297,13 @@ def nautobot_metrics_view(request):
         pass
     metrics_page = prometheus_client.generate_latest(prometheus_registry)
     return HttpResponse(metrics_page, content_type=prometheus_client.CONTENT_TYPE_LATEST)
+
+
+@permission_required(get_permission_for_model(FileProxy, "view"), raise_exception=True)
+def get_file_with_authorization(request, *args, **kwargs):
+    """Patch db_file_storage view with authentication."""
+    # Make sure user has permissions
+    queryset = FileProxy.objects.restrict(request.user, "view")
+    get_object_or_404(queryset, file=request.GET.get("name"))
+
+    return get_file(request, *args, **kwargs)

programming_language: Python