Repository: CTFd/CTFd
Pull request #2771 (2025-07-05 09:20:42)

Allow max attempts to behave as a timeout instead of a lockout
* Allow max attempts to behave as a timeout instead of a lockout


Patch:
diff --git a/CTFd/api/v1/challenges.py b/CTFd/api/v1/challenges.py
index c5788a0a..78de296d 100644
--- a/CTFd/api/v1/challenges.py
+++ b/CTFd/api/v1/challenges.py
@@ -1,3 +1,5 @@
+import math
+from datetime import datetime, timedelta
 from typing import List  # noqa: I001
 
 from flask import abort, render_template, request, url_for
@@ -429,9 +431,19 @@ class Challenge(Resource):
 
         if authed():
             # Get current attempts for the user
-            attempts = Submissions.query.filter_by(
+            attempts_query = Submissions.query.filter_by(
                 account_id=user.account_id, challenge_id=challenge_id
-            ).count()
+            )
+            max_attempts_behavior = get_config("max_attempts_behavior", "lockout")
+            if max_attempts_behavior == "timeout":
+                max_attempts_timeout = int(get_config("max_attempts_timeout", 300))
+                timeout_delta = datetime.utcnow() - timedelta(
+                    seconds=max_attempts_timeout
+                )
+                attempts_query = attempts_query.filter(
+                    Submissions.date >= timeout_delta
+                )
+            attempts = attempts_query.count()
         else:
             attempts = 0
 
@@ -558,10 +570,6 @@ class ChallengeAttempt(Resource):
         if config.is_teams_mode() and team is None:
             abort(403)
 
-        fails = Fails.query.filter_by(
-            account_id=user.account_id, challenge_id=challenge_id
-        ).count()
-
         challenge = Challenges.query.filter_by(id=challenge_id).first_or_404()
 
         if challenge.state == "hidden":
@@ -622,22 +630,40 @@ class ChallengeAttempt(Resource):
         solves = Solves.query.filter_by(
             account_id=user.account_id, challenge_id=challenge_id
         ).first()
+        # We default fails to 0 as it's not needed unless we are working with max attempts
+        fails = 0
 
         # Challenge not solved yet
         if not solves:
             # Hit max attempts
             max_tries = challenge.max_attempts
