From a9649e56231521bdb8da2033a71eecd8d0e3e962 Mon Sep 17 00:00:00 2001 From: JoaquinBN Date: Fri, 5 Jun 2026 12:01:44 +0200 Subject: [PATCH 1/2] Fix immediate security findings --- backend/.env.example | 2 +- backend/2025-05-27.sqlite3 | Bin 307200 -> 0 bytes backend/builders/tests.py | 41 ++++- backend/builders/views.py | 49 +++++- .../management/commands/review_submissions.py | 52 ++++-- backend/contributions/models.py | 4 +- backend/contributions/serializers.py | 21 ++- .../tests/test_review_submissions.py | 87 ++++++---- .../tests/test_steward_permissions.py | 86 +++++++++- .../tests/test_validator_category_gating.py | 139 +++++++++++++++- backend/contributions/views.py | 152 ++++++++++++------ backend/docker-compose.yml | 4 +- .../migrations/0002_nonce_purpose.py | 24 +++ backend/ethereum_auth/models.py | 17 +- backend/ethereum_auth/siwe_utils.py | 48 ++++++ backend/ethereum_auth/tests.py | 129 ++++++++++++++- backend/ethereum_auth/views.py | 115 +++++++------ backend/poaps/tests/test_poaps.py | 73 ++++++++- backend/poaps/views.py | 41 ++++- backend/stewards/tests.py | 41 ++++- backend/stewards/views.py | 48 +++++- backend/validators/tests.py | 26 ++- backend/validators/tests/test_api.py | 35 +++- backend/validators/views.py | 57 ++++++- frontend/package-lock.json | 18 +++ frontend/package.json | 1 + frontend/src/lib/auth.js | 7 +- frontend/src/lib/markdownLoader.js | 26 ++- frontend/src/routes/PoapRecovery.svelte | 2 +- frontend/src/tests/markdownLoader.test.js | 28 ++++ package-lock.json | 2 +- 31 files changed, 1172 insertions(+), 203 deletions(-) delete mode 100644 backend/2025-05-27.sqlite3 create mode 100644 backend/ethereum_auth/migrations/0002_nonce_purpose.py create mode 100644 backend/ethereum_auth/siwe_utils.py create mode 100644 frontend/src/tests/markdownLoader.test.js diff --git a/backend/.env.example b/backend/.env.example index 3b6cc0c5..3c319f3c 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -30,7 +30,7 @@ CORS_ALLOWED_ORIGINS=https://your-frontend-domain.com,https://another-domain.com CSRF_TRUSTED_ORIGINS=https://your-frontend-domain.com,https://another-domain.com # Ethereum Authentication -SIWE_DOMAIN=localhost +SIWE_DOMAIN=localhost:5173 # Blockchain Settings - Shared RPC (both networks on same chain) VALIDATOR_RPC_URL=https://rpc.testnet-chain.genlayer.com diff --git a/backend/2025-05-27.sqlite3 b/backend/2025-05-27.sqlite3 deleted file mode 100644 index e95644d8982c43775cde678a51859b390dfc83f1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 307200 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 diff --git a/backend/builders/tests.py b/backend/builders/tests.py index 7ce503c2..f153d395 100644 --- a/backend/builders/tests.py +++ b/backend/builders/tests.py @@ -1,3 +1,40 @@ -from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APITestCase -# Create your tests here. +from .models import Builder + + +User = get_user_model() + + +class BuilderAPITestCase(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email='builder-user@example.com', + password='testpass123', + ) + self.client.force_authenticate(user=self.user) + + def test_patch_builder_me_does_not_create_profile(self): + response = self.client.patch('/api/v1/builders/me/', {}) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertFalse(Builder.objects.filter(user=self.user).exists()) + + def test_regular_user_cannot_create_builder_profile(self): + response = self.client.post('/api/v1/builders/', {}) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(Builder.objects.filter(user=self.user).exists()) + + def test_regular_user_cannot_mutate_arbitrary_builder_profile(self): + other_user = User.objects.create_user( + email='builder-other@example.com', + password='testpass123', + ) + builder = Builder.objects.create(user=other_user) + + response = self.client.patch(f'/api/v1/builders/{builder.id}/', {}) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/builders/views.py b/backend/builders/views.py index 1acbc7a4..15a5e2ba 100644 --- a/backend/builders/views.py +++ b/backend/builders/views.py @@ -14,6 +14,45 @@ class BuilderViewSet(viewsets.ModelViewSet): serializer_class = BuilderSerializer permission_classes = [IsAuthenticated] + def _is_staff_mutation(self, request): + return bool( + request.user + and request.user.is_authenticated + and (request.user.is_staff or request.user.is_superuser) + ) + + def _deny_non_staff_mutation(self, request): + if self._is_staff_mutation(request): + return None + return Response( + {'detail': 'Only staff users can mutate builder profiles.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + def create(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().partial_update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().destroy(request, *args, **kwargs) + def get_permissions(self): """ Allow read-only access without authentication for public endpoints. @@ -39,7 +78,13 @@ def my_profile(self, request): ) elif request.method == 'PATCH': - builder, created = Builder.objects.get_or_create(user=request.user) + try: + builder = Builder.objects.get(user=request.user) + except Builder.DoesNotExist: + return Response( + {'detail': 'Builder profile not found for current user.'}, + status=status.HTTP_404_NOT_FOUND + ) serializer = self.get_serializer(builder, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -74,4 +119,4 @@ def newest_builders(self, request): 'created_at': builder.created_at }) - return Response(result) \ No newline at end of file + return Response(result) diff --git a/backend/contributions/management/commands/review_submissions.py b/backend/contributions/management/commands/review_submissions.py index 38c91d2a..024b7a3c 100644 --- a/backend/contributions/management/commands/review_submissions.py +++ b/backend/contributions/management/commands/review_submissions.py @@ -132,8 +132,8 @@ def rule_duplicate_evidence_url(submission, evidence_items, before comparison to prevent cosmetic variants from bypassing the check. Args: - skip_pending: If True, only reject when an older submitted duplicate - already exists. This keeps `--submission-id` runs deterministic. + Pending submission duplicates only count when the duplicate is older. + Accepted contribution duplicates always count. """ urls_with_evidence = [(e, _normalize_url(e.url)) for e in evidence_items if e.url] @@ -186,15 +186,21 @@ def _check_single_url_duplicate(submission, evidence, normalized, if not others: return None - if not skip_pending: - return ( - 'Reject: Duplicate Submission', - f'Tier 1 auto-reject: Evidence URL already exists in another ' - f'submission: {evidence.url[:100]}', - ) - submission_key = (submission.created_at, str(submission.id)) created_at_lookup = submitted_created_at or {} + missing_created_at_ids = [ + other_id for other_id in others + if other_id not in created_at_lookup + ] + if missing_created_at_ids: + created_at_lookup = { + **created_at_lookup, + **dict( + SubmittedContribution.objects + .filter(id__in=missing_created_at_ids) + .values_list('id', 'created_at') + ), + } if any( ( created_at_lookup.get(other_id, submission.created_at), @@ -367,6 +373,8 @@ def _build_url_lookup(self): # URLs from pending/accepted submitted contributions. # Evidence whose url_type allows duplicates is excluded so those # URLs never participate in duplicate detection. + from contributions.url_utils import detect_url_type + submitted = ( Evidence.objects .filter( @@ -375,27 +383,39 @@ def _build_url_lookup(self): ], url__gt='', ) - .exclude(url_type__allow_duplicate=True) .values_list( 'url', 'submitted_contribution_id', 'submitted_contribution__created_at', + 'url_type__allow_duplicate', ) ) url_to_sub_ids = defaultdict(set) submitted_created_at = {} - for url, sub_id, created_at in submitted: + for url, sub_id, created_at, allow_duplicate in submitted: + if allow_duplicate: + continue + if allow_duplicate is None: + detected = detect_url_type(url) + if detected and detected.allow_duplicate: + continue url_to_sub_ids[_normalize_url(url)].add(sub_id) submitted_created_at.setdefault(sub_id, created_at) # URLs from converted/accepted contributions - accepted_urls = set( - _normalize_url(url) for url in + accepted_urls = set() + for url, allow_duplicate in ( Evidence.objects .filter(contribution__isnull=False, url__gt='') - .exclude(url_type__allow_duplicate=True) - .values_list('url', flat=True) - ) + .values_list('url', 'url_type__allow_duplicate') + ): + if allow_duplicate: + continue + if allow_duplicate is None: + detected = detect_url_type(url) + if detected and detected.allow_duplicate: + continue + accepted_urls.add(_normalize_url(url)) return url_to_sub_ids, accepted_urls, submitted_created_at diff --git a/backend/contributions/models.py b/backend/contributions/models.py index 30e1b339..f73182ce 100644 --- a/backend/contributions/models.py +++ b/backend/contributions/models.py @@ -748,7 +748,9 @@ class Evidence(BaseModel): def save(self, *args, **kwargs): if self.url: - from .url_utils import normalize_url + from .url_utils import detect_url_type, normalize_url + if self.url_type_id is None: + self.url_type = detect_url_type(self.url) self.normalized_url = normalize_url(self.url) else: self.normalized_url = '' diff --git a/backend/contributions/serializers.py b/backend/contributions/serializers.py index d21dd12b..942564e7 100644 --- a/backend/contributions/serializers.py +++ b/backend/contributions/serializers.py @@ -690,7 +690,8 @@ def update(self, instance, validated_data): evidence_items_data = self.initial_data.get('evidence_items', None) evidence_items_validated = None if evidence_items_data is not None: - user = self.context['request'].user + request = self.context.get('request') + user = request.user if request else instance.user contribution_type = ( validated_data.get('contribution_type') or instance.contribution_type @@ -893,21 +894,37 @@ class StewardSubmissionReviewSerializer(serializers.Serializer): def validate(self, data): """Validate the review action and required fields.""" action = data.get('action') + submission = self.context.get('submission') + request = self.context.get('request') if action == 'accept': - if not data.get('points'): + if 'points' not in data or data.get('points') is None: raise serializers.ValidationError({ 'points': 'Points are required when accepting a submission.' }) # Validate points are within contribution type limits contribution_type = data.get('contribution_type') + if not contribution_type and submission: + contribution_type = submission.contribution_type if contribution_type: points = data.get('points') if points < contribution_type.min_points or points > contribution_type.max_points: raise serializers.ValidationError({ 'points': f'Points must be between {contribution_type.min_points} and {contribution_type.max_points} for {contribution_type.name}.' }) + + if submission and data.get('user') and data['user'] != submission.user: + reviewer_is_staff = bool( + request + and request.user + and request.user.is_authenticated + and (request.user.is_staff or request.user.is_superuser) + ) + if not reviewer_is_staff: + raise serializers.ValidationError({ + 'user': 'Only staff users can reassign accepted contributions.' + }) # Validate highlight fields if creating highlight if data.get('create_highlight'): diff --git a/backend/contributions/tests/test_review_submissions.py b/backend/contributions/tests/test_review_submissions.py index a75fcda6..de5fa127 100644 --- a/backend/contributions/tests/test_review_submissions.py +++ b/backend/contributions/tests/test_review_submissions.py @@ -399,36 +399,41 @@ def test_skip_pending_allows_oldest_submission(self): ) self.assertIsNone(result) - def test_lookup_update_prevents_mutual_rejection(self): - """After rejecting submission A, its URL should be removed from the - lookup so submission B (sharing the same URL) is not also rejected.""" - from contributions.management.commands.review_submissions import Command - - sub_a = self._create_submission(notes='First submission') - ev_a = self._add_evidence(sub_a, url='https://example.com/shared') + def test_newer_duplicate_plus_unique_url_does_not_reject_older_original(self): + """A newer duplicate must not cause the older original to fail.""" + original = self._create_submission(notes='Original submission') + original_shared = self._add_evidence( + original, + url='https://example.com/shared', + ) + original_unique = self._add_evidence( + original, + url='https://example.com/original-unique', + ) - sub_b = self._create_submission( - user=self.other_user, notes='Second submission', + newer = self._create_submission( + user=self.other_user, + notes='Newer duplicate', ) - ev_b = self._add_evidence(sub_b, url='https://example.com/shared') + newer_shared = self._add_evidence(newer, url='https://example.com/shared') url_lookup, accepted_urls = self._build_lookup() - # A sees B in the lookup → would be rejected - result_a = rule_duplicate_evidence_url( - sub_a, [ev_a], url_lookup, accepted_urls, + original_result = rule_duplicate_evidence_url( + original, + [original_shared, original_unique], + url_lookup, + accepted_urls, ) - self.assertIsNotNone(result_a) - - # Simulate the command removing A from lookup after rejection - cmd = Command() - cmd._remove_from_url_lookup(sub_a, [ev_a], url_lookup) - - # Now B should NOT see A anymore → passes - result_b = rule_duplicate_evidence_url( - sub_b, [ev_b], url_lookup, accepted_urls, + newer_result = rule_duplicate_evidence_url( + newer, + [newer_shared], + url_lookup, + accepted_urls, ) - self.assertIsNone(result_b) + + self.assertIsNone(original_result) + self.assertIsNotNone(newer_result) class RuleDuplicateUrlNormalizationTest(Tier1RuleTestBase): @@ -496,7 +501,7 @@ def test_fragment_detected(self): def test_normalize_url_helper(self): """_normalize_url strips query, fragment, and trailing slash.""" self.assertEqual( - _normalize_url('https://example.com/post?a=1'), + _normalize_url('https://example.com/post?utm_source=x'), 'https://example.com/post', ) self.assertEqual( @@ -508,7 +513,7 @@ def test_normalize_url_helper(self): 'https://example.com/post', ) self.assertEqual( - _normalize_url('https://example.com/post/?a=1#sec'), + _normalize_url('https://example.com/post/?utm_source=x#sec'), 'https://example.com/post', ) @@ -866,8 +871,36 @@ class RuleDuplicateUrlAllowDuplicateTest(Tier1RuleTestBase): def setUp(self): super().setUp() from contributions.models import EvidenceURLType - EvidenceURLType.objects.filter(slug='github-repo').update( - allow_duplicate=True, + EvidenceURLType.objects.update_or_create( + slug='github-repo', + defaults={ + 'name': 'GitHub Repository', + 'description': 'A GitHub repository', + 'url_patterns': [ + r'^https?://github\.com/[^/]+/[^/]+/?$', + r'^https?://github\.com/[^/]+/[^/]+/?#', + ], + 'is_generic': False, + 'order': 2, + 'handle_extract_pattern': r'github\.com/(?P[^/]+)/', + 'ownership_social_account': 'github', + 'allow_duplicate': True, + }, + ) + EvidenceURLType.objects.update_or_create( + slug='github-pr', + defaults={ + 'name': 'GitHub Pull Request', + 'description': 'A pull request on GitHub', + 'url_patterns': [ + r'^https?://github\.com/[^/]+/[^/]+/pull/\d+', + ], + 'is_generic': False, + 'order': 4, + 'handle_extract_pattern': '', + 'ownership_social_account': '', + 'allow_duplicate': False, + }, ) def _build_lookup(self): diff --git a/backend/contributions/tests/test_steward_permissions.py b/backend/contributions/tests/test_steward_permissions.py index a5828f0b..b0313fa2 100644 --- a/backend/contributions/tests/test_steward_permissions.py +++ b/backend/contributions/tests/test_steward_permissions.py @@ -3,6 +3,7 @@ from rest_framework.test import APIClient from rest_framework import status from contributions.models import SubmittedContribution, ContributionType, Category, ContributionHighlight +from leaderboard.models import GlobalLeaderboardMultiplier from stewards.models import Steward, StewardPermission from datetime import datetime from django.utils import timezone @@ -31,6 +32,25 @@ def setUp(self): min_points=10, max_points=100 ) + self.other_category = Category.objects.create( + name="Other Category", + slug="other", + description="Other category" + ) + self.other_contribution_type = ContributionType.objects.create( + name="Other Type", + slug="other-type", + description="Other contribution type", + category=self.other_category, + min_points=1, + max_points=5 + ) + for contribution_type in [self.contribution_type, self.other_contribution_type]: + GlobalLeaderboardMultiplier.objects.create( + contribution_type=contribution_type, + multiplier_value=1, + valid_from=timezone.now() - timezone.timedelta(days=1), + ) # Create regular user self.regular_user = User.objects.create_user( @@ -61,6 +81,11 @@ def setUp(self): address='0x1111111111111111111111111111111111111111', password='testpass123' ) + self.reassignment_user = User.objects.create_user( + email='reassignment@test.com', + address='0x2222222222222222222222222222222222222222', + password='testpass123' + ) # Create a submission self.submission = SubmittedContribution.objects.create( @@ -86,9 +111,9 @@ def test_non_authenticated_cannot_access_steward_endpoints(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - # Try to get stats + # Stats is a public endpoint by view design. response = self.client.get('/api/v1/steward-submissions/stats/') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_regular_user_cannot_access_steward_endpoints(self): """Test that regular users cannot access steward endpoints.""" @@ -106,9 +131,9 @@ def test_regular_user_cannot_access_steward_endpoints(self): ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - # Try to get stats + # Stats is a public endpoint by view design. response = self.client.get('/api/v1/steward-submissions/stats/') - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_200_OK) def test_steward_can_access_steward_endpoints(self): """Test that stewards can access steward endpoints.""" @@ -148,6 +173,59 @@ def test_steward_can_review_submissions(self): self.assertEqual(self.submission.reviewed_by, self.steward_user) self.assertIsNotNone(self.submission.converted_contribution) self.assertEqual(self.submission.converted_contribution.points, 50) + + def test_accept_checks_permission_against_final_contribution_type(self): + self.client.force_authenticate(user=self.steward_user) + + response = self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/review/', + { + 'action': 'accept', + 'points': 3, + 'contribution_type': self.other_contribution_type.id, + }, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.submission.refresh_from_db() + self.assertEqual(self.submission.state, 'pending') + self.assertIsNone(self.submission.converted_contribution) + + def test_accept_validates_points_against_original_type_when_type_omitted(self): + self.client.force_authenticate(user=self.steward_user) + + response = self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/review/', + { + 'action': 'accept', + 'points': 500, + }, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.submission.refresh_from_db() + self.assertEqual(self.submission.state, 'pending') + + def test_non_staff_steward_cannot_reassign_accepted_contribution(self): + self.client.force_authenticate(user=self.steward_user) + + response = self.client.post( + f'/api/v1/steward-submissions/{self.submission.id}/review/', + { + 'action': 'accept', + 'points': 50, + 'contribution_type': self.contribution_type.id, + 'user': self.reassignment_user.id, + }, + format='json' + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.submission.refresh_from_db() + self.assertEqual(self.submission.state, 'pending') + self.assertIsNone(self.submission.converted_contribution) def test_steward_can_reject_submissions(self): """Test that stewards can reject submissions.""" diff --git a/backend/contributions/tests/test_validator_category_gating.py b/backend/contributions/tests/test_validator_category_gating.py index cc894634..39468f47 100644 --- a/backend/contributions/tests/test_validator_category_gating.py +++ b/backend/contributions/tests/test_validator_category_gating.py @@ -4,7 +4,8 @@ from rest_framework.test import APIClient from rest_framework import status -from contributions.models import Contribution, ContributionType, Category +from contributions.models import Contribution, ContributionType, Category, SubmittedContribution +from leaderboard.models import GlobalLeaderboardMultiplier from validators.models import Validator User = get_user_model() @@ -41,6 +42,69 @@ def setUp(self): 'max_points': 100, }, ) + self.builder_category, _ = Category.objects.get_or_create( + slug='builder', + defaults={'name': 'Builder', 'description': 'Builder contributions'}, + ) + self.builder_type, _ = ContributionType.objects.get_or_create( + slug='builder-only-test', + defaults={ + 'name': 'Builder Only Test', + 'description': 'Builder restricted test type', + 'category': self.builder_category, + 'min_points': 10, + 'max_points': 100, + }, + ) + self.community_category, _ = Category.objects.get_or_create( + slug='community', + defaults={'name': 'Community', 'description': 'Community contributions'}, + ) + self.community_type, _ = ContributionType.objects.get_or_create( + slug='community-test', + defaults={ + 'name': 'Community Test', + 'description': 'Unrestricted test type', + 'category': self.community_category, + 'min_points': 1, + 'max_points': 10, + }, + ) + self.capacity_limited_type, _ = ContributionType.objects.get_or_create( + slug='capacity-limited-test', + defaults={ + 'name': 'Capacity Limited Test', + 'description': 'Capacity-limited unrestricted test type', + 'category': self.community_category, + 'min_points': 1, + 'max_points': 10, + 'max_submissions': 1, + }, + ) + self.mission_only_type, _ = ContributionType.objects.get_or_create( + slug='mission-only-test', + defaults={ + 'name': 'Mission Only Test', + 'description': 'Mission-only test type', + 'category': self.community_category, + 'min_points': 1, + 'max_points': 10, + 'is_submittable': False, + }, + ) + for contribution_type in [ + self.waitlist_type, + self.node_running_type, + self.builder_type, + self.community_type, + self.capacity_limited_type, + self.mission_only_type, + ]: + GlobalLeaderboardMultiplier.objects.create( + contribution_type=contribution_type, + multiplier_value=1, + valid_from=timezone.now() - timezone.timedelta(days=1), + ) self.waitlist_user = User.objects.create_user( email='waitlist@test.com', @@ -97,3 +161,76 @@ def test_user_with_validator_profile_passes_gating(self): response = self._post_submission(self.validator_user, self.node_running_type) # May be 201 or 400 depending on recaptcha config, but must not be 403 self.assertNotEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def _pending_submission(self, user=None): + return SubmittedContribution.objects.create( + user=user or self.plain_user, + contribution_type=self.community_type, + contribution_date=timezone.now(), + notes='Original unrestricted submission', + state='pending', + ) + + def test_plain_user_cannot_edit_submission_into_validator_type(self): + submission = self._pending_submission() + self.client.force_authenticate(user=self.plain_user) + + response = self.client.patch( + f'/api/v1/submissions/{submission.id}/', + {'contribution_type': self.node_running_type.id}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + submission.refresh_from_db() + self.assertEqual(submission.contribution_type, self.community_type) + + def test_plain_user_cannot_edit_submission_into_builder_type(self): + submission = self._pending_submission() + self.client.force_authenticate(user=self.plain_user) + + response = self.client.patch( + f'/api/v1/submissions/{submission.id}/', + {'contribution_type': self.builder_type.id}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + submission.refresh_from_db() + self.assertEqual(submission.contribution_type, self.community_type) + + def test_plain_user_cannot_edit_submission_into_mission_only_type(self): + submission = self._pending_submission() + self.client.force_authenticate(user=self.plain_user) + + response = self.client.patch( + f'/api/v1/submissions/{submission.id}/', + {'contribution_type': self.mission_only_type.id}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + submission.refresh_from_db() + self.assertEqual(submission.contribution_type, self.community_type) + + def test_user_can_edit_submission_when_unchanged_type_is_full(self): + submission = SubmittedContribution.objects.create( + user=self.plain_user, + contribution_type=self.capacity_limited_type, + contribution_date=timezone.now(), + notes='Original notes', + state='pending', + ) + self.assertTrue(self.capacity_limited_type.is_full()) + self.client.force_authenticate(user=self.plain_user) + + response = self.client.patch( + f'/api/v1/submissions/{submission.id}/', + {'notes': 'Updated notes'}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + submission.refresh_from_db() + self.assertEqual(submission.notes, 'Updated notes') + self.assertEqual(submission.contribution_type, self.capacity_limited_type) diff --git a/backend/contributions/views.py b/backend/contributions/views.py index 23eb1540..8868fe27 100644 --- a/backend/contributions/views.py +++ b/backend/contributions/views.py @@ -44,6 +44,7 @@ ContributionDiscordXPStateSerializer) from .forms import SubmissionReviewForm from .permissions import IsSteward, steward_has_permission, steward_permitted_type_ids +from .url_utils import normalize_url from leaderboard.models import GlobalLeaderboardMultiplier from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from ethereum_auth.authentication import EthereumAuthentication @@ -683,6 +684,65 @@ def _validate_required_discord_roles(self, user, contribution_type): ) return None + + def _validate_submission_contribution_type( + self, + user, + contribution_type, + mission=None, + skip_capacity_check=False, + ): + """Validate role, category, and requirement gates for submissions.""" + if mission and contribution_type.id != mission.contribution_type_id: + return Response( + {'error': 'Mission submissions must use the mission contribution type.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not contribution_type.is_submittable and mission is None: + return Response( + {'error': 'This contribution type cannot be submitted directly. Submit through one of its active missions.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not skip_capacity_check and contribution_type.is_full(): + return Response( + {'error': 'This contribution type has reached its submission limit.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + if contribution_type.category: + if contribution_type.category.slug == 'builder' and not hasattr(user, 'builder'): + return Response( + {'error': 'You must complete the Builder Welcome journey before submitting builder contributions.'}, + status=status.HTTP_403_FORBIDDEN + ) + if contribution_type.category.slug == 'validator' and not hasattr(user, 'validator'): + return Response( + {'error': 'Only validators can submit validator contributions. Join the Validator Waitlist to be considered for selection.'}, + status=status.HTTP_403_FORBIDDEN + ) + + if contribution_type.required_social_accounts: + connection_map = { + 'twitter': ('twitterconnection', 'X (Twitter)'), + 'discord': ('discordconnection', 'Discord'), + 'github': ('githubconnection', 'GitHub'), + } + missing = [] + for account in contribution_type.required_social_accounts: + relation, label = connection_map.get(account, (None, account)) + if relation and not hasattr(user, relation): + missing.append(label) + if missing: + return Response( + {'error': f'You must link your {", ".join(missing)} account(s) to submit this type of contribution.'}, + status=status.HTTP_403_FORBIDDEN + ) + + discord_role_error = self._validate_required_discord_roles(user, contribution_type) + if discord_role_error is not None: + return discord_role_error + + return None def create(self, request, *args, **kwargs): """Create a new submission with optional mission tracking.""" @@ -743,48 +803,13 @@ def create(self, request, *args, **kwargs): .prefetch_related('required_discord_roles') .get(id=contribution_type_id) ) - # Non-submittable types can only be submitted through an active mission - if not contribution_type.is_submittable and not mission_id: - return Response( - {'error': 'This contribution type cannot be submitted directly. Submit through one of its active missions.'}, - status=status.HTTP_400_BAD_REQUEST - ) - if contribution_type.is_full(): - return Response( - {'error': 'This contribution type has reached its submission limit.'}, - status=status.HTTP_400_BAD_REQUEST - ) - if contribution_type.category: - if contribution_type.category.slug == 'builder' and not hasattr(request.user, 'builder'): - return Response( - {'error': 'You must complete the Builder Welcome journey before submitting builder contributions.'}, - status=status.HTTP_403_FORBIDDEN - ) - if contribution_type.category.slug == 'validator' and not hasattr(request.user, 'validator'): - return Response( - {'error': 'Only validators can submit validator contributions. Join the Validator Waitlist to be considered for selection.'}, - status=status.HTTP_403_FORBIDDEN - ) - # Check required social accounts for this contribution type - if contribution_type.required_social_accounts: - connection_map = { - 'twitter': ('twitterconnection', 'X (Twitter)'), - 'discord': ('discordconnection', 'Discord'), - 'github': ('githubconnection', 'GitHub'), - } - missing = [] - for account in contribution_type.required_social_accounts: - relation, label = connection_map.get(account, (None, account)) - if relation and not hasattr(request.user, relation): - missing.append(label) - if missing: - return Response( - {'error': f'You must link your {", ".join(missing)} account(s) to submit this type of contribution.'}, - status=status.HTTP_403_FORBIDDEN - ) - discord_role_error = self._validate_required_discord_roles(request.user, contribution_type) - if discord_role_error is not None: - return discord_role_error + contribution_type_error = self._validate_submission_contribution_type( + request.user, + contribution_type, + mission, + ) + if contribution_type_error is not None: + return contribution_type_error except ContributionType.DoesNotExist: pass @@ -848,6 +873,19 @@ def update(self, request, *args, **kwargs): status=status.HTTP_403_FORBIDDEN ) + if 'mission' in request.data: + requested_mission = request.data.get('mission') + current_mission = str(instance.mission_id) if instance.mission_id else None + if requested_mission in ('', None): + requested_mission = None + else: + requested_mission = str(requested_mission) + if requested_mission != current_mission: + return Response( + {'error': 'Mission cannot be changed after submission.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + # Update the submission serializer = self.get_serializer(instance, data=request.data, partial=partial) serializer.is_valid(raise_exception=True) @@ -856,17 +894,22 @@ def update(self, request, *args, **kwargs): serializer.validated_data.get('contribution_type') or instance.contribution_type ) + mission = serializer.validated_data.get('mission', instance.mission) contribution_type = ( ContributionType.objects + .select_related('category') .prefetch_related('required_discord_roles') .get(id=contribution_type.id) ) - discord_role_error = self._validate_required_discord_roles( + keeps_same_contribution_type = contribution_type.id == instance.contribution_type_id + contribution_type_error = self._validate_submission_contribution_type( request.user, contribution_type, + mission, + skip_capacity_check=keeps_same_contribution_type, ) - if discord_role_error is not None: - return discord_role_error + if contribution_type_error is not None: + return contribution_type_error # Update state back to pending and track edit time instance.state = 'pending' @@ -1747,10 +1790,17 @@ def review(self, request, pk=None): """Review and take action on a submission.""" submission = self.get_object() - serializer = StewardSubmissionReviewSerializer(data=request.data) + serializer = StewardSubmissionReviewSerializer( + data=request.data, + context={'submission': submission, 'request': request}, + ) serializer.is_valid(raise_exception=True) action_name = serializer.validated_data['action'] + final_contribution_type = serializer.validated_data.get( + 'contribution_type', + submission.contribution_type, + ) # Per-action permission checks permission_map = { @@ -1759,7 +1809,12 @@ def review(self, request, pk=None): 'more_info': 'request_more_info', } required_permission = permission_map.get(action_name) - if required_permission and not steward_has_permission(request.user, submission.contribution_type_id, required_permission): + permission_contribution_type_id = ( + final_contribution_type.id + if action_name == 'accept' + else submission.contribution_type_id + ) + if required_permission and not steward_has_permission(request.user, permission_contribution_type_id, required_permission): return Response( {'detail': f'You do not have permission to {action_name} submissions of this contribution type.'}, status=status.HTTP_403_FORBIDDEN @@ -1771,7 +1826,7 @@ def review(self, request, pk=None): if action_name == 'accept': # Get the contribution type (use provided or keep original) - contribution_type = serializer.validated_data.get('contribution_type', submission.contribution_type) + contribution_type = final_contribution_type # Get the user for the contribution (use provided or keep original submitter) contribution_user = serializer.validated_data.get('user', submission.user) @@ -1810,6 +1865,7 @@ def review(self, request, pk=None): url=evidence.url, file=evidence.file, url_type=evidence.url_type, + normalized_url=normalize_url(evidence.url) if evidence.url else '', ) for evidence in submission.evidence_items.all() ]) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 9d0cbd02..58470f0d 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -22,7 +22,7 @@ services: - DATABASE_URL=postgresql://tally_user:tally_password@db:5432/tally_db - ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 - FRONTEND_URL=http://localhost:5173 - - SIWE_DOMAIN=localhost + - SIWE_DOMAIN=localhost:5173 - CSRF_TRUSTED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 depends_on: - db @@ -30,4 +30,4 @@ services: - .:/app volumes: - postgres_data: \ No newline at end of file + postgres_data: diff --git a/backend/ethereum_auth/migrations/0002_nonce_purpose.py b/backend/ethereum_auth/migrations/0002_nonce_purpose.py new file mode 100644 index 00000000..8f246b7f --- /dev/null +++ b/backend/ethereum_auth/migrations/0002_nonce_purpose.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ethereum_auth', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='nonce', + name='purpose', + field=models.CharField( + choices=[ + ('login', 'Login'), + ('poap_recovery', 'POAP Recovery'), + ], + db_index=True, + default='login', + max_length=32, + ), + ), + ] diff --git a/backend/ethereum_auth/models.py b/backend/ethereum_auth/models.py index 456281c6..46c97ce7 100644 --- a/backend/ethereum_auth/models.py +++ b/backend/ethereum_auth/models.py @@ -6,7 +6,20 @@ class Nonce(models.Model): """ Model to store nonce values for SIWE authentication. """ + PURPOSE_LOGIN = 'login' + PURPOSE_POAP_RECOVERY = 'poap_recovery' + PURPOSE_CHOICES = [ + (PURPOSE_LOGIN, 'Login'), + (PURPOSE_POAP_RECOVERY, 'POAP Recovery'), + ] + value = models.CharField(max_length=64, unique=True) + purpose = models.CharField( + max_length=32, + choices=PURPOSE_CHOICES, + default=PURPOSE_LOGIN, + db_index=True, + ) created_at = models.DateTimeField(default=timezone.now) used = models.BooleanField(default=False) expires_at = models.DateTimeField() @@ -15,7 +28,7 @@ class Meta: ordering = ['-created_at'] def __str__(self): - return f"{self.value} - {'Used' if self.used else 'Unused'}" + return f"{self.value} ({self.purpose}) - {'Used' if self.used else 'Unused'}" def is_valid(self): """Check if nonce is valid (not used and not expired)""" @@ -24,4 +37,4 @@ def is_valid(self): def mark_as_used(self): """Mark the nonce as used""" self.used = True - self.save() \ No newline at end of file + self.save(update_fields=['used']) diff --git a/backend/ethereum_auth/siwe_utils.py b/backend/ethereum_auth/siwe_utils.py new file mode 100644 index 00000000..0f0f7cf4 --- /dev/null +++ b/backend/ethereum_auth/siwe_utils.py @@ -0,0 +1,48 @@ +from urllib.parse import urlparse + +from django.conf import settings + + +def normalize_origin(value): + return (value or '').strip().rstrip('/') + + +def _host_from_value(value): + value = normalize_origin(value) + if not value: + return '' + parsed = urlparse(value if '://' in value else f'//{value}') + return parsed.netloc or parsed.path + + +def _hostname_from_host(host): + if not host: + return '' + parsed = urlparse(f'//{host}') + return parsed.hostname or host.split(':', 1)[0] + + +def get_expected_siwe_domain(): + """Return the SIWE domain expected in frontend-signed messages. + + Local configs often set SIWE_DOMAIN=localhost while the browser signs with + window.location.host (localhost:5173). If the configured SIWE_DOMAIN is the + same hostname as FRONTEND_URL but omits the port, use FRONTEND_URL's host. + Otherwise, keep the explicit SIWE_DOMAIN value. + """ + configured_domain = _host_from_value(settings.ETHEREUM_AUTH.get('SIWE_DOMAIN', '')) + frontend_domain = _host_from_value(getattr(settings, 'FRONTEND_URL', '')) + + if not configured_domain: + return frontend_domain + if not frontend_domain: + return configured_domain + if configured_domain == frontend_domain: + return configured_domain + if configured_domain == _hostname_from_host(frontend_domain): + return frontend_domain + return configured_domain + + +def get_expected_siwe_uri(): + return normalize_origin(settings.FRONTEND_URL) diff --git a/backend/ethereum_auth/tests.py b/backend/ethereum_auth/tests.py index 8486d7cc..87586fc5 100644 --- a/backend/ethereum_auth/tests.py +++ b/backend/ethereum_auth/tests.py @@ -1,7 +1,12 @@ from django.contrib.auth import get_user_model -from django.test import TestCase +from django.test import TestCase, override_settings +from django.utils import timezone +from eth_account import Account +from eth_account.messages import encode_defunct from rest_framework.test import APIClient +from .models import Nonce + User = get_user_model() @@ -35,3 +40,125 @@ def test_unauthenticated_verify_auth_does_not_return_session_key(self): self.assertEqual(response.status_code, 200) self.assertFalse(response.data['authenticated']) self.assertNotIn('session_key', response.data) + + +@override_settings( + ETHEREUM_AUTH={ + 'SIWE_DOMAIN': 'localhost', + 'NONCE_EXPIRY_MINUTES': 5, + }, + FRONTEND_URL='http://localhost:5173', +) +class EthereumAuthNoncePurposeTests(TestCase): + def setUp(self): + self.client = APIClient() + + def _login_message(self, account, nonce_value): + return ( + 'localhost:5173 wants you to sign in with your Ethereum account:\n' + f'{account.address}\n\n' + 'Sign in with Ethereum to GenLayer Testnet Contributions\n\n' + 'URI: http://localhost:5173\n' + 'Version: 1\n' + 'Chain ID: 1\n' + f'Nonce: {nonce_value}\n' + f'Issued At: {timezone.now().isoformat()}' + ) + + def _recovery_message(self, account, nonce_value): + return ( + 'localhost:5173 wants you to verify a wallet for GenLayer POAP recovery:\n' + f'{account.address}\n\n' + 'This signature only proves ownership of this wallet for attaching legacy POAPs ' + 'to your current portal account. It will not sign you into the portal or change your account.\n\n' + f'Portal Account: {account.address}\n' + 'URI: http://localhost:5173\n' + 'Version: 1\n' + 'Chain ID: 1\n' + f'Nonce: {nonce_value}\n' + f'Issued At: {timezone.now().isoformat()}' + ) + + def _sign(self, account, message): + return Account.sign_message( + encode_defunct(text=message), + private_key=account.key, + ).signature.hex() + + def test_nonce_endpoint_defaults_to_login_purpose(self): + response = self.client.get('/api/auth/nonce/') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['purpose'], Nonce.PURPOSE_LOGIN) + self.assertTrue( + Nonce.objects.filter( + value=response.data['nonce'], + purpose=Nonce.PURPOSE_LOGIN, + ).exists() + ) + + def test_nonce_endpoint_accepts_poap_recovery_purpose(self): + response = self.client.get('/api/auth/nonce/?purpose=poap_recovery') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data['purpose'], Nonce.PURPOSE_POAP_RECOVERY) + self.assertTrue( + Nonce.objects.filter( + value=response.data['nonce'], + purpose=Nonce.PURPOSE_POAP_RECOVERY, + ).exists() + ) + + def test_login_accepts_frontend_host_when_siwe_domain_omits_port(self): + account = Account.create() + nonce = Nonce.objects.create( + value='localLoginNonce1', + purpose=Nonce.PURPOSE_LOGIN, + expires_at=timezone.now() + timezone.timedelta(minutes=5), + ) + message = self._login_message(account, nonce.value) + + response = self.client.post('/api/auth/login/', { + 'message': message, + 'signature': self._sign(account, message), + }, format='json') + + self.assertEqual(response.status_code, 200) + nonce.refresh_from_db() + self.assertTrue(nonce.used) + + def test_login_rejects_recovery_purpose_nonce(self): + account = Account.create() + nonce = Nonce.objects.create( + value='recoveryNonceForLogin1', + purpose=Nonce.PURPOSE_POAP_RECOVERY, + expires_at=timezone.now() + timezone.timedelta(minutes=5), + ) + message = self._login_message(account, nonce.value) + + response = self.client.post('/api/auth/login/', { + 'message': message, + 'signature': self._sign(account, message), + }, format='json') + + self.assertEqual(response.status_code, 400) + nonce.refresh_from_db() + self.assertFalse(nonce.used) + + def test_login_rejects_recovery_message_shape(self): + account = Account.create() + nonce = Nonce.objects.create( + value='recoveryMessageNonce1', + purpose=Nonce.PURPOSE_POAP_RECOVERY, + expires_at=timezone.now() + timezone.timedelta(minutes=5), + ) + message = self._recovery_message(account, nonce.value) + + response = self.client.post('/api/auth/login/', { + 'message': message, + 'signature': self._sign(account, message), + }, format='json') + + self.assertEqual(response.status_code, 400) + nonce.refresh_from_db() + self.assertFalse(nonce.used) diff --git a/backend/ethereum_auth/views.py b/backend/ethereum_auth/views.py index a5b2a6f8..0e3235f6 100644 --- a/backend/ethereum_auth/views.py +++ b/backend/ethereum_auth/views.py @@ -2,21 +2,24 @@ import string from datetime import timedelta +from django.conf import settings from django.contrib.auth import get_user_model +from django.db import transaction from django.utils import timezone from rest_framework import status from rest_framework.decorators import api_view, authentication_classes, permission_classes from rest_framework.response import Response from rest_framework.permissions import AllowAny -from eth_account.messages import encode_defunct -from eth_account import Account +from siwe import SiweMessage, VerificationError from .models import Nonce from .authentication import CsrfExemptSessionAuthentication +from .siwe_utils import get_expected_siwe_domain, get_expected_siwe_uri, normalize_origin from tally.middleware.logging_utils import get_app_logger User = get_user_model() logger = get_app_logger('auth') +LOGIN_STATEMENT = 'Sign in with Ethereum to GenLayer Testnet Contributions' def generate_nonce(length=32): @@ -31,20 +34,27 @@ def get_nonce(request): """ Generate a new nonce for SIWE authentication. """ - # Generate a random nonce + purpose = request.query_params.get('purpose', Nonce.PURPOSE_LOGIN) + valid_purposes = {choice[0] for choice in Nonce.PURPOSE_CHOICES} + if purpose not in valid_purposes: + return Response( + {'error': 'Invalid nonce purpose.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + nonce_value = generate_nonce() - - # Set expiration time (e.g., 5 minutes from now) - expires_at = timezone.now() + timedelta(minutes=5) + expiry_minutes = settings.ETHEREUM_AUTH.get('NONCE_EXPIRY_MINUTES', 5) + expires_at = timezone.now() + timedelta(minutes=expiry_minutes) # Create and save the nonce nonce = Nonce.objects.create( value=nonce_value, + purpose=purpose, expires_at=expires_at ) # Return the nonce to the client - return Response({'nonce': nonce_value}) + return Response({'nonce': nonce_value, 'purpose': purpose}) @api_view(['POST']) @@ -66,58 +76,59 @@ def login(request): ) try: - # Extract the nonce and address from the message directly - # Parse the message format which should be: - # domain wants you to sign in with your Ethereum account: - # 0x123... - # - # Sign in with Ethereum to Tally - # - # URI: http://... - # Version: 1 - # Chain ID: 1 - # Nonce: abc123 - # Issued At: 2023-... - - message_lines = message.strip().split('\n') - ethereum_address = message_lines[1].lower() - - # Find the nonce line and extract the value - nonce_line = next((line for line in message_lines if line.startswith('Nonce:')), None) - if not nonce_line: + try: + siwe_message = SiweMessage.from_message(message) + except Exception: return Response( - {'error': 'Invalid message format: No nonce found.'}, - status=status.HTTP_400_BAD_REQUEST + {'error': 'Invalid SIWE message.'}, + status=status.HTTP_400_BAD_REQUEST, ) - - nonce_value = nonce_line.split(':', 1)[1].strip() - - # Verify the nonce - try: - nonce = Nonce.objects.get(value=nonce_value) - if not nonce.is_valid(): - return Response( - {'error': 'Invalid or expired nonce.'}, - status=status.HTTP_400_BAD_REQUEST - ) - except Nonce.DoesNotExist: + + if siwe_message.statement != LOGIN_STATEMENT: return Response( - {'error': 'Invalid nonce.'}, - status=status.HTTP_400_BAD_REQUEST + {'error': 'Invalid SIWE statement.'}, + status=status.HTTP_400_BAD_REQUEST, ) - - # Verify the signature using eth_account - message_hash = encode_defunct(text=message) - recovered_address = Account.recover_message(message_hash, signature=signature) - if recovered_address.lower() != ethereum_address: + if normalize_origin(str(siwe_message.uri)) != get_expected_siwe_uri(): return Response( - {'error': 'Invalid signature: address mismatch'}, - status=status.HTTP_400_BAD_REQUEST + {'error': 'Invalid SIWE URI.'}, + status=status.HTTP_400_BAD_REQUEST, ) - - # Mark the nonce as used - nonce.mark_as_used() + + with transaction.atomic(): + try: + nonce = Nonce.objects.select_for_update().get( + value=siwe_message.nonce, + purpose=Nonce.PURPOSE_LOGIN, + ) + except Nonce.DoesNotExist: + return Response( + {'error': 'Invalid nonce.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not nonce.is_valid(): + return Response( + {'error': 'Invalid or expired nonce.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + siwe_message.verify( + signature, + domain=get_expected_siwe_domain(), + nonce=siwe_message.nonce, + ) + except VerificationError: + return Response( + {'error': 'Invalid SIWE signature.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + nonce.mark_as_used() + + ethereum_address = siwe_message.address.lower() # Get or create the user user, created = User.objects.get_or_create( diff --git a/backend/poaps/tests/test_poaps.py b/backend/poaps/tests/test_poaps.py index 96b04f64..d119d04f 100644 --- a/backend/poaps/tests/test_poaps.py +++ b/backend/poaps/tests/test_poaps.py @@ -7,7 +7,7 @@ from django.core.management import call_command from django.core.management.base import CommandError from django.db import IntegrityError, connection, transaction -from django.test import TestCase +from django.test import TestCase, override_settings from django.test.utils import CaptureQueriesContext from django.utils import timezone from eth_account import Account @@ -24,6 +24,13 @@ User = get_user_model() +@override_settings( + ETHEREUM_AUTH={ + 'SIWE_DOMAIN': 'localhost', + 'NONCE_EXPIRY_MINUTES': 5, + }, + FRONTEND_URL='http://localhost:5173', +) class PoapAPITest(TestCase): def setUp(self): self.client = APIClient() @@ -72,14 +79,21 @@ def _secret_distribution(self, secret='friend-scientist-natural', **kwargs): defaults.update(kwargs) return PoapDistribution.objects.create(**defaults) - def _recovery_payload(self, account, portal_user=None, nonce_value='recover-nonce'): + def _recovery_payload( + self, + account, + portal_user=None, + nonce_value='recover-nonce', + nonce_purpose=Nonce.PURPOSE_POAP_RECOVERY, + ): portal_user = portal_user or self.user Nonce.objects.create( value=nonce_value, + purpose=nonce_purpose, expires_at=timezone.now() + timezone.timedelta(minutes=5), ) message = ( - f'localhost wants you to verify a wallet for GenLayer POAP recovery:\n' + f'localhost:5173 wants you to verify a wallet for GenLayer POAP recovery:\n' f'{account.address}\n\n' 'This signature only proves ownership of this wallet for attaching legacy POAPs ' 'to your current portal account. It will not sign you into the portal or change your account.\n\n' @@ -457,15 +471,66 @@ def test_verify_wallet_rejects_invalid_signature(self): self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + def test_verify_wallet_rejects_login_purpose_nonce(self): + account = Account.create() + self.client.force_authenticate(user=self.user) + + response = self.client.post( + '/api/v1/poaps/verify-wallet/', + self._recovery_payload( + account, + nonce_value='login-purpose-recovery-nonce', + nonce_purpose=Nonce.PURPOSE_LOGIN, + ), + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Nonce.objects.get(value='login-purpose-recovery-nonce').used) + + def test_verify_wallet_rejects_login_message_shape(self): + account = Account.create() + nonce = Nonce.objects.create( + value='login-message-nonce', + purpose=Nonce.PURPOSE_LOGIN, + expires_at=timezone.now() + timezone.timedelta(minutes=5), + ) + message = ( + 'localhost:5173 wants you to sign in with your Ethereum account:\n' + f'{account.address}\n\n' + 'Sign in with Ethereum to GenLayer Testnet Contributions\n\n' + 'URI: http://localhost:5173\n' + 'Version: 1\n' + 'Chain ID: 1\n' + f'Nonce: {nonce.value}\n' + f'Issued At: {timezone.now().isoformat()}' + ) + payload = { + 'address': account.address, + 'message': message, + 'signature': Account.sign_message( + encode_defunct(text=message), + private_key=account.key, + ).signature.hex(), + } + self.client.force_authenticate(user=self.user) + + response = self.client.post('/api/v1/poaps/verify-wallet/', payload, format='json') + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + nonce.refresh_from_db() + self.assertFalse(nonce.used) + def test_verify_wallet_rejects_expired_nonce(self): account = Account.create() nonce_value = 'expired-recovery-nonce' Nonce.objects.create( value=nonce_value, + purpose=Nonce.PURPOSE_POAP_RECOVERY, expires_at=timezone.now() - timezone.timedelta(minutes=1), ) message = ( - f'localhost wants you to verify a wallet for GenLayer POAP recovery:\n' + f'localhost:5173 wants you to verify a wallet for GenLayer POAP recovery:\n' f'{account.address}\n\n' 'This signature only proves ownership of this wallet for attaching legacy POAPs ' 'to your current portal account. It will not sign you into the portal or change your account.\n\n' diff --git a/backend/poaps/views.py b/backend/poaps/views.py index 893cf449..cef2842a 100644 --- a/backend/poaps/views.py +++ b/backend/poaps/views.py @@ -12,6 +12,7 @@ from rest_framework.response import Response from ethereum_auth.models import Nonce +from ethereum_auth.siwe_utils import get_expected_siwe_domain, get_expected_siwe_uri, normalize_origin from .models import PoapClaim, PoapDistribution, PoapDrop from .serializers import ( @@ -29,6 +30,11 @@ ) User = get_user_model() +RECOVERY_STATEMENT = ( + 'This signature only proves ownership of this wallet for attaching legacy ' + 'POAPs to your current portal account. It will not sign you into the portal ' + 'or change your account.' +) def extract_message_field(message, field_name): @@ -47,6 +53,14 @@ def is_ethereum_address(value): return bool(re.fullmatch(r'0x[a-fA-F0-9]{40}', value or '')) +def expected_recovery_domain(): + return get_expected_siwe_domain() + + +def expected_recovery_uri(): + return get_expected_siwe_uri() + + def open_distribution_queryset(at_time=None): at_time = at_time or timezone.now() secret_distribution = ( @@ -216,16 +230,34 @@ def verify_wallet(self, request): ) if not is_ethereum_address(wallet_address): return Response({'error': 'Invalid wallet address.'}, status=status.HTTP_400_BAD_REQUEST) - if 'GenLayer POAP recovery' not in message or 'will not sign you into the portal' not in message: + + message_lines = message.splitlines() + expected_first_line = ( + f'{expected_recovery_domain()} wants you to verify a wallet for ' + 'GenLayer POAP recovery:' + ) + if len(message_lines) < 10 or message_lines[0] != expected_first_line: return Response( {'error': 'Invalid recovery message.'}, status=status.HTTP_400_BAD_REQUEST, ) - if wallet_address not in message.lower(): + if normalize_wallet_address(message_lines[1]) != wallet_address: return Response( {'error': 'Recovery message does not include the wallet being verified.'}, status=status.HTTP_400_BAD_REQUEST, ) + if message_lines[3] != RECOVERY_STATEMENT: + return Response( + {'error': 'Invalid recovery message.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if normalize_origin(extract_message_field(message, 'URI')) != expected_recovery_uri(): + return Response({'error': 'Invalid recovery URI.'}, status=status.HTTP_400_BAD_REQUEST) + if extract_message_field(message, 'Version') != '1': + return Response({'error': 'Invalid recovery message version.'}, status=status.HTTP_400_BAD_REQUEST) + if not extract_message_field(message, 'Chain ID').isdigit(): + return Response({'error': 'Invalid recovery chain ID.'}, status=status.HTTP_400_BAD_REQUEST) portal_account = normalize_wallet_address(extract_message_field(message, 'Portal Account')) if portal_account != normalize_wallet_address(request.user.address): @@ -240,7 +272,10 @@ def verify_wallet(self, request): with transaction.atomic(): try: - nonce = Nonce.objects.select_for_update().get(value=nonce_value) + nonce = Nonce.objects.select_for_update().get( + value=nonce_value, + purpose=Nonce.PURPOSE_POAP_RECOVERY, + ) except Nonce.DoesNotExist: return Response({'error': 'Invalid nonce.'}, status=status.HTTP_400_BAD_REQUEST) diff --git a/backend/stewards/tests.py b/backend/stewards/tests.py index 7ce503c2..4b06b795 100644 --- a/backend/stewards/tests.py +++ b/backend/stewards/tests.py @@ -1,3 +1,40 @@ -from django.test import TestCase +from django.contrib.auth import get_user_model +from rest_framework import status +from rest_framework.test import APITestCase -# Create your tests here. +from .models import Steward + + +User = get_user_model() + + +class StewardAPITestCase(APITestCase): + def setUp(self): + self.user = User.objects.create_user( + email='steward-user@example.com', + password='testpass123', + ) + self.client.force_authenticate(user=self.user) + + def test_patch_steward_me_does_not_create_profile(self): + response = self.client.patch('/api/v1/stewards/me/', {}) + + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertFalse(Steward.objects.filter(user=self.user).exists()) + + def test_regular_user_cannot_create_steward_profile(self): + response = self.client.post('/api/v1/stewards/', {}) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(Steward.objects.filter(user=self.user).exists()) + + def test_regular_user_cannot_mutate_arbitrary_steward_profile(self): + other_user = User.objects.create_user( + email='steward-other@example.com', + password='testpass123', + ) + steward = Steward.objects.create(user=other_user) + + response = self.client.patch(f'/api/v1/stewards/{steward.id}/', {}) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/backend/stewards/views.py b/backend/stewards/views.py index 29792f25..68bf7158 100644 --- a/backend/stewards/views.py +++ b/backend/stewards/views.py @@ -25,6 +25,45 @@ class StewardViewSet(viewsets.ModelViewSet): queryset = Steward.objects.all() serializer_class = StewardSerializer permission_classes = [IsAuthenticated] + + def _is_staff_mutation(self, request): + return bool( + request.user + and request.user.is_authenticated + and (request.user.is_staff or request.user.is_superuser) + ) + + def _deny_non_staff_mutation(self, request): + if self._is_staff_mutation(request): + return None + return Response( + {'detail': 'Only staff users can mutate steward profiles.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + def create(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().partial_update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().destroy(request, *args, **kwargs) def list(self, request, *args, **kwargs): """ @@ -96,7 +135,13 @@ def my_profile(self, request): ) elif request.method == 'PATCH': - steward, created = Steward.objects.get_or_create(user=request.user) + try: + steward = Steward.objects.get(user=request.user) + except Steward.DoesNotExist: + return Response( + {'detail': 'Steward profile not found for current user.'}, + status=status.HTTP_404_NOT_FOUND + ) serializer = self.get_serializer(steward, data=request.data, partial=True) if serializer.is_valid(): serializer.save() @@ -236,4 +281,3 @@ def search_users(self, request): return Response(results) - diff --git a/backend/validators/tests.py b/backend/validators/tests.py index 58a4d2b4..25de9ba9 100644 --- a/backend/validators/tests.py +++ b/backend/validators/tests.py @@ -35,38 +35,34 @@ def test_get_validator_profile_not_exists(self): response = self.client.get('/api/v1/validators/me/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_create_validator_profile(self): - """Test creating validator profile via PATCH""" + def test_patch_validator_profile_does_not_create_missing_profile(self): + """PATCH /me must not create a validator profile.""" response = self.client.patch('/api/v1/validators/me/', { - 'node_version': '1.2.3' + 'node_version_asimov': '1.2.3' }) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertFalse(Validator.objects.filter(user=self.user).exists()) - # Verify profile was created - self.assertTrue(Validator.objects.filter(user=self.user).exists()) - validator = Validator.objects.get(user=self.user) - self.assertEqual(validator.node_version, '1.2.3') - def test_update_validator_profile(self): """Test updating existing validator profile""" # Create profile first - Validator.objects.create(user=self.user, node_version='1.0.0') + Validator.objects.create(user=self.user, node_version_asimov='1.0.0') # Update it response = self.client.patch('/api/v1/validators/me/', { - 'node_version': '2.0.0' + 'node_version_asimov': '2.0.0' }) self.assertEqual(response.status_code, status.HTTP_200_OK) # Verify update validator = Validator.objects.get(user=self.user) - self.assertEqual(validator.node_version, '2.0.0') + self.assertEqual(validator.node_version_asimov, '2.0.0') def test_get_validator_profile_exists(self): """Test getting existing validator profile""" - Validator.objects.create(user=self.user, node_version='1.2.3') + Validator.objects.create(user=self.user, node_version_asimov='1.2.3') response = self.client.get('/api/v1/validators/me/') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn('node_version', response.data) - self.assertEqual(response.data['node_version'], '1.2.3') \ No newline at end of file + self.assertIn('node_version_asimov', response.data) + self.assertEqual(response.data['node_version_asimov'], '1.2.3') diff --git a/backend/validators/tests/test_api.py b/backend/validators/tests/test_api.py index 572b0994..630d1173 100644 --- a/backend/validators/tests/test_api.py +++ b/backend/validators/tests/test_api.py @@ -39,17 +39,38 @@ def test_get_validator_profile_not_exists(self): response = self.client.get('/api/v1/validators/me/') self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_create_validator_profile(self): - """Test creating validator profile via PATCH""" + def test_patch_validator_profile_does_not_create_missing_profile(self): + """PATCH /me must not create a validator profile.""" response = self.client.patch('/api/v1/validators/me/', { 'node_version_asimov': '1.2.3' }) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertFalse(Validator.objects.filter(user=self.user).exists()) - # Verify profile was created - self.assertTrue(Validator.objects.filter(user=self.user).exists()) - validator = Validator.objects.get(user=self.user) - self.assertEqual(validator.node_version_asimov, '1.2.3') + def test_regular_user_cannot_mutate_arbitrary_validator_profile(self): + """Ordinary users cannot mutate validator profiles through object routes.""" + other_user = User.objects.create_user( + email='other@example.com', + password='testpass123', + ) + validator = Validator.objects.create(user=other_user) + + response = self.client.patch( + f'/api/v1/validators/{validator.id}/', + {'node_version_asimov': '9.9.9'}, + ) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + validator.refresh_from_db() + self.assertNotEqual(validator.node_version_asimov, '9.9.9') + + def test_regular_user_cannot_create_validator_profile(self): + response = self.client.post('/api/v1/validators/', { + 'node_version_asimov': '1.2.3', + }) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(Validator.objects.filter(user=self.user).exists()) def test_update_validator_profile(self): """Test updating existing validator profile""" diff --git a/backend/validators/views.py b/backend/validators/views.py index d5417ec1..244dabb7 100644 --- a/backend/validators/views.py +++ b/backend/validators/views.py @@ -34,6 +34,45 @@ class ValidatorViewSet(viewsets.ModelViewSet): """ queryset = Validator.objects.all() serializer_class = ValidatorSerializer + + def _is_staff_mutation(self, request): + return bool( + request.user + and request.user.is_authenticated + and (request.user.is_staff or request.user.is_superuser) + ) + + def _deny_non_staff_mutation(self, request): + if self._is_staff_mutation(request): + return None + return Response( + {'detail': 'Only staff users can mutate validator profiles.'}, + status=status.HTTP_403_FORBIDDEN, + ) + + def create(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().create(request, *args, **kwargs) + + def update(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().update(request, *args, **kwargs) + + def partial_update(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().partial_update(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + denied = self._deny_non_staff_mutation(request) + if denied is not None: + return denied + return super().destroy(request, *args, **kwargs) def get_permissions(self): """ @@ -60,7 +99,23 @@ def my_profile(self, request): ) elif request.method == 'PATCH': - validator, created = Validator.objects.get_or_create(user=request.user) + unsupported_fields = set(request.data) - { + 'node_version_asimov', + 'node_version_bradbury', + } + if unsupported_fields: + return Response( + {'detail': 'Only node version fields can be updated here.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + validator = Validator.objects.get(user=request.user) + except Validator.DoesNotExist: + return Response( + {'detail': 'Validator profile not found for current user.'}, + status=status.HTTP_404_NOT_FOUND + ) serializer = self.get_serializer(validator, data=request.data, partial=True) if serializer.is_valid(): serializer.save() diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3a647ba0..710441be 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -15,6 +15,7 @@ "buffer": "^6.0.3", "chart.js": "^4.5.0", "date-fns": "^2.30.0", + "dompurify": "^3.4.8", "ethers": "^6.14.1", "marked": "^16.3.0", "process": "^0.11.10", @@ -4369,6 +4370,15 @@ "node": ">=12" } }, + "node_modules/dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -12261,6 +12271,14 @@ } } }, + "dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "requires": { + "@types/trusted-types": "^2.0.7" + } + }, "dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index c74c6be3..e4c7e24f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "buffer": "^6.0.3", "chart.js": "^4.5.0", "date-fns": "^2.30.0", + "dompurify": "^3.4.8", "ethers": "^6.14.1", "marked": "^16.3.0", "process": "^0.11.10", diff --git a/frontend/src/lib/auth.js b/frontend/src/lib/auth.js index 8a0d940f..642b3be0 100644 --- a/frontend/src/lib/auth.js +++ b/frontend/src/lib/auth.js @@ -196,11 +196,12 @@ export async function connectWallet(provider = null, walletName = 'wallet') { /** * Fetch a nonce from the backend + * @param {string} purpose - Purpose for the nonce, defaults to login * @returns {Promise} Nonce for SIWE message */ -export async function getNonce() { +export async function getNonce(purpose = 'login') { try { - const response = await authAxios.get(API_ENDPOINTS.NONCE); + const response = await authAxios.get(API_ENDPOINTS.NONCE, { params: { purpose } }); return response.data.nonce; } catch (error) { // Use a specific error message for this case @@ -283,7 +284,7 @@ export async function signInWithEthereum(provider = null, walletName = 'wallet', const ethereumProvider = state.provider || provider || window.ethereum; // Get nonce from server - const nonce = await getNonce(); + const nonce = await getNonce('login'); // Create and sign the message with the specific provider const { message, signature } = await createAndSignMessage(address, nonce, ethereumProvider); diff --git a/frontend/src/lib/markdownLoader.js b/frontend/src/lib/markdownLoader.js index 030b1243..385b2a7c 100644 --- a/frontend/src/lib/markdownLoader.js +++ b/frontend/src/lib/markdownLoader.js @@ -1,4 +1,25 @@ import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +const SANITIZE_CONFIG = { + USE_PROFILES: { html: true }, + FORBID_TAGS: [ + 'script', + 'style', + 'iframe', + 'object', + 'embed', + 'form', + 'input', + 'button', + 'textarea', + 'select', + 'option', + 'meta', + 'link' + ], + FORBID_ATTR: ['style'] +}; // Configure marked with minimal settings for legal documents marked.setOptions({ @@ -6,7 +27,6 @@ marked.setOptions({ gfm: true, // Enable GitHub Flavored Markdown headerIds: true, // Add IDs to headings for anchor links mangle: false, // Don't mangle header IDs - sanitize: false, // Allow raw HTML (we trust our own content) pedantic: false, // Don't be overly strict smartLists: true, // Use smarter list behavior smartypants: false // Don't use smart quotes (keep original) @@ -28,7 +48,7 @@ export function parseMarkdown(markdownContent) { return '

