Skip to content

Commit fd26908

Browse files
Maffoochclaude
andcommitted
Fix remove_finding BFLA and add test coverage (PR #14633)
Gate the remove_finding POST branch on edit_mode so only the edit URL (requiring Risk_Acceptance permission) can process finding removals. Scope the finding lookup to risk_acceptance.accepted_findings to prevent cross-product blind enumeration via sequential IDs. Add 6 security tests covering: edit_mode guard, scoped lookup, cross-product IDOR, decorator enforcement, and positive regression. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9f92409 commit fd26908

File tree

3 files changed

+265
-3
lines changed

3 files changed

+265
-3
lines changed

dojo/engagement/views.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1442,9 +1442,10 @@ def view_edit_risk_acceptance(request, eid, raid, *, edit_mode=False):
14421442
"Since you are not the note's author, it was not deleted.",
14431443
extra_tags="alert-danger")
14441444

1445-
if "remove_finding" in request.POST:
1445+
if edit_mode and "remove_finding" in request.POST:
14461446
finding = get_object_or_404(
1447-
Finding, pk=request.POST["remove_finding_id"])
1447+
risk_acceptance.accepted_findings,
1448+
pk=request.POST["remove_finding_id"])
14481449

14491450
ra_helper.remove_finding_from_risk_acceptance(request.user, risk_acceptance, finding)
14501451

