From cfebf13ac471fa4384d2772559f7a3994e358c88 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 00:27:57 +0000 Subject: [PATCH] fix(api): sanitize the schema download filename so a crafted ?version= cannot 500 GET/HEAD /api/v2/oa3/schema/?version= returned 500 with django.http.response.BadHeaderError. drf-spectacular interpolates the attacker-controlled ?version= query parameter into the Content-Disposition filename unsanitized, and Django refuses header values containing CR/LF. The header injection never actually succeeds, but each scanner probe produced an unhandled 500 and a diagnostics error report. Override DojoSpectacularAPIView._get_filename to strip CR, LF and the quote delimiter from the filename, so the response header is always well formed whatever ?version= contains. The Pro SerializedSpectacularAPIView subclass inherits this with no extra change. Add a regression test. Refs DefectDojo-Inc/dojo-pro#6013 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013r7wUwFigkbymmti9TLSmL --- dojo/api_v2/views.py | 12 ++++++++ unittests/test_apiv2_oa3_schema.py | 44 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 unittests/test_apiv2_oa3_schema.py diff --git a/dojo/api_v2/views.py b/dojo/api_v2/views.py index 12807882b4c..b2919ceb9f7 100644 --- a/dojo/api_v2/views.py +++ b/dojo/api_v2/views.py @@ -149,6 +149,18 @@ def get_indent(self, accepted_media_type, renderer_context): class DojoSpectacularAPIView(SpectacularAPIView): renderer_classes = [DojoOpenApiJsonRenderer, *SpectacularAPIView.renderer_classes] + def _get_filename(self, request, version): + # The schema download filename is interpolated into the Content-Disposition response + # header, and `version` is attacker-controlled (the ?version= query parameter). + # drf-spectacular does not sanitize it, so a CR/LF in ?version= makes Django's + # HttpResponse raise BadHeaderError -- an unauthenticated 500 (and a diagnostics error + # report) on every probe. The header injection never actually succeeds because Django + # rejects header values containing CR/LF, but the crash and the error-report noise are + # avoidable. Strip CR, LF and the double quote that delimits the filename so the header + # is always well formed, whatever `version` (or a DRF versioning scheme) produced. + filename = super()._get_filename(request, version) + return filename.translate({0x0D: None, 0x0A: None, 0x22: None}) + class DojoModelViewSet( viewsets.ModelViewSet, diff --git a/unittests/test_apiv2_oa3_schema.py b/unittests/test_apiv2_oa3_schema.py new file mode 100644 index 00000000000..1afa495a75c --- /dev/null +++ b/unittests/test_apiv2_oa3_schema.py @@ -0,0 +1,44 @@ +from urllib.parse import urlencode + +from django.urls import reverse +from rest_framework.authtoken.models import Token +from rest_framework.test import APIClient, APITestCase + +from unittests.dojo_test_case import versioned_fixtures + + +@versioned_fixtures +class OA3SchemaContentDispositionTest(APITestCase): + # Regression: GET /api/v2/oa3/schema/?version= returned 500 with + # django.http.response.BadHeaderError, because drf-spectacular interpolated the + # attacker-controlled ?version= query parameter into the Content-Disposition filename + # unsanitized. The header injection never succeeds (Django rejects CR/LF in header + # values), but the unhandled exception became a 500 plus a diagnostics error report on + # every scanner probe. DojoSpectacularAPIView now strips CR/LF (and the quote delimiter) + # from the filename. + fixtures = ["dojo_testdata.json"] + + def setUp(self): + token = Token.objects.get(user__username="admin") + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION="Token " + token.key) + + def test_crlf_in_version_is_not_a_500_and_injects_no_header(self): + query = urlencode({"format": "json", "version": "test\r\nX-Injected: 1"}) + response = self.client.get(reverse("schema_oa3") + "?" + query) + + self.assertEqual( + response.status_code, 200, + msg=f"expected 200, got {response.status_code} (CR/LF in ?version= must not crash the response)", + ) + disposition = response.headers.get("Content-Disposition", "") + self.assertNotIn("\r", disposition, msg=f"Content-Disposition carried a CR: {disposition!r}") + self.assertNotIn("\n", disposition, msg=f"Content-Disposition carried a LF: {disposition!r}") + self.assertNotIn("X-Injected", response.headers, msg="the crafted header must not be injected") + + def test_a_normal_version_is_still_served(self): + query = urlencode({"format": "json", "version": "v2"}) + response = self.client.get(reverse("schema_oa3") + "?" + query) + + self.assertEqual(response.status_code, 200, msg=f"expected 200, got {response.status_code}") + self.assertIn("v2", response.headers.get("Content-Disposition", ""))