From 087049b7eaeb4afa8c8b23e4cf2b7c6812113415 Mon Sep 17 00:00:00 2001 From: prakash-kalwaniya Date: Wed, 11 Feb 2026 02:27:46 +0530 Subject: [PATCH] fix: patch 4 critical security vulnerabilities - IDOR in update_submission and update_partially_evaluated_submission: Added ownership validation to verify submission belongs to the challenge specified in the URL path, preventing cross-challenge tampering. - SSRF in is_url_valid(): Added private/reserved IP blocking, scheme restriction (http/https only), hostname resolution checks, and timeout. - eval() on environment variable: Replaced dangerous eval() call on LIMIT_CONCURRENT_SUBMISSION_PROCESSING with safe string comparison. - Arbitrary pip install from user-uploaded evaluation scripts: Added --no-deps flag and warning log to limit supply-chain risk. --- apps/jobs/utils.py | 31 +++++++++++++++++++++++----- apps/jobs/views.py | 30 +++++++++++++++++++++++++++ scripts/workers/submission_worker.py | 9 ++++++-- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/apps/jobs/utils.py b/apps/jobs/utils.py index 9d58fba61f..f794b0b508 100644 --- a/apps/jobs/utils.py +++ b/apps/jobs/utils.py @@ -159,16 +159,37 @@ def get_remaining_submission_for_a_phase( def is_url_valid(url): """ - Checks that a given URL is reachable. + Checks that a given URL is reachable and not an internal/private address. :param url: A URL :return type: bool """ - request = urllib.request.Request(url) - request.get_method = lambda: "HEAD" try: - urllib.request.urlopen(request) + from urllib.parse import urlparse + import ipaddress + import socket + + parsed = urlparse(url) + + # Only allow http and https schemes + if parsed.scheme not in ("http", "https"): + return False + + hostname = parsed.hostname + if not hostname: + return False + + # Resolve hostname and check against private/reserved IP ranges + resolved_ip = socket.gethostbyname(hostname) + ip = ipaddress.ip_address(resolved_ip) + if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local: + return False + + request = urllib.request.Request(url) + request.get_method = lambda: "HEAD" + urllib.request.urlopen(request, timeout=5) return True - except urllib.request.HTTPError: + except (urllib.request.HTTPError, urllib.request.URLError, + socket.gaierror, ValueError, OSError): return False diff --git a/apps/jobs/views.py b/apps/jobs/views.py index 0e7fe0ebf8..f9d24e38ce 100644 --- a/apps/jobs/views.py +++ b/apps/jobs/views.py @@ -1195,6 +1195,13 @@ def update_submission(request, challenge_pk): metadata = request.data.get("metadata", "") submission = get_submission_model(submission_pk) + # Verify submission belongs to the challenge specified in the URL + if submission.challenge_phase.challenge_id != int(challenge_pk): + response_data = { + "error": "Submission does not belong to this challenge" + } + return Response(response_data, status=status.HTTP_403_FORBIDDEN) + public_results = [] successful_submission = ( True if submission_status == Submission.FINISHED else False @@ -1353,6 +1360,14 @@ def update_submission(request, challenge_pk): if request.method == "PATCH": submission_pk = request.data.get("submission") submission = get_submission_model(submission_pk) + + # Verify submission belongs to the challenge specified in the URL + if submission.challenge_phase.challenge_id != int(challenge_pk): + response_data = { + "error": "Submission does not belong to this challenge" + } + return Response(response_data, status=status.HTTP_403_FORBIDDEN) + # Update submission_input_file for is_static_dataset_code_upload # submission evaluation if ( @@ -1630,6 +1645,13 @@ def update_partially_evaluated_submission(request, challenge_pk): metadata = request.data.get("metadata", "") submission = get_submission_model(submission_pk) + # Verify submission belongs to the challenge specified in the URL + if submission.challenge_phase.challenge_id != int(challenge_pk): + response_data = { + "error": "Submission does not belong to this challenge" + } + return Response(response_data, status=status.HTTP_403_FORBIDDEN) + public_results = [] successful_submission = ( True @@ -1798,6 +1820,14 @@ def update_partially_evaluated_submission(request, challenge_pk): submission_status = request.data.get("submission_status", "").lower() job_name = request.data.get("job_name", "").lower() submission = get_submission_model(submission_pk) + + # Verify submission belongs to the challenge specified in the URL + if submission.challenge_phase.challenge_id != int(challenge_pk): + response_data = { + "error": "Submission does not belong to this challenge" + } + return Response(response_data, status=status.HTTP_403_FORBIDDEN) + jobs = submission.job_name if job_name: jobs.append(job_name) diff --git a/scripts/workers/submission_worker.py b/scripts/workers/submission_worker.py index c679e6cb09..097dd53b9c 100644 --- a/scripts/workers/submission_worker.py +++ b/scripts/workers/submission_worker.py @@ -335,12 +335,17 @@ def extract_challenge_data(challenge, phases): challenge_data_directory, "requirements.txt" ) if os.path.isfile(requirements_location): + logger.warning( + "Installing custom requirements for challenge {}. " + "This may pose a security risk.".format(challenge.id) + ) subprocess.check_output( [ sys.executable, "-m", "pip", "install", + "--no-deps", "-r", requirements_location, ] @@ -892,7 +897,7 @@ def main(): q_params["pk"] = challenge_pk if settings.DEBUG or settings.TEST: - if eval(LIMIT_CONCURRENT_SUBMISSION_PROCESSING): + if LIMIT_CONCURRENT_SUBMISSION_PROCESSING and LIMIT_CONCURRENT_SUBMISSION_PROCESSING.lower() in ("true", "1", "yes"): if not challenge_pk: logger.exception( "{} Please add CHALLENGE_PK for the challenge to be loaded in the docker.env file.".format( @@ -926,7 +931,7 @@ def main(): ): continue if settings.DEBUG or settings.TEST: - if eval(LIMIT_CONCURRENT_SUBMISSION_PROCESSING): + if LIMIT_CONCURRENT_SUBMISSION_PROCESSING and LIMIT_CONCURRENT_SUBMISSION_PROCESSING.lower() in ("true", "1", "yes"): current_running_submissions_count = ( Submission.objects.filter( challenge_phase__challenge=challenge.id,