diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index a8649c629..45639dd4b 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -8,6 +8,7 @@ from django.core.exceptions import ImproperlyConfigured from django.db import ProgrammingError, connection from django.db.utils import InternalError from django.http import Http404, HttpResponseRedirect +from django.middleware.common import CommonMiddleware as DjangoCommonMiddleware from django_prometheus import middleware from netbox.config import clear_config, get_config @@ -18,6 +19,7 @@ from utilities.error_handlers import handle_rest_api_exception from utilities.request import apply_request_processors __all__ = ( + 'CommonMiddleware', 'CoreMiddleware', 'MaintenanceModeMiddleware', 'PrometheusAfterMiddleware', @@ -26,6 +28,23 @@ __all__ = ( ) +class CommonMiddleware(DjangoCommonMiddleware): + """ + Subclass of Django's CommonMiddleware that suppresses the APPEND_SLASH + redirect for REST API requests using an unsafe HTTP method. Redirecting a + POST/PUT/PATCH/DELETE to a trailing-slash URL would either drop the request + body (clients downgrade to GET on a 302) or raise a RuntimeError when + DEBUG is enabled. Letting the original 404 propagate gives the caller a + clear, actionable error instead. + """ + UNSAFE_METHODS = frozenset(('DELETE', 'PATCH', 'POST', 'PUT')) + + def should_redirect_with_slash(self, request): + if request.method in self.UNSAFE_METHODS and is_api_request(request): + return False + return super().should_redirect_with_slash(request) + + class CoreMiddleware: def __init__(self, get_response): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0840f0149..592309e82 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -474,7 +474,7 @@ MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.locale.LocaleMiddleware', - 'django.middleware.common.CommonMiddleware', + 'netbox.middleware.CommonMiddleware', # Replaces django.middleware.common.CommonMiddleware 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index 885f71a73..3e4405917 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -284,3 +284,55 @@ class GetViewNameTestCase(TestCase): name = get_view_name(view) self.assertEqual(name, 'Mock List') + + +class APITrailingSlashTestCase(APITestCase): + """ + Verify behavior for REST API requests sent to a URL without a trailing slash. + + GET requests should continue to be redirected to the trailing-slash URL (Django's default + APPEND_SLASH behavior). Write methods (POST/PUT/PATCH/DELETE) should instead receive a 404 + so that the request body is not silently dropped by a redirect. + """ + model = Site + user_permissions = ('dcim.view_site', 'dcim.add_site', 'dcim.change_site', 'dcim.delete_site') + + @classmethod + def setUpTestData(cls): + cls.site = Site.objects.create(name='Site 1', slug='site-1') + + def _strip_slash(self, url): + return url.rstrip('/') + + def test_get_redirects(self): + url = self._strip_slash(reverse('dcim-api:site-list')) + response = self.client.get(url, **self.header) + self.assertIn(response.status_code, (301, 302)) + self.assertTrue(response['Location'].endswith('/')) + + def test_post_returns_404(self): + url = self._strip_slash(reverse('dcim-api:site-list')) + data = {'name': 'Site 2', 'slug': 'site-2'} + with disable_warnings('django.request'): + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND) + + def test_patch_returns_404(self): + url = self._strip_slash(self._get_detail_url(self.site)) + with disable_warnings('django.request'): + response = self.client.patch(url, {'name': 'Renamed'}, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND) + + def test_put_returns_404(self): + url = self._strip_slash(self._get_detail_url(self.site)) + data = {'name': 'Renamed', 'slug': 'renamed'} + with disable_warnings('django.request'): + response = self.client.put(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND) + + def test_delete_returns_404(self): + url = self._strip_slash(self._get_detail_url(self.site)) + with disable_warnings('django.request'): + response = self.client.delete(url, **self.header) + self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND) + self.assertTrue(Site.objects.filter(pk=self.site.pk).exists())