Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions dojo/api_v2/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions unittests/test_apiv2_oa3_schema.py
Original file line number Diff line number Diff line change
@@ -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=<CR/LF> 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", ""))
Loading