Content Error

No markdown content provided to parse

'; } - return marked(markdownContent); + return DOMPurify.sanitize(marked.parse(markdownContent), SANITIZE_CONFIG); } catch (error) { return `

Parse Error

Failed to parse markdown content: ${error.message}

`; } @@ -137,4 +157,4 @@ export function extractHeadings(markdownContent) { } return headings; -} \ No newline at end of file +} diff --git a/frontend/src/routes/PoapRecovery.svelte b/frontend/src/routes/PoapRecovery.svelte index e9c74836..37cf93f8 100644 --- a/frontend/src/routes/PoapRecovery.svelte +++ b/frontend/src/routes/PoapRecovery.svelte @@ -141,7 +141,7 @@ Issued At: ${new Date().toISOString()}`; const provider = window.ethereum; const address = await requestRecoveryWallet(provider); verifiedAddress = address; - const nonce = await getNonce(); + const nonce = await getNonce('poap_recovery'); const chainId = await getChainId(provider); const message = buildRecoveryMessage(address, nonce, chainId); const ethersProvider = new ethers.BrowserProvider(provider); diff --git a/frontend/src/tests/markdownLoader.test.js b/frontend/src/tests/markdownLoader.test.js new file mode 100644 index 00000000..4dead273 --- /dev/null +++ b/frontend/src/tests/markdownLoader.test.js @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { parseMarkdown } from '../lib/markdownLoader.js'; + +describe('parseMarkdown', () => { + it('preserves normal markdown links and formatting', () => { + const html = parseMarkdown('Read **the docs** at [GenLayer](https://genlayer.com).'); + + expect(html).toContain('the docs'); + expect(html).toContain('GenLayer'); + }); + + it('strips scripts, event handlers, inline styles, and unsafe URLs', () => { + const html = parseMarkdown(` + + +bad link + +`); + + expect(html).not.toContain(' Date: Fri, 5 Jun 2026 12:32:58 +0200 Subject: [PATCH 2/2] Address PR security review follow-ups --- backend/.env.example | 2 +- .../management/commands/review_submissions.py | 11 +++- .../tests/test_review_submissions.py | 53 +++++++++++++++++-- backend/validators/tests/test_api.py | 9 ++++ frontend/src/lib/markdownLoader.js | 45 ++++++++++------ frontend/src/tests/markdownLoader.test.js | 20 +++++++ 6 files changed, 120 insertions(+), 20 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 3c319f3c..27821a3f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,7 +1,7 @@ # Django Settings SECRET_KEY=your-secret-key-here DEBUG=True -ALLOWED_HOSTS=localhost,127.0.0.1 +ALLOWED_HOSTS=localhost,127.0.0.1,testserver # Database # Leave DATABASE_URL empty for SQLite (development) diff --git a/backend/contributions/management/commands/review_submissions.py b/backend/contributions/management/commands/review_submissions.py index 024b7a3c..fb60261d 100644 --- a/backend/contributions/management/commands/review_submissions.py +++ b/backend/contributions/management/commands/review_submissions.py @@ -132,7 +132,8 @@ def rule_duplicate_evidence_url(submission, evidence_items, before comparison to prevent cosmetic variants from bypassing the check. Args: - Pending submission duplicates only count when the duplicate is older. + Pending submission duplicates only count when the duplicate is older, + unless skip_pending is set for a targeted command run. Accepted contribution duplicates always count. """ urls_with_evidence = [(e, _normalize_url(e.url)) @@ -183,6 +184,14 @@ def _check_single_url_duplicate(submission, evidence, normalized, ) # Check pending/accepted submitted contributions (exclude self) others = (url_to_sub_ids.get(normalized) or set()) - {submission.id} + if skip_pending and others: + pending_states = {'pending', 'more_info_needed'} + others = set( + SubmittedContribution.objects + .filter(id__in=others) + .exclude(state__in=pending_states) + .values_list('id', flat=True) + ) if not others: return None diff --git a/backend/contributions/tests/test_review_submissions.py b/backend/contributions/tests/test_review_submissions.py index de5fa127..d2d9c22b 100644 --- a/backend/contributions/tests/test_review_submissions.py +++ b/backend/contributions/tests/test_review_submissions.py @@ -356,8 +356,8 @@ def test_skip_pending_still_catches_accepted(self): self.assertIsNotNone(result) self.assertIn('accepted contribution', result[1]) - def test_skip_pending_rejects_when_older_duplicate_exists(self): - """Targeted runs should still reject against an older duplicate.""" + def test_skip_pending_ignores_older_pending_duplicate(self): + """Targeted runs should ignore pending submission duplicates.""" older_sub = self._create_submission(notes='Older submission') self._add_evidence(older_sub, url='https://example.com/post') @@ -375,6 +375,30 @@ def test_skip_pending_rejects_when_older_duplicate_exists(self): newer_sub, [ev], url_lookup, accepted_urls, skip_pending=True, submitted_created_at=created_at_lookup, ) + self.assertIsNone(result) + + def test_skip_pending_still_rejects_accepted_submitted_duplicate(self): + """Targeted runs still reject against accepted submitted duplicates.""" + accepted_sub = self._create_submission( + notes='Accepted submission', + state='accepted', + ) + self._add_evidence(accepted_sub, url='https://example.com/post') + + newer_sub = self._create_submission( + user=self.other_user, notes='Newer submission', + ) + ev = self._add_evidence(newer_sub, url='https://example.com/post') + + url_lookup, accepted_urls = self._build_lookup() + created_at_lookup = { + accepted_sub.id: accepted_sub.created_at, + newer_sub.id: newer_sub.created_at, + } + result = rule_duplicate_evidence_url( + newer_sub, [ev], url_lookup, accepted_urls, skip_pending=True, + submitted_created_at=created_at_lookup, + ) self.assertIsNotNone(result) self.assertIn('older submission', result[1]) @@ -724,7 +748,7 @@ def setUp(self): action='reject', ) - def test_submission_id_rejects_newer_duplicate(self): + def test_submission_id_ignores_pending_duplicate(self): older_sub = self._create_submission(notes='Older submission') self._add_evidence(older_sub, url='https://example.com/post') @@ -741,6 +765,29 @@ def test_submission_id_rejects_newer_duplicate(self): stdout=out, ) + newer_sub.refresh_from_db() + self.assertEqual(newer_sub.state, 'pending') + + def test_submission_id_rejects_accepted_submitted_duplicate(self): + accepted_sub = self._create_submission( + notes='Accepted submission', + state='accepted', + ) + self._add_evidence(accepted_sub, url='https://example.com/post') + + newer_sub = self._create_submission( + user=self.other_user, notes='Newer submission', + ) + self._add_evidence(newer_sub, url='https://example.com/post') + + out = StringIO() + call_command( + 'review_submissions', + '--submission-id', str(newer_sub.id), + '--batch-size', '0', + stdout=out, + ) + newer_sub.refresh_from_db() self.assertEqual(newer_sub.state, 'rejected') diff --git a/backend/validators/tests/test_api.py b/backend/validators/tests/test_api.py index 630d1173..01f2c261 100644 --- a/backend/validators/tests/test_api.py +++ b/backend/validators/tests/test_api.py @@ -47,6 +47,15 @@ def test_patch_validator_profile_does_not_create_missing_profile(self): self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) self.assertFalse(Validator.objects.filter(user=self.user).exists()) + def test_patch_validator_profile_rejects_unsupported_keys(self): + """PATCH /me rejects arbitrary keys and does not create a profile.""" + response = self.client.patch('/api/v1/validators/me/', { + 'unsupported_field': 'x', + }) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertFalse(Validator.objects.filter(user=self.user).exists()) + def test_regular_user_cannot_mutate_arbitrary_validator_profile(self): """Ordinary users cannot mutate validator profiles through object routes.""" other_user = User.objects.create_user( diff --git a/frontend/src/lib/markdownLoader.js b/frontend/src/lib/markdownLoader.js index 385b2a7c..433d6be9 100644 --- a/frontend/src/lib/markdownLoader.js +++ b/frontend/src/lib/markdownLoader.js @@ -2,22 +2,37 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; const SANITIZE_CONFIG = { - USE_PROFILES: { html: true }, - FORBID_TAGS: [ - 'script', - 'style', - 'iframe', - 'object', - 'embed', - 'form', - 'input', - 'button', - 'textarea', - 'select', - 'option', - 'meta', - 'link' + ALLOWED_TAGS: [ + 'a', + 'blockquote', + 'br', + 'code', + 'del', + 'em', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'hr', + 'img', + 'li', + 'ol', + 'p', + 'pre', + 'strong', + 'table', + 'tbody', + 'td', + 'th', + 'thead', + 'tr', + 'ul' ], + ALLOWED_ATTR: ['alt', 'href', 'id', 'src', 'title'], + ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|#|\/(?!\/))/i, + ALLOW_DATA_ATTR: false, FORBID_ATTR: ['style'] }; diff --git a/frontend/src/tests/markdownLoader.test.js b/frontend/src/tests/markdownLoader.test.js index 4dead273..3206a75e 100644 --- a/frontend/src/tests/markdownLoader.test.js +++ b/frontend/src/tests/markdownLoader.test.js @@ -25,4 +25,24 @@ describe('parseMarkdown', () => { expect(html).not.toContain('javascript:'); expect(html).not.toContain(' { + const html = parseMarkdown(` + +

hover

+ + +
+ +`); + + expect(html).not.toContain('data:text/html'); + expect(html).not.toContain('onload'); + expect(html).not.toContain('onmouseover'); + expect(html).not.toContain('