CVE-2023-50263: Missing Authentication for Critical Function (CWE-306)
value: 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. 

In 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.

Note 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.

Fixes 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.
CWE description: The product does not perform any authentication for functionality that requires a provable user identity or consumes a significant amount of resources.
CVSS v3 base score: 5.3
Published: 2023-12-12T23:15Z
Repository: https://github.com/nautobot/nautobot
Language: Python
Files changed: changes/4959.security, nautobot/core/tests/test_views.py, nautobot/core/urls.py, nautobot/core/views/__init__.py
Commit date: 2023-12-12 15:03:54 -0500
Commit message: 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>
Diff: diff --git a/changes/4959.security b/changes/4959.security
new file mode 100644
index 0000000000..ee836c2332
--- /dev/null
+++ b/changes/4959.security
@@ -0,0 +1 @@
+Enforce authentication and object permissions on DB file storage views (https://github.com/nautobot/nautobot/security/advisories/GHSA-75mc-3pjc-727q).
diff --git a/nautobot/core/tests/test_views.py b/nautobot/core/tests/test_views.py
index e8ef1aba95..e4ddea45b3 100644
--- a/nautobot/core/tests/test_views.py
+++ b/nautobot/core/tests/test_views.py
@@ -1,21 +1,25 @@
 import re
 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):
     """Tests for the API of GetReturnURLMixin."""
 
@@ -474,5 +478,61 @@ class ErrorPagesTestCase(TestCase):
             self.client.raise_request_exception = False
             response = self.client.get(url)
         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)
diff --git a/nautobot/core/urls.py b/nautobot/core/urls.py
index 9967308e49..eb61def493 100644
--- a/nautobot/core/urls.py
+++ b/nautobot/core/urls.py
@@ -1,11 +1,18 @@
 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,
 )
 from nautobot.users.views import LoginView, LogoutView
@@ -43,11 +50,22 @@ urlpatterns = [
     # Social auth/SSO
     path("", include("social_django.urls", namespace="social")),
     # 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",
+    ),
 ]
 
 
 if settings.DEBUG:
     try:
diff --git a/nautobot/core/views/__init__.py b/nautobot/core/views/__init__.py
index 9f5e6f7368..28e2105c7a 100644
--- a/nautobot/core/views/__init__.py
+++ b/nautobot/core/views/__init__.py
@@ -1,16 +1,18 @@
 import os
 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
 from django.views.decorators.csrf import requires_csrf_token
 from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
@@ -25,11 +27,12 @@ from prometheus_client.registry import Collector
 from nautobot.core.constants import SEARCH_MAX_RESULTS
 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
 
 
 class HomeView(AccessMixin, TemplateView):
@@ -292,5 +295,15 @@ def nautobot_metrics_view(request):
     except ValueError:
         # Collector already registered, we are running without multiprocessing
         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)

Vulnerable code: from django.conf.urls import include
from nautobot.core.views import CustomGraphQLView, HomeView, StaticMediaFailureView, SearchView, nautobot_metrics_view
    path("files/", include("db_file_storage.urls")),
from django.shortcuts import redirect, render
from nautobot.extras.models import GraphQLQuery
Fixed code: Enforce authentication and object permissions on DB file storage views (https://github.com/nautobot/nautobot/security/advisories/GHSA-75mc-3pjc-727q).
from django.core.files.uploadedfile import SimpleUploadedFile
from nautobot.core.utils.permissions import get_permission_for_model
from nautobot.extras.models import FileProxy
from nautobot.users.models import ObjectPermission


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)
from django.conf.urls import include, url
from nautobot.core.views import (
    CustomGraphQLView,
    HomeView,
    StaticMediaFailureView,
    SearchView,
    nautobot_metrics_view,
    get_file_with_authorization,
)
    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",
    ),
from db_file_storage.views import get_file
from django.contrib.auth.decorators import permission_required
from django.shortcuts import get_object_or_404, redirect, render
from nautobot.core.utils.permissions import get_permission_for_model
from nautobot.extras.models import GraphQLQuery, FileProxy


@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)