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 @@
@@ -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"
}