Skip to content

Commit b435880

Browse files
committed
Scope location and endpoint reference writes to authorized products
Hardening to object-level authorization on several API and UI write paths that associate locations and endpoints with findings and products. These paths now restrict the selectable references to the objects the requesting user is authorized for, matching the scoping the UI forms and dedicated viewsets already apply. Adds regression tests. No functional change for correctly-permissioned users.
1 parent f15a141 commit b435880

4 files changed

Lines changed: 186 additions & 4 deletions

File tree

dojo/api_v2/serializers.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
from rest_framework.exceptions import NotFound
1919
from rest_framework.exceptions import ValidationError as RestFrameworkValidationError
2020

21+
from dojo.endpoint.queries import get_authorized_endpoints
2122
from dojo.importers.auto_create_context import AutoCreateContextManager
2223
from dojo.importers.base_importer import BaseImporter
2324
from dojo.importers.default_importer import DefaultImporter
2425
from dojo.importers.default_reimporter import DefaultReImporter
2526
from dojo.location.models import Location
27+
from dojo.location.queries import get_authorized_locations
2628
from dojo.models import (
2729
DEDUPLICATION_EXECUTION_MODE_CHOICES,
2830
IMPORT_ACTIONS,
@@ -514,14 +516,20 @@ class CommonImportScanSerializer(serializers.Serializer):
514516
# TODO: Delete this after the move to Locations
515517
def __init__(self, *args, **kwargs):
516518
super().__init__(*args, **kwargs)
519+
# Scope endpoint_to_add to the locations/endpoints the requesting user is authorized for.
520+
user = getattr(self.context.get("request"), "user", None)
517521
if not settings.V3_FEATURE_LOCATIONS:
518522
# TODO: why do we allow only existing endpoints?
519523
self.fields["endpoint_to_add"] = serializers.PrimaryKeyRelatedField(
520-
queryset=Endpoint.objects.all(),
524+
queryset=get_authorized_endpoints("view", user=user) if user else Endpoint.objects.none(),
521525
required=False,
522526
default=None,
523527
help_text="Enter the ID of an Endpoint that is associated with the target Product. New Findings will be added to that Endpoint.",
524528
)
529+
else:
530+
self.fields["endpoint_to_add"].queryset = (
531+
get_authorized_locations("view", user=user) if user else Location.objects.none()
532+
)
525533

526534
def get_importer(
527535
self,

dojo/finding/api/serializer.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import dojo.finding.helper as finding_helper
1616
from dojo.authorization.authorization import user_has_permission
1717
from dojo.celery_dispatch import dojo_dispatch_task
18+
from dojo.endpoint.queries import get_authorized_endpoints
1819
from dojo.finding.cwe import cwe_label, cwe_number
1920
from dojo.finding.helper import (
2021
save_cwes,
@@ -26,6 +27,7 @@
2627
from dojo.jira import services as jira_services
2728
from dojo.jira.api.serializers import JIRAIssueSerializer
2829
from dojo.location.models import LocationFindingReference
30+
from dojo.location.queries import get_authorized_location_finding_reference
2931
from dojo.models import (
3032
SEVERITIES,
3133
Development_Environment,
@@ -379,9 +381,19 @@ def get_fields(self):
379381
# TODO: Delete this after the move to Locations
380382
def __init__(self, *args, **kwargs):
381383
super().__init__(*args, **kwargs)
384+
# Scope the endpoints field to the references the requesting user is authorized
385+
# for, mirroring the scoping the finding UI form already applies.
386+
user = getattr(self.context.get("request"), "user", None)
382387
if not settings.V3_FEATURE_LOCATIONS:
383388
self.fields["endpoints"] = serializers.PrimaryKeyRelatedField(
384-
many=True, required=False, queryset=Endpoint.objects.all(),
389+
many=True, required=False,
390+
queryset=get_authorized_endpoints("view", user=user) if user else Endpoint.objects.none(),
391+
)
392+
else:
393+
self.fields["endpoints"] = serializers.PrimaryKeyRelatedField(
394+
source="locations", many=True, required=False,
395+
queryset=get_authorized_location_finding_reference("view", user=user)
396+
if user else LocationFindingReference.objects.none(),
385397
)
386398

387399
def get_accepted_risks(self, obj):

dojo/url/ui/views.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from django.utils import timezone
1515

1616
from dojo.authorization.authorization import user_has_permission_or_403
17+
from dojo.authorization.roles_permissions import Permissions
1718
from dojo.endpoint.utils import endpoint_meta_import
1819
from dojo.forms import (
1920
DeleteEndpointForm,
@@ -24,6 +25,7 @@
2425
from dojo.location.queries import annotate_location_counts_and_status, get_authorized_locations
2526
from dojo.location.status import FindingLocationStatus, ProductLocationStatus
2627
from dojo.models import DojoMeta, Finding, Product
28+
from dojo.product.queries import get_authorized_products
2729
from dojo.reports.ui.views import generate_report
2830
from dojo.url.filters import URLFilter
2931
from dojo.url.models import URL
@@ -536,13 +538,26 @@ def endpoint_bulk_update_all(request, product_id=None):
536538
f"Skipped mitigation of {skipped_location_count} locations because you are not authorized.",
537539
)
538540

541+
# Scope the reference updates to the acting product (or, on the all-products
542+
# route, the products the user may edit); get_authorized_locations above scopes
543+
# only the Location rows, not their references.
544+
if product_id is not None:
545+
reference_products = Product.objects.filter(id=product_id)
546+
else:
547+
reference_products = get_authorized_products(Permissions.Product_Edit, request.user)
539548
# Bulk update the status of related FindingLocationStatus and ProductLocationStatus objects to 'Mitigated'
540-
finding_update_counts = LocationFindingReference.objects.filter(location__in=locations).update(
549+
finding_update_counts = LocationFindingReference.objects.filter(
550+
location__in=locations,
551+
finding__test__engagement__product__in=reference_products,
552+
).update(
541553
status=FindingLocationStatus.Mitigated,
542554
auditor=request.user,
543555
audit_time=timezone.now(),
544556
)
545-
product_update_counts = LocationProductReference.objects.filter(location__in=locations).update(
557+
product_update_counts = LocationProductReference.objects.filter(
558+
location__in=locations,
559+
product__in=reference_products,
560+
).update(
546561
status=ProductLocationStatus.Mitigated,
547562
)
548563
# Total number of updated statuses for reporting
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""
2+
Regression tests: the V3 location/endpoint reference write paths limit the
3+
references a request may select to the objects the requesting user is authorized
4+
for. Each test asserts an authorized reference is accepted and an unauthorized
5+
one is rejected, for the Finding endpoints field, the import endpoint_to_add
6+
field, and bulk-mitigate reference updates.
7+
"""
8+
from django.core.files.uploadedfile import SimpleUploadedFile
9+
from django.urls import reverse
10+
from django.utils.timezone import now
11+
from rest_framework.exceptions import ValidationError as DRFValidationError
12+
from rest_framework.test import APIRequestFactory
13+
14+
from dojo.api_v2.serializers import ImportScanSerializer
15+
from dojo.authorization.roles_permissions import Roles
16+
from dojo.location.models import (
17+
LocationFindingReference,
18+
LocationProductReference,
19+
)
20+
from dojo.location.status import FindingLocationStatus
21+
from dojo.models import (
22+
Dojo_User,
23+
Engagement,
24+
Finding,
25+
Product,
26+
Product_Member,
27+
Product_Type,
28+
Role,
29+
Test,
30+
Test_Type,
31+
)
32+
from dojo.url.models import URL
33+
from unittests.dojo_test_case import DojoAPITestCase, skip_unless_v3
34+
35+
GENERIC_FINDINGS = b'{"findings": [{"title": "probe", "severity": "Info", "description": "probe"}]}'
36+
37+
38+
@skip_unless_v3
39+
class V3LocationReferenceAuthzTests(DojoAPITestCase):
40+
41+
"""Two products, two users; each user is authorized only for their own product."""
42+
43+
@classmethod
44+
def setUpTestData(cls):
45+
cls.pt = Product_Type.objects.create(name="v3_locref_pt")
46+
cls.product_a = Product.objects.create(name="v3_locref_a", description="a", prod_type=cls.pt)
47+
cls.product_b = Product.objects.create(name="v3_locref_b", description="b", prod_type=cls.pt)
48+
49+
cls.user_a = Dojo_User.objects.create(username="v3_locref_user_a", is_active=True)
50+
cls.user_b = Dojo_User.objects.create(username="v3_locref_user_b", is_active=True)
51+
# Grant membership two ways so the test is authorization-backend agnostic:
52+
# legacy authorized_users (honored by the OS backend) and a Product_Member
53+
# Owner role (honored by the Pro backend).
54+
owner = Role.objects.get(id=Roles.Owner)
55+
for product, user in ((cls.product_a, cls.user_a), (cls.product_b, cls.user_b)):
56+
product.authorized_users.add(user)
57+
Product_Member.objects.create(product=product, user=user, role=owner)
58+
59+
cls.test_type, _ = Test_Type.objects.get_or_create(name="v3_locref_scan")
60+
61+
cls.finding_a = cls._make_finding(cls.product_a, cls.user_a, "finding_a")
62+
cls.engagement_a = cls.finding_a.test.engagement
63+
cls.finding_b = cls._make_finding(cls.product_b, cls.user_b, "finding_b")
64+
65+
# A location on product_b (authorized only for user_b).
66+
url_b = URL.get_or_create_from_object(URL.from_value("https://b.example.test/x"))
67+
cls.lfr_b = url_b.location.associate_with_finding(cls.finding_b)
68+
cls.location_b = url_b.location
69+
70+
# A location on product_a (authorized for user_a) for the accepted-case controls.
71+
cls.finding_a_extra = cls._make_finding(cls.product_a, cls.user_a, "finding_a_extra")
72+
url_a = URL.get_or_create_from_object(URL.from_value("https://a.example.test/ok"))
73+
cls.lfr_a = url_a.location.associate_with_finding(cls.finding_a_extra)
74+
cls.location_a = url_a.location
75+
76+
# A location associated with both products, with an active reference from product_b.
77+
url_shared = URL.get_or_create_from_object(URL.from_value("https://shared.example.test/s"))
78+
cls.location_shared = url_shared.location
79+
cls.location_shared.associate_with_product(cls.product_a)
80+
cls.lfr_shared_b = LocationFindingReference.objects.create(
81+
location=cls.location_shared, finding=cls.finding_b, status=FindingLocationStatus.Active,
82+
)
83+
84+
@classmethod
85+
def _make_finding(cls, product, user, title):
86+
engagement = Engagement.objects.create(name=title + "_eng", product=product, target_start=now(), target_end=now())
87+
test = Test.objects.create(engagement=engagement, test_type=cls.test_type, target_start=now(), target_end=now())
88+
return Finding.objects.create(
89+
test=test, title=title, description="x", severity="High",
90+
numerical_severity="S0", active=True, verified=True, reporter=user,
91+
)
92+
93+
# Finding endpoints field
94+
def test_finding_endpoints_rejects_unauthorized_reference(self):
95+
self.client.force_authenticate(user=self.user_a)
96+
response = self.client.patch(
97+
reverse("finding-list") + f"{self.finding_a.id}/",
98+
{"endpoints": [self.lfr_b.id], "push_to_jira": False}, format="json", secure=True,
99+
)
100+
self.assertEqual(response.status_code, 400)
101+
self.assertFalse(
102+
LocationProductReference.objects.filter(location=self.location_b, product=self.product_a).exists(),
103+
)
104+
105+
def test_finding_endpoints_allows_authorized_reference(self):
106+
self.client.force_authenticate(user=self.user_a)
107+
response = self.client.patch(
108+
reverse("finding-list") + f"{self.finding_a.id}/",
109+
{"endpoints": [self.lfr_a.id], "push_to_jira": False}, format="json", secure=True,
110+
)
111+
self.assertEqual(response.status_code, 200)
112+
113+
# import endpoint_to_add
114+
def test_import_endpoint_to_add_rejects_unauthorized_reference(self):
115+
self.client.force_authenticate(user=self.user_a)
116+
response = self.client.post(reverse("importscan-list"), {
117+
"engagement": self.engagement_a.id,
118+
"scan_type": "Generic Findings Import",
119+
"endpoint_to_add": self.location_b.id,
120+
"file": SimpleUploadedFile("s.json", GENERIC_FINDINGS, content_type="application/json"),
121+
}, secure=True)
122+
self.assertEqual(response.status_code, 400)
123+
self.assertFalse(
124+
LocationProductReference.objects.filter(location=self.location_b, product=self.product_a).exists(),
125+
)
126+
127+
def test_import_endpoint_to_add_allows_authorized_reference(self):
128+
# Checked at the field level so the assertion is about the queryset scoping,
129+
# not the downstream import pipeline.
130+
request = APIRequestFactory().post(reverse("importscan-list"))
131+
request.user = self.user_a
132+
field = ImportScanSerializer(context={"request": request}).fields["endpoint_to_add"]
133+
self.assertEqual(field.to_internal_value(self.location_a.id), self.location_a)
134+
with self.assertRaises(DRFValidationError):
135+
field.to_internal_value(self.location_b.id)
136+
137+
# bulk mitigate reference updates
138+
def test_bulk_mitigate_scopes_reference_updates(self):
139+
self.client.force_login(self.user_a)
140+
response = self.client.post(
141+
reverse("endpoints_bulk_update_all_product", args=(self.product_a.id,)),
142+
data={"endpoints_to_update": self.location_shared.id}, secure=True,
143+
)
144+
self.assertIn(response.status_code, (200, 302))
145+
self.lfr_shared_b.refresh_from_db()
146+
# A reference outside the acting product is left unchanged.
147+
self.assertEqual(self.lfr_shared_b.status, FindingLocationStatus.Active)

0 commit comments

Comments
 (0)