-            if max_tries and fails >= max_tries > 0:
-                return (
-                    {
-                        "success": True,
-                        "data": {
-                            "status": "incorrect",
-                            "message": "You have 0 tries remaining",
-                        },
-                    },
-                    403,
+            if max_tries and max_tries > 0:
+                max_attempts_behavior = get_config("max_attempts_behavior", "lockout")
+                fails_query = Fails.query.filter_by(
+                    account_id=user.account_id, challenge_id=challenge_id
                 )
+                if max_attempts_behavior == "timeout":  # Use timeout behavior
+                    max_attempts_timeout = int(get_config("max_attempts_timeout", 300))
+                    timeout_delta = datetime.utcnow() - timedelta(
+                        seconds=max_attempts_timeout
+                    )
+                    fails = fails_query.filter(Fails.date >= timeout_delta).count()
+                    response = f"Not accepted. Try again in {math.ceil(max_attempts_timeout / 60)} minutes"
+                else:  # Use lockout behavior
+                    fails = fails_query.count()
+                    response = "Not accepted. You have 0 tries remaining"
+
+                if fails >= max_tries:
+                    return (
+                        {
+                            "success": True,
+                            "data": {
+                                "status": "ratelimited",
+                                "message": response,
+                            },
+                        },
+                        403,
+                    )
 
             status, message = chal_class.attempt(challenge, request)
             if status:  # The challenge plugin says the input is right
@@ -684,13 +710,23 @@ class ChallengeAttempt(Resource):
                     # Add a punctuation mark if there isn't one
                     if message[-1] not in "!().;?[]{}":
                         message = message + "."
+                    message = "{} You have {} {} remaining.".format(
+                        message, attempts_left, tries_str
+                    )
+                    if attempts_left == 0:
+                        max_attempts_behavior = get_config(
+                            "max_attempts_behavior", "lockout"
+                        )
+                        if max_attempts_behavior == "timeout":
+                            max_attempts_timeout = int(
+                                get_config("max_attempts_timeout", 300)
+                            )
+                            message += f" Try again in {math.ceil(max_attempts_timeout / 60)} minutes."
                     return {
                         "success": True,
                         "data": {
                             "status": "incorrect",
-                            "message": "{} You have {} {} remaining.".format(
-                                message, attempts_left, tries_str
-                            ),
+                            "message": message,
                         },
                     }
                 else:
diff --git a/CTFd/forms/config.py b/CTFd/forms/config.py
index 7802a972..cee4d292 100644
--- a/CTFd/forms/config.py
+++ b/CTFd/forms/config.py
@@ -139,6 +139,17 @@ class LegalSettingsForm(BaseForm):
 
 
 class ChallengeSettingsForm(BaseForm):
+    max_attempts_behavior = SelectField(
+        "Max Attempts Behavior",
+        description="Set Max Attempts behavior to be a lockout or a timeout",
+        choices=[("lockout", "lockout"), ("timeout", "timeout")],
+        default="lockout",
+    )
+    max_attempts_timeout = IntegerField(
+        "Max Attempts Timeout Duration",
+        description="How long the timeout lasts in seconds for max attempts (if set to timeout). Default is 300 seconds",
+        default=300,
+    )
     hints_free_public_access = SelectField(
         "Hints Free Public Access",
         description="Control whether users must be logged in to see free hints (hints without cost or requirements)",
diff --git a/CTFd/themes/admin/templates/configs/challenges.html b/CTFd/themes/admin/templates/configs/challenges.html
index 08eff4db..a917767b 100644
--- a/CTFd/themes/admin/templates/configs/challenges.html
+++ b/CTFd/themes/admin/templates/configs/challenges.html
@@ -1,11 +1,29 @@
 <div role="tabpanel" class="tab-pane config-section" id="challenges">
     {% set hints_free_public_access = "true" if hints_free_public_access == True else "false" %}
+	{% set max_attempts_behavior = "timeout" if max_attempts_behavior == "timeout" else "lockout" %}
+	{% set max_attempts_timeout = 300 if max_attempts_timeout is not defined else max_attempts_timeout %}
 	{% 
 		with form = Forms.config.ChallengeSettingsForm(
-			hints_free_public_access=hints_free_public_access,
+			hints_free_public_access=hints_free_public_access, 
+			max_attempts_behavior=max_attempts_behavior,
+			max_attempts_timeout=max_attempts_timeout,
 		) 
 	%}
 	<form method="POST" autocomplete="off" class="w-100">
+		<div class="form-group">
+			{{ form.max_attempts_behavior.label }}
+			{{ form.max_attempts_behavior(class="form-control custom-select") }}
+			<small class="form-text text-muted">
+				{{ form.max_attempts_behavior.description }}
+			</small>
+		</div>
+		<div class="form-group">
+			{{ form.max_attempts_timeout.label }}
+			{{ form.max_attempts_timeout(class="form-control custom-select") }}
+			<small class="form-text text-muted">
+				{{ form.max_attempts_timeout.description }}
+			</small>
+		</div>
 		<div class="form-group">
 			{{ form.hints_free_public_access.label }}
 			{{ form.hints_free_public_access(class="form-control custom-select") }}


Test patch:
diff --git a/tests/users/test_challenges.py b/tests/users/test_challenges.py
index 911caf6e..a94320d8 100644
--- a/tests/users/test_challenges.py
+++ b/tests/users/test_challenges.py
@@ -1,5 +1,6 @@
 #!/usr/bin/env python
 # -*- coding: utf-8 -*-
+from datetime import timedelta
 
 from freezegun import freeze_time
 
@@ -264,14 +265,65 @@ def test_challenges_with_max_attempts():
         assert r.status_code == 403
 
         resp = r.get_json()["data"]
-        assert resp.get("status") == "incorrect"
-        assert resp.get("message") == "You have 0 tries remaining"
+        assert resp.get("status") == "ratelimited"
+        assert resp.get("message") == "Not accepted. You have 0 tries remaining"
 
         solves = Solves.query.count()
         assert solves == 0
     destroy_ctfd(app)
 
 
+def test_challenges_with_max_attempts_timeout_behavior():
+    """Test that users are temporarily locked out of a challenge after reaching max_attempts with timeout behavior"""
+
+    app = create_ctfd()
+    with app.app_context():
+        set_config("max_attempts_behavior", "timeout")
+        set_config("max_attempts_timeout", 300)  # 300 seconds timeout for test
+
+        register_user(app)
+        client = login_as_user(app)
+        chal = gen_challenge(app.db)
+        chal = Challenges.query.filter_by(id=chal.id).first()
+        chal_id = chal.id
+        chal.max_attempts = 2
+        app.db.session.commit()
+
+        gen_flag(app.db, challenge_id=chal.id, content="flag")
+        for _ in range(2):
+            data = {"submission": "notflag", "challenge_id": chal_id}
+            r = client.post("/api/v1/challenges/attempt", json=data)
+            assert r.status_code == 200
+
+        # Should be locked out now
+        data = {"submission": "flag", "challenge_id": chal_id}
+        r = client.post("/api/v1/challenges/attempt", json=data)
+        assert r.status_code == 403
+        resp = r.get_json()["data"]
+        assert resp.get("status") == "ratelimited"
+        assert "Not accepted. Try again in 5 minutes" in resp.get("message")
+
+        # Use freeze_time to advance time by 290 seconds
+        with freeze_time(timedelta(seconds=290)):
+            data = {"submission": "flag", "challenge_id": chal_id}
+            r = client.post("/api/v1/challenges/attempt", json=data)
+            assert r.status_code == 403
+            resp = r.get_json()["data"]
+            assert resp.get("status") == "ratelimited"
+            assert "Not accepted. Try again in 5 minutes" in resp.get("message")
+
+        # Use freeze_time to advance time by 301 seconds
+        with freeze_time(timedelta(seconds=301)):
+            data = {"submission": "flag", "challenge_id": chal_id}
+            r = client.post("/api/v1/challenges/attempt", json=data)
+            assert r.status_code == 200
+            resp = r.get_json()["data"]
+            # Should be correct now
+            assert resp.get("status") == "correct"
+            assert resp.get("message") == "Correct"
+    destroy_ctfd(app)
+
+
 def test_challenge_kpm_limit():
     """Test that users are properly ratelimited when submitting flags"""
     app = create_ctfd()