diff --git a/.circleci/config.yml b/.circleci/config.yml index ac57f8393..152fcf580 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -70,7 +70,7 @@ jobs: - run: name: "Tests: Run unit/integration tests (excluding e2e)" - command: docker compose exec django py.test src/ + command: docker compose exec -e CELERY_TASK_ALWAYS_EAGER=True django py.test src/ # We give the name of the test files manually because we need test_auth.py to be run before the others for state.json file to be created # CI="true" to skip some tests that fail in the CI for now @@ -78,7 +78,7 @@ jobs: name: "Tests: Run end-to-end (E2E) tests" command: | docker compose exec django python ./manage.py createsuperuser --no-input - cd tests && CI=True $HOME/.local/bin/uv run pytest test_auth.py test_account_creation.py test_competition.py test_submission.py + cd tests && $HOME/.local/bin/uv run pytest test_auth.py test_account_creation.py test_competition.py test_submission.py no_output_timeout: 30m # Example to run specific set of tests (for debugging individual tests from a batch of tests) diff --git a/.env_circleci b/.env_circleci index 9ec59cd51..79844788a 100644 --- a/.env_circleci +++ b/.env_circleci @@ -34,3 +34,5 @@ DJANGO_SUPERUSER_USERNAME=codabench DOMAIN_NAME=localhost:80 TLS_EMAIL=your@email.com SUBMISSIONS_API_URL=http://django:8000/api + +CELERY_TASK_ALWAYS_EAGER=False \ No newline at end of file diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 000000000..b24d71705 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,30 @@ +# Security Policy + +## Supported Versions + +Security fixes are applied to the `develop` branch and included in the next release. +Only the [latest release](https://github.com/codalab/codabench/releases/latest) is supported. +If you run your own Codabench instance, please keep it up to date. + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues, pull requests, or discussions.** + +Report them privately using one of the following channels: + +- **GitHub:** go to the [Security tab](https://github.com/codalab/codabench/security) of this repository and click **Report a vulnerability**. +- **Email:** send the details to [info@codabench.org](mailto:info@codabench.org) with `[SECURITY]` in the subject line. + +Please include as much of the following as possible: + +- Type of issue (e.g. XSS, SQL injection, privilege escalation, sandbox escape in the compute worker) +- Affected component (Django app, API endpoint, frontend page, compute worker, etc.) and file paths if known +- Affected version, commit, or URL +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code, if available +- Impact of the issue and how an attacker might exploit it + +## Handling of Reports + +- We will investigate, keep you informed of our progress, and let you know when a fix is released. +- Please give us reasonable time to fix the issue before disclosing it publicly. diff --git a/docker-compose.yml b/docker-compose.yml index 4b2949dbb..b167880f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,7 +56,7 @@ services: # Minio local storage helper #---------------------------------------------------------------------------------------------------- minio: - image: quay.io/minio/minio:RELEASE.2025-04-22T22-12-26Z + image: codalab/minio:RELEASE.2025-04-22T22-12-26Z command: server /export volumes: - ./var/minio:/export @@ -69,7 +69,7 @@ services: interval: 5s retries: 5 createbuckets: - image: quay.io/minio/mc:RELEASE.2025-07-21T05-28-08Z + image: codalab/mc:RELEASE.2025-07-21T05-28-08Z depends_on: minio: condition: service_healthy diff --git a/src/apps/api/serializers/competitions.py b/src/apps/api/serializers/competitions.py index 3fa4eb041..f2a4faf19 100644 --- a/src/apps/api/serializers/competitions.py +++ b/src/apps/api/serializers/competitions.py @@ -15,7 +15,7 @@ from profiles.models import User from tasks.models import Task -from api.serializers.queues import QueueSerializer +from api.serializers.queues import QueueSerializer, QueuePublicSerializer from datetime import datetime from django.utils.timezone import now @@ -375,9 +375,29 @@ class CompetitionDetailSerializer(serializers.ModelSerializer): participant_status = serializers.CharField(read_only=True) participants_count = serializers.IntegerField(read_only=True) submissions_count = serializers.IntegerField(read_only=True) - queue = QueueSerializer(read_only=True) + queue = QueuePublicSerializer(read_only=True) whitelist_emails = serializers.SerializerMethodField() + # Fields only visible to competition admins (creator, collaborators, staff/superusers) + ADMIN_ONLY_FIELDS = ( + 'secret_key', + 'whitelist_emails', + 'collaborators', + 'queue', + 'enable_detailed_results', + 'show_detailed_results_in_submission_panel', + 'show_detailed_results_in_leaderboard', + 'auto_run_submissions', + 'forum', + 'forum_enabled', + 'enable_human_in_the_loop', + 'registration_auto_approve', + 'can_participants_make_submissions_public', + 'make_programs_available', + 'make_input_data_available', + 'fact_sheet', + ) + class Meta: model = Competition fields = ( @@ -446,10 +466,10 @@ def to_representation(self, instance): representation = super().to_representation(instance) user = self.context['request'].user - # If user is not admin/creator/collaborator then do not include secret_key and whitelist_emails + # If user is not admin/creator/collaborator then do not include the admin-only fields if not instance.user_has_admin_permission(user): - representation.pop('secret_key', None) - representation.pop('whitelist_emails', None) + for field in self.ADMIN_ONLY_FIELDS: + representation.pop(field, None) return representation diff --git a/src/apps/api/serializers/queues.py b/src/apps/api/serializers/queues.py index c1818896e..76a3dd722 100644 --- a/src/apps/api/serializers/queues.py +++ b/src/apps/api/serializers/queues.py @@ -64,6 +64,14 @@ def validate(self, attrs): return super().validate(attrs) +class QueuePublicSerializer(serializers.ModelSerializer): + """Minimal queue info safe to expose on public endpoints (no broker credentials).""" + class Meta: + model = Queue + fields = ('id', 'name') + read_only_fields = fields + + class QueueSerializer(QueueOwnerMixin, serializers.ModelSerializer): is_owner = serializers.SerializerMethodField() owner = serializers.CharField(source='owner.username', read_only=True) diff --git a/src/apps/api/tests/test_competitions.py b/src/apps/api/tests/test_competitions.py index a8e902b63..307aa93b9 100644 --- a/src/apps/api/tests/test_competitions.py +++ b/src/apps/api/tests/test_competitions.py @@ -1,16 +1,17 @@ import json import random import csv +import uuid from zipfile import ZipFile from io import StringIO, BytesIO from unittest import mock from django.urls import reverse from rest_framework.test import APITestCase -from api.serializers.competitions import CompetitionSerializer +from api.serializers.competitions import CompetitionSerializer, CompetitionDetailSerializer from competitions.models import CompetitionParticipant, Submission, Competition from factories import UserFactory, CompetitionFactory, CompetitionParticipantFactory, PhaseFactory, LeaderboardFactory, \ - ColumnFactory, SubmissionFactory, SubmissionScoreFactory, TaskFactory + ColumnFactory, SubmissionFactory, SubmissionScoreFactory, TaskFactory, QueueFactory class CompetitionTests(APITestCase): @@ -84,6 +85,204 @@ def test_delete_own_competition(self): assert not Competition.objects.filter(pk=self.comp.pk).exists() +class CompetitionDetailTests(APITestCase): + """ + Tests for the competition detail API: who can access public and private competitions, + which fields admins and non-admins see, and that the queue only includes its id and name. + """ + def setUp(self): + self.creator = UserFactory(username='creator', password='creator') + self.collaborator = UserFactory(username='collaborator', password='collaborator') + self.participant = UserFactory(username='participant', password='participant') + self.pending_participant = UserFactory(username='pending_participant', password='pending_participant') + self.other_user = UserFactory(username='other_user', password='other_user') + self.superuser = UserFactory(username='superuser', password='superuser', is_superuser=True, is_staff=True) + self.queue_owner = UserFactory(username='queue_owner', password='queue_owner') + # Mock RabbitMQ so saving the queue doesn't create a real vhost; return a fake vhost UUID instead + with mock.patch('queues.models.rabbit.create_queue') as rabbit_create_queue: + rabbit_create_queue.return_value = uuid.uuid4() + self.queue = QueueFactory(owner=self.queue_owner, is_public=True) + + self.public_comp = CompetitionFactory( + created_by=self.creator, collaborators=[self.collaborator], queue=self.queue, published=True + ) + self.private_comp = CompetitionFactory( + created_by=self.creator, collaborators=[self.collaborator], queue=self.queue, published=False + ) + for comp in (self.public_comp, self.private_comp): + CompetitionParticipantFactory(user=self.participant, competition=comp, status='approved') + CompetitionParticipantFactory(user=self.pending_participant, competition=comp, status='pending') + self.queue_owner_comp = CompetitionFactory(created_by=self.queue_owner, queue=self.queue, published=True) + + # One visible and one hidden leaderboard on the public competition + self.visible_leaderboard = LeaderboardFactory(hidden=False) + self.hidden_leaderboard = LeaderboardFactory(hidden=True) + PhaseFactory(competition=self.public_comp, leaderboard=self.visible_leaderboard) + PhaseFactory(competition=self.public_comp, leaderboard=self.hidden_leaderboard) + + # None means a logged-out user + self.admins = [self.creator, self.collaborator, self.superuser] + self.non_admins = [None, self.participant, self.pending_participant, self.other_user] + + def _get(self, competition, user=None, **params): + """Request the detail API of `competition` as `user`, or logged out when `user` is None.""" + self.client.logout() + if user: + self.client.force_login(user) + url = reverse('competition-detail', kwargs={"pk": competition.pk}) + return self.client.get(url, params) + + # ---------- Access ---------- + + def test_anyone_can_access_public_competition(self): + """ + Admins, participants, other users and logged-out users request a published competition. + Expects a 200 response for all of them. + """ + for user in self.admins + self.non_admins: + assert self._get(self.public_comp, user).status_code == 200 + + def test_organizers_approved_participants_and_superusers_can_access_private_competition(self): + """ + The creator, a collaborator, an approved participant and a superuser request an unpublished competition. + Expects a 200 response for all of them. + """ + for user in [self.creator, self.collaborator, self.participant, self.superuser]: + assert self._get(self.private_comp, user).status_code == 200 + + def test_other_users_cannot_access_private_competition(self): + """ + A logged-out user, an unrelated user and a pending participant request an unpublished competition + without a secret key. + Expects a 404 response for all of them. + """ + for user in [None, self.other_user, self.pending_participant]: + assert self._get(self.private_comp, user).status_code == 404 + + def test_valid_secret_key_gives_access_to_private_competition(self): + """ + A logged-out user, an unrelated user and a pending participant request an unpublished competition + with its secret key. + Expects a 200 response for all of them. + """ + for user in [None, self.other_user, self.pending_participant]: + resp = self._get(self.private_comp, user, secret_key=str(self.private_comp.secret_key)) + assert resp.status_code == 200 + + def test_invalid_secret_key_does_not_give_access_to_private_competition(self): + """ + A logged-out user and an unrelated user request an unpublished competition with a wrong secret key. + Expects a 404 response for both. + """ + for user in [None, self.other_user]: + resp = self._get(self.private_comp, user, secret_key=str(uuid.uuid4())) + assert resp.status_code == 404 + + # ---------- Fields ---------- + + def test_admins_see_admin_only_fields(self): + """ + The creator, a collaborator and a superuser request a published competition. + Expects every admin-only field in the response, including the competition's secret key. + """ + for user in self.admins: + resp = self._get(self.public_comp, user) + assert resp.status_code == 200 + for field in CompetitionDetailSerializer.ADMIN_ONLY_FIELDS: + assert field in resp.data, field + assert resp.data['secret_key'] == str(self.public_comp.secret_key) + + def test_non_admins_do_not_see_admin_only_fields(self): + """ + Participants, an unrelated user and a logged-out user request a published competition. + Expects the public fields in the response, no admin-only field, + and the competition's secret key absent from the whole response body. + """ + for user in self.non_admins: + resp = self._get(self.public_comp, user) + assert resp.status_code == 200 + for field in ('id', 'title', 'created_by', 'phases', 'leaderboards'): + assert field in resp.data, field + for field in CompetitionDetailSerializer.ADMIN_ONLY_FIELDS: + assert field not in resp.data, field + assert str(self.public_comp.secret_key) not in resp.content.decode() + + def test_non_admins_with_secret_key_do_not_see_admin_only_fields(self): + """ + A logged-out user and an unrelated user open an unpublished competition with its secret key. + Expects a 200 response without any admin-only field. + """ + for user in [None, self.other_user]: + resp = self._get(self.private_comp, user, secret_key=str(self.private_comp.secret_key)) + assert resp.status_code == 200 + for field in CompetitionDetailSerializer.ADMIN_ONLY_FIELDS: + assert field not in resp.data, field + + def test_admins_see_hidden_leaderboards(self): + """ + The creator, a collaborator and a superuser request a competition with a visible and a hidden leaderboard. + Expects both leaderboards in the response. + """ + for user in self.admins: + resp = self._get(self.public_comp, user) + leaderboard_ids = {lb['id'] for lb in resp.data['leaderboards']} + assert leaderboard_ids == {self.visible_leaderboard.id, self.hidden_leaderboard.id} + + def test_non_admins_do_not_see_hidden_leaderboards(self): + """ + Participants, an unrelated user and a logged-out user request a competition + with a visible and a hidden leaderboard. + Expects only the visible leaderboard in the response. + """ + for user in self.non_admins: + resp = self._get(self.public_comp, user) + leaderboard_ids = {lb['id'] for lb in resp.data['leaderboards']} + assert leaderboard_ids == {self.visible_leaderboard.id} + + # ---------- Queue ---------- + + def test_admins_see_only_queue_id_and_name(self): + """ + The creator and a collaborator (neither owns the queue) and a superuser request a competition + that uses someone else's queue. + Expects the queue to have only id and name. + """ + for user in self.admins: + resp = self._get(self.public_comp, user) + assert resp.status_code == 200 + assert resp.data['queue'] == {'id': self.queue.id, 'name': self.queue.name} + + def test_queue_owner_sees_only_queue_id_and_name_on_own_competition(self): + """ + The queue owner requests the detail API of their own competition that uses their queue. + Expects the queue to have only id and name. + Queue owners can get the broker URL from the queues API instead. + """ + resp = self._get(self.queue_owner_comp, self.queue_owner) + assert resp.status_code == 200 + assert resp.data['queue'] == {'id': self.queue.id, 'name': self.queue.name} + + def test_non_admins_do_not_see_queue(self): + """ + Participants, an unrelated user and a logged-out user request a competition that uses a queue. + Expects no queue field in the response. + """ + for user in self.non_admins: + resp = self._get(self.public_comp, user) + assert resp.status_code == 200 + assert 'queue' not in resp.data + + def test_admins_see_null_queue_when_competition_has_no_queue(self): + """ + The creator requests a competition that doesn't use a custom queue. + Expects the queue field to be None. + """ + comp = CompetitionFactory(created_by=self.creator, queue=None, published=True) + resp = self._get(comp, self.creator) + assert resp.status_code == 200 + assert resp.data['queue'] is None + + class CompetitionListTests(APITestCase): def setUp(self): self.user = UserFactory(username='user', password='user') diff --git a/src/apps/api/views/competitions.py b/src/apps/api/views/competitions.py index 88be3c012..4a3bacb00 100644 --- a/src/apps/api/views/competitions.py +++ b/src/apps/api/views/competitions.py @@ -187,7 +187,7 @@ def get_queryset(self): if search_query: qs = qs.filter(Q(title__icontains=search_query) | Q(description__icontains=search_query)) - qs = qs.order_by('created_when') + qs = qs.order_by('-created_when') return qs def get_permissions(self): diff --git a/src/apps/competitions/models.py b/src/apps/competitions/models.py index a2934bf5b..af9d2f41c 100644 --- a/src/apps/competitions/models.py +++ b/src/apps/competitions/models.py @@ -166,6 +166,7 @@ def apply_phase_migration(self, current_phase, next_phase, force_migration=False owner=submission.owner, data=submission.data, organization=submission.organization, + fact_sheet_answers=submission.fact_sheet_answers, ) new_submission.save(ignore_submission_limit=True) new_submission.start() diff --git a/src/apps/competitions/tests/test_phase_migration.py b/src/apps/competitions/tests/test_phase_migration.py index f1b6e77a8..7e610c8fb 100644 --- a/src/apps/competitions/tests/test_phase_migration.py +++ b/src/apps/competitions/tests/test_phase_migration.py @@ -131,6 +131,14 @@ def test_only_parent_submissions_migrated(self): mock_sub_start = self.mock_migration() assert mock_sub_start.call_count == 1 + def test_fact_sheet_answers_are_migrated(self): + answers = {'method_name': 'my method', 'uses_external_data': 'false'} + self.phase1.submissions.update(fact_sheet_answers=answers) + self.mock_migration() + assert self.phase2.submissions.exists() + for submission in self.phase2.submissions.all(): + assert submission.fact_sheet_answers == answers + class PhaseStatusTests(TestCase): def setUp(self): diff --git a/src/apps/pages/views.py b/src/apps/pages/views.py index c6bb6a65e..d68e5da4d 100644 --- a/src/apps/pages/views.py +++ b/src/apps/pages/views.py @@ -5,7 +5,6 @@ from competitions.models import Submission from announcements.models import Announcement, NewsPost -from django.conf import settings from utils.data import pretty_bytes @@ -20,7 +19,6 @@ def get_context_data(self, *args, **kwargs): news_posts = NewsPost.objects.all().order_by('-id') context['news_posts'] = news_posts - context['CONTACT_EMAIL'] = settings.CONTACT_EMAIL return context diff --git a/src/settings/test.py b/src/settings/test.py index 4294dd940..a1ccf673f 100644 --- a/src/settings/test.py +++ b/src/settings/test.py @@ -1,9 +1,10 @@ +import os from settings.base import * # noqa: F401,F403 # these noqa comments are for flake8 ignores DEBUG = True -CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_ALWAYS_EAGER = os.environ.get("CELERY_TASK_ALWAYS_EAGER", "True").lower() == "true" INSTALLED_APPS += ('debug_toolbar',) MIDDLEWARE = ('debug_toolbar.middleware.DebugToolbarMiddleware', 'querycount.middleware.QueryCountMiddleware', diff --git a/src/static/riot/external_competitions/external_competition_list.tag b/src/static/riot/external_competitions/external_competition_list.tag index 6b05bf892..86e575084 100644 --- a/src/static/riot/external_competitions/external_competition_list.tag +++ b/src/static/riot/external_competitions/external_competition_list.tag @@ -41,7 +41,7 @@
- +
@@ -53,7 +53,6 @@
-
{competition.platform_name}
@@ -81,11 +80,11 @@
- - + + { current_page } of {Math.ceil(competitions.count/competitions.page_size)} - - + +
@@ -167,6 +166,12 @@ self.handle_ajax_pages = function (num) { self.update_competitions_list(self.get_url_page_number_or_default() + num) + .done(function () { + // Short pause so the user sees the new results appear before scrolling up + setTimeout(function () { + window.scrollTo({top: 0, behavior: 'smooth'}) + }, 200) + }) } self.update_competitions_list = function (num) { @@ -238,199 +243,4 @@ self.update_competitions_list(self.get_url_page_number_or_default()) }) - - diff --git a/src/static/riot/profiles/profile_account.tag b/src/static/riot/profiles/profile_account.tag index b6e6c68d0..fc41a2adb 100644 --- a/src/static/riot/profiles/profile_account.tag +++ b/src/static/riot/profiles/profile_account.tag @@ -15,6 +15,10 @@ This is extremely important. +
+ + If you just want to change your username, contact us instead at { contact_email } +

By clicking "Delete my account" you will receive a confirmation email to proceed with your account deletion. @@ -25,7 +29,7 @@

You will also no longer be eligible for any cash prizes in competitions you are participating in.

- You will not be able to re-create an account using the same email address for 30 days. + You will not be able to re-create an account using the same email address for 30 days.

@@ -53,7 +57,7 @@ {% endblock %} diff --git a/src/utils/context_processors.py b/src/utils/context_processors.py index 065758095..8b432d95e 100644 --- a/src/utils/context_processors.py +++ b/src/utils/context_processors.py @@ -58,4 +58,5 @@ def common_settings(request): 'VERSION_INFO': version_info, 'HOME_PAGE_COUNTERS_INFO': home_page_counters_info, 'DOMAIN_NAME': settings.DOMAIN_NAME, + 'CONTACT_EMAIL': settings.CONTACT_EMAIL, } diff --git a/tests/test_submission.py b/tests/test_submission.py index e23f1a6fb..2621883ac 100644 --- a/tests/test_submission.py +++ b/tests/test_submission.py @@ -2,14 +2,8 @@ import toml import pytest import re -import os from loguru import logger -if os.environ.get("CI", "false").lower() == "true": - ci = True -else: - ci = False - data = toml.load("config/config.toml") @@ -139,7 +133,6 @@ def test_v18_autowsl(page: Page): # Skip this test if in the CI -@pytest.mark.skipif(ci, reason="Works locally but fails in the CI because of CELERY_TASK_ALWAYS_EAGER = True") def test_v2_multiTask(page: Page) -> None: page.goto("/") page.get_by_role("link", name=" Benchmarks/Competitions").click() @@ -159,7 +152,7 @@ def test_v2_multiTask(page: Page) -> None: # Wait for Finished to show. If it does not, catch the error and reload the page in case the page didn't update automatically try: expect(page.get_by_role("cell", name="Finished")).to_be_visible(timeout=35000) - except: + except Exception: page.reload() expect(page.get_by_role("cell", name="Finished")).to_be_visible(timeout=2000) # Add to leaderboard and see if shows @@ -167,7 +160,7 @@ def test_v2_multiTask(page: Page) -> None: submission_Id = text.split(None, 1) try: page.locator("td:nth-child(6) > span > .icon").first.click(timeout=300) - except: + except Exception: page.locator("td:nth-child(7) > span > .icon").first.click(timeout=300) page.locator("div").filter(has_text=re.compile(r"^Results$")).click() expect( @@ -186,14 +179,13 @@ def test_v2_multiTask(page: Page) -> None: ).to_be_visible() found = True break - except: + except Exception: pass if not found: assert 0, "Submission not found in the leaderboard" # Skip this test if in the CI -@pytest.mark.skipif(ci, reason="Works locally but fails in the CI because of CELERY_TASK_ALWAYS_EAGER = True") def test_v2_multiTaskFactSheet(page: Page) -> None: page.goto("/") page.get_by_role("link", name=" Benchmarks/Competitions").click() diff --git a/version.json b/version.json index 272184d28..c155bea47 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "tag_name": "v1.32", - "release_name": "v1.32", - "html_url": "https://github.com/codalab/codabench/releases/tag/v1.32" + "tag_name": "v1.33", + "release_name": "v1.33", + "html_url": "https://github.com/codalab/codabench/releases/tag/v1.33" }