Repository: CTFd/CTFd
Pull request #2425 (2023-11-20 17:31:35)

Clarify and update hidden score implementation
There is some confusion with hidden/admins-only scoring. 

I want to clarify how we should view this type of scoring. 

* A user can see their on score even when it is hidden or admin. This is because a user can always calculate their own score by adding up all the challenges they have solved.
* Even under freeze time a user should still see their current score
* However place should be hidden under freeze time 

This updates the user & team personal profile pages and API endpoints to show an account's score even when score is hidden or admins only. 


Patch:
diff --git a/CTFd/api/v1/teams.py b/CTFd/api/v1/teams.py
index d787c443..1f528c21 100644
--- a/CTFd/api/v1/teams.py
+++ b/CTFd/api/v1/teams.py
@@ -290,8 +290,11 @@ class TeamPrivate(Resource):
         if response.errors:
             return {"success": False, "errors": response.errors}, 400
 
+        # A team can always calculate their score regardless of any setting because they can simply sum all of their challenges
+        # Therefore a team requesting their private data should be able to get their own current score
+        # However place is not something that a team can ascertain on their own so it is always gated behind freeze time
         response.data["place"] = team.place
-        response.data["score"] = team.score
+        response.data["score"] = team.get_score(admin=True)
         return {"success": True, "data": response.data}
 
     @authed_only
diff --git a/CTFd/api/v1/users.py b/CTFd/api/v1/users.py
index 762aeb77..6258c2dd 100644
--- a/CTFd/api/v1/users.py
+++ b/CTFd/api/v1/users.py
@@ -302,8 +302,13 @@ class UserPrivate(Resource):
     def get(self):
         user = get_current_user()
         response = UserSchema("self").dump(user).data
+
+        # A user can always calculate their score regardless of any setting because they can simply sum all of their challenges
+        # Therefore a user requesting their private data should be able to get their own current score
+        # However place is not something that a user can ascertain on their own so it is always gated behind freeze time
         response["place"] = user.place
-        response["score"] = user.score
+        response["score"] = user.get_score(admin=True)
+
         return {"success": True, "data": response}
 
     @authed_only
diff --git a/CTFd/teams.py b/CTFd/teams.py
index 09372184..25834328 100644
--- a/CTFd/teams.py
+++ b/CTFd/teams.py
@@ -334,7 +334,7 @@ def private():
     awards = team.get_awards()
 
     place = team.place
-    score = team.score
+    score = team.get_score(admin=True)
 
     if config.is_scoreboard_frozen():
         infos.append("Scoreboard has been frozen")
diff --git a/CTFd/themes/core/templates/users/private.html b/CTFd/themes/core/templates/users/private.html
index 9120100e..5ba09cc9 100644
--- a/CTFd/themes/core/templates/users/private.html
+++ b/CTFd/themes/core/templates/users/private.html
@@ -52,9 +52,7 @@
 					{% endif %}
 				</h2>
 				<h2 class="text-center">
-					{% if account.place %}
-						{{ account.score }} <small>points</small>
-					{% endif %}
+					{{ account.get_score(admin=True) }} <small>points</small>
 				</h2>
 			</div>
 


Test patch:
diff --git a/tests/api/v1/teams/test_scoring.py b/tests/api/v1/teams/test_scoring.py
index 6f39880a..688521c3 100644
--- a/tests/api/v1/teams/test_scoring.py
+++ b/tests/api/v1/teams/test_scoring.py
@@ -21,28 +21,33 @@ def test_api_team_place_score_hidden_if_scores_hidden():
             r = client.get("/api/v1/teams/me", json="")
             resp = r.get_json()
             assert resp["data"]["place"] == "1st"
-            assert resp["data"]["score"] is not None
+            assert resp["data"]["score"] == 100
 
         set_config("score_visibility", "hidden")
         with login_as_user(app, name=u.name) as client:
             r = client.get("/api/v1/teams/me", json="")
             resp = r.get_json()
+            # Teams can see their own score but they cannot see their place
+            # This is because a team can always sum up their own score but
+            # they cannot determine their place without social information
             assert resp["data"]["place"] is None
-            assert resp["data"]["score"] is None
+            assert resp["data"]["score"] == 100
 
         set_config("score_visibility", "admins")
         with login_as_user(app, name=u.name) as client:
             r = client.get("/api/v1/teams/me", json="")
             resp = r.get_json()
+            # The same behavior as above applies even under admins only score mode
+            # The rationale is the same. Teams can always sum their own score
             assert resp["data"]["place"] is None
-            assert resp["data"]["score"] is None
+            assert resp["data"]["score"] == 100
 
         with login_as_user(app, name="admin") as client:
             r = client.get("/api/v1/teams/1", json="")
             resp = r.get_json()
             print(resp)
             assert resp["data"]["place"] == "1st"
-            assert resp["data"]["score"] is not None
+            assert resp["data"]["score"] == 100
     destroy_ctfd(app)
 
 
diff --git a/tests/api/v1/users/test_scoring.py b/tests/api/v1/users/test_scoring.py
index fdfea7ec..d514d0fe 100644
--- a/tests/api/v1/users/test_scoring.py
+++ b/tests/api/v1/users/test_scoring.py
@@ -24,21 +24,26 @@ def test_api_user_place_score_hidden_if_scores_hidden():
             r = client.get("/api/v1/users/me", json="")
             resp = r.get_json()
             assert resp["data"]["place"] == "1st"
-            assert resp["data"]["score"] is not None
+            assert resp["data"]["score"] == 200
 
         set_config("score_visibility", "hidden")
         with login_as_user(app, name="user") as client:
             r = client.get("/api/v1/users/me", json="")
             resp = r.get_json()
+            # Users can see their own score but they cannot see their place
+            # This is because a user can always sum up their own score but
+            # they cannot determine their place without social information
             assert resp["data"]["place"] is None
-            assert resp["data"]["score"] is None
+            assert resp["data"]["score"] == 200
 
         set_config("score_visibility", "admins")
         with login_as_user(app, name="user") as client:
             r = client.get("/api/v1/users/me", json="")
             resp = r.get_json()
+            # The same behavior as above applies even under admins only score mode
+            # The rationale is the same. Users can always sum their own score
             assert resp["data"]["place"] is None
-            assert resp["data"]["score"] is None
+            assert resp["data"]["score"] == 200
 
         with login_as_user(app, name="admin") as client:
             r = client.get("/api/v1/users/2", json="")