unittests/test_permissions_audit.py

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
8. Questionnaire cross-engagement IDOR (H1 #3571957)
1313
9. Finding Templates exposure via find_template_to_apply (H1 #3577363)
1414
10. Jira Epic BFLA - Reader cannot trigger update_jira_epic (H1 #3577193)
15+
11. Risk Acceptance remove_finding: edit_mode guard + scoped finding lookup (PR #14633)
1516
"""
1617
import datetime
1718

@@ -701,6 +702,266 @@ def test_view_risk_acceptance_same_engagement(self):
701702
self.assertEqual(response.status_code, 200)
702703

703704

705+
class TestRiskAcceptanceRemoveFindingGuard(DojoTestCase):
706+
707+
"""
708+
PR #14633: view_edit_risk_acceptance must:
709+
1. Only process 'remove_finding' when edit_mode is True (Writer+ via edit URL).
710+
2. Scope the finding lookup to risk_acceptance.accepted_findings (not global Finding).
711+
712+
Prevents a Reader from removing findings via the view URL, and prevents
713+
cross-product blind enumeration of finding IDs.
714+
"""
715+
716+
@classmethod
717+
def setUpTestData(cls):
718+
cls.reader_role = Role.objects.get(name="Reader")
719+
cls.owner_role = Role.objects.get(name="Owner")
720+
721+
# ── Product A ────────────────────────────────────────────────
722+
cls.product_type_a = Product_Type.objects.create(
723+
name="RA Remove Guard Test PT A",
724+
)
725+
cls.product_a = Product.objects.create(
726+
name="RA Remove Guard Product A",
727+
description="Test",
728+
prod_type=cls.product_type_a,
729+
enable_full_risk_acceptance=True,
730+
)
731+
732+
# ── Product B (for cross-product IDOR test) ─────────────────
733+
cls.product_type_b = Product_Type.objects.create(
734+
name="RA Remove Guard Test PT B",
735+
)
736+
cls.product_b = Product.objects.create(
737+
name="RA Remove Guard Product B",
738+
description="Test",
739+
prod_type=cls.product_type_b,
740+
enable_full_risk_acceptance=True,
741+
)
742+
743+
# ── Users ────────────────────────────────────────────────────
744+
cls.reader_user_a = Dojo_User.objects.create_user(
745+
username="ra_remove_reader_a",
746+
password="testTEST1234!@#$", # noqa: S106
747+
is_active=True,
748+
)
749+
cls.owner_user_a = Dojo_User.objects.create_user(
750+
username="ra_remove_owner_a",
751+
password="testTEST1234!@#$", # noqa: S106
752+
is_active=True,
753+
)
754+
cls.owner_user_b = Dojo_User.objects.create_user(
755+
username="ra_remove_owner_b",
756+
password="testTEST1234!@#$", # noqa: S106
757+
is_active=True,
758+
)
759+
760+
# ── Role assignments ─────────────────────────────────────────
761+
Product_Member.objects.create(
762+
product=cls.product_a,
763+
user=cls.reader_user_a,
764+
role=cls.reader_role,
765+
)
766+
Product_Member.objects.create(
767+
product=cls.product_a,
768+
user=cls.owner_user_a,
769+
role=cls.owner_role,
770+
)
771+
Product_Member.objects.create(
772+
product=cls.product_b,
773+
user=cls.owner_user_b,
774+
role=cls.owner_role,
775+
)
776+
777+
# ── Product A: engagement, test, findings, risk acceptance ───
778+
cls.engagement_a = Engagement.objects.create(
779+
name="RA Remove Guard Engagement A",
780+
product=cls.product_a,
781+
target_start=datetime.date(2024, 1, 1),
782+
target_end=datetime.date(2024, 12, 31),
783+
)
784+
test_type, _ = Test_Type.objects.get_or_create(
785+
name="Manual Code Review",
786+
)
787+
cls.test_a = Test.objects.create(
788+
engagement=cls.engagement_a,
789+
test_type=test_type,
790+
target_start=timezone.now(),
791+
target_end=timezone.now(),
792+
)
793+
794+
# Finding that IS in the risk acceptance
795+
cls.finding_a = Finding.objects.create(
796+
title="RA Remove Guard Finding A",
797+
test=cls.test_a,
798+
severity="High",
799+
numerical_severity="S1",
800+
active=False,
801+
risk_accepted=True,
802+
reporter=cls.owner_user_a,
803+
)
804+
805+
# Finding in same engagement but NOT in the risk acceptance
806+
cls.finding_a_extra = Finding.objects.create(
807+
title="RA Remove Guard Finding A Extra",
808+
test=cls.test_a,
809+
severity="Medium",
810+
numerical_severity="S2",
811+
active=True,
812+
risk_accepted=False,
813+
reporter=cls.owner_user_a,
814+
)
815+
816+
cls.risk_acceptance_a = Risk_Acceptance.objects.create(
817+
name="RA Remove Guard RA A",
818+
owner=cls.owner_user_a,
819+
)
820+
cls.risk_acceptance_a.accepted_findings.add(cls.finding_a)
821+
cls.engagement_a.risk_acceptance.add(cls.risk_acceptance_a)
822+
823+
# ── Product B: engagement, test, finding, risk acceptance ────
824+
cls.engagement_b = Engagement.objects.create(
825+
name="RA Remove Guard Engagement B",
826+
product=cls.product_b,
827+
target_start=datetime.date(2024, 1, 1),
828+
target_end=datetime.date(2024, 12, 31),
829+
)
830+
cls.test_b = Test.objects.create(
831+
engagement=cls.engagement_b,
832+
test_type=test_type,
833+
target_start=timezone.now(),
834+
target_end=timezone.now(),
835+
)
836+
cls.finding_b = Finding.objects.create(
837+
title="RA Remove Guard Finding B",
838+
test=cls.test_b,
839+
severity="High",
840+
numerical_severity="S1",
841+
active=False,
842+
risk_accepted=True,
843+
reporter=cls.owner_user_b,
844+
)
845+
846+
cls.risk_acceptance_b = Risk_Acceptance.objects.create(
847+
name="RA Remove Guard RA B",
848+
owner=cls.owner_user_b,
849+
)
850+
cls.risk_acceptance_b.accepted_findings.add(cls.finding_b)
851+
cls.engagement_b.risk_acceptance.add(cls.risk_acceptance_b)
852+
853+
def _login(self, username):
854+
client = Client()
855+
client.login(
856+
username=username,
857+
password="testTEST1234!@#$", # noqa: S106
858+
)
859+
return client
860+
861+
def _remove_finding_data(self, finding_id):
862+
return {
863+
"remove_finding": "Remove",
864+
"remove_finding_id": finding_id,
865+
}
866+
867+
# ── Test 1: edit_mode guard (BFLA) ───────────────────────────────
868+
869+
def test_reader_cannot_remove_finding_via_view_url(self):
870+
"""Reader POSTing remove_finding to view URL must be silently ignored."""
871+
client = self._login("ra_remove_reader_a")
872+
url = reverse("view_risk_acceptance", args=(
873+
self.engagement_a.id, self.risk_acceptance_a.id,
874+
))
875+
response = client.post(url, self._remove_finding_data(self.finding_a.id))
876+
# View still redirects (302) because errors=False, but finding is untouched
877+
self.assertEqual(response.status_code, 302)
878+
self.finding_a.refresh_from_db()
879+
self.assertFalse(self.finding_a.active)
880+
self.assertTrue(self.finding_a.risk_accepted)
881+
self.assertTrue(
882+
self.risk_acceptance_a.accepted_findings
883+
.filter(pk=self.finding_a.pk)
884+
.exists(),
885+
)
886+
887+
# ── Test 2: positive regression (edit URL works) ─────────────────
888+
889+
def test_owner_can_remove_finding_via_edit_url(self):
890+
"""Owner POSTing remove_finding to edit URL must succeed."""
891+
client = self._login("ra_remove_owner_a")
892+
url = reverse("edit_risk_acceptance", args=(
893+
self.engagement_a.id, self.risk_acceptance_a.id,
894+
))
895+
response = client.post(url, self._remove_finding_data(self.finding_a.id))
896+
self.assertEqual(response.status_code, 302)
897+
self.finding_a.refresh_from_db()
898+
self.assertTrue(self.finding_a.active)
899+
self.assertFalse(self.finding_a.risk_accepted)
900+
self.assertFalse(
901+
self.risk_acceptance_a.accepted_findings
902+
.filter(pk=self.finding_a.pk)
903+
.exists(),
904+
)
905+
906+
# ── Test 3: scoped lookup (finding not in this RA) ───────────────
907+
908+
def test_finding_not_in_risk_acceptance_returns_404(self):
909+
"""Supplying a finding ID not in the RA's accepted_findings must 404."""
910+
client = self._login("ra_remove_owner_a")
911+
url = reverse("edit_risk_acceptance", args=(
912+
self.engagement_a.id, self.risk_acceptance_a.id,
913+
))
914+
# finding_a_extra exists in the same engagement but is NOT accepted
915+
response = client.post(
916+
url, self._remove_finding_data(self.finding_a_extra.id),
917+
)
918+
self.assertEqual(response.status_code, 404)
919+
920+
# ── Test 4: cross-product IDOR ───────────────────────────────────
921+
922+
def test_cross_product_finding_id_rejected(self):
923+
"""Finding from Product B cannot be removed via Product A's RA."""
924+
client = self._login("ra_remove_owner_a")
925+
url = reverse("edit_risk_acceptance", args=(
926+
self.engagement_a.id, self.risk_acceptance_a.id,
927+
))
928+
response = client.post(
929+
url, self._remove_finding_data(self.finding_b.id),
930+
)
931+
self.assertEqual(response.status_code, 404)
932+
# Product B's finding must remain untouched
933+
self.finding_b.refresh_from_db()
934+
self.assertFalse(self.finding_b.active)
935+
self.assertTrue(self.finding_b.risk_accepted)
936+
937+
# ── Test 5: Reader blocked by decorator on edit URL ──────────────
938+
939+
def test_reader_blocked_on_edit_url_by_decorator(self):
940+
"""Reader lacks Risk_Acceptance permission — edit URL itself is denied."""
941+
client = self._login("ra_remove_reader_a")
942+
url = reverse("edit_risk_acceptance", args=(
943+
self.engagement_a.id, self.risk_acceptance_a.id,
944+
))
945+
response = client.post(url, self._remove_finding_data(self.finding_a.id))
946+
# PermissionDenied raised; custom handler403 returns 400 (DD bug)
947+
self.assertIn(response.status_code, [400, 403])
948+
# Finding must remain untouched
949+
self.finding_a.refresh_from_db()
950+
self.assertFalse(self.finding_a.active)
951+
self.assertTrue(self.finding_a.risk_accepted)
952+
953+
# ── Test 6: nonexistent finding ID ───────────────────────────────
954+
955+
def test_nonexistent_finding_id_returns_404(self):
956+
"""A bogus finding ID must produce 404 from the scoped lookup."""
957+
client = self._login("ra_remove_owner_a")
958+
url = reverse("edit_risk_acceptance", args=(
959+
self.engagement_a.id, self.risk_acceptance_a.id,
960+
))
961+
response = client.post(url, self._remove_finding_data(999999))
962+
self.assertEqual(response.status_code, 404)
963+
964+
704965
class TestEngagementPresetsCrossProductIDOR(DojoTestCase):
705966

706967
"""

unittests/test_risk_acceptance.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ def test_remove_findings_from_risk_acceptance_findings_active(self):
111111
data = copy.copy(self.data_remove_finding_from_ra)
112112
data["remove_finding_id"] = 2
113113
ra = Risk_Acceptance.objects.last()
114-
response = self.client.post(reverse("view_risk_acceptance", args=(1, ra.id)), data)
114+
response = self.client.post(reverse("edit_risk_acceptance", args=(1, ra.id)), data)
115115
self.assertEqual(302, response.status_code, response.content[:1000])
116116
self.assert_all_active_not_risk_accepted(Finding.objects.filter(id=2))
117117
self.assert_all_inactive_risk_accepted(Finding.objects.filter(id=3))

0 commit comments

Comments
 (0)