Repository: CTFd/CTFd

Issue:
Hints nonfunctional unless logged in
<!--
If this is a bug report please fill out the template below.

If this is a feature request please describe the behavior that you'd like to see.
-->

**Environment**:

- CTFd Version/Commit: 3.5.0
- Operating System: any
- Web Browser and Version: any

**What happened?**
Clicking on a hint while being signed out does absolutely nothing. This makes it seem like the website is nonfunctional.

**What did you expect to happen?**
Hints with no cost should be viewable without being signed in. If the hint has a cost, an error/alert telling the user to sign in should be displayed.

**How to reproduce your issue**
Have a CTFd instance with the challenges page public, attempt to click on a challenge hint.

Comments:

Patch:
diff --git a/CTFd/api/v1/hints.py b/CTFd/api/v1/hints.py
index 0ee60fa3..8d347fd9 100644
--- a/CTFd/api/v1/hints.py
+++ b/CTFd/api/v1/hints.py
@@ -9,7 +9,8 @@ from CTFd.api.v1.schemas import APIDetailedSuccessResponse, APIListSuccessRespon
 from CTFd.constants import RawEnum
 from CTFd.models import Hints, HintUnlocks, db
 from CTFd.schemas.hints import HintSchema
-from CTFd.utils.decorators import admins_only, authed_only, during_ctf_time_only
+from CTFd.utils.decorators import admins_only, during_ctf_time_only
+from CTFd.utils.decorators.visibility import check_challenge_visibility
 from CTFd.utils.helpers.models import build_model_filters
 from CTFd.utils.user import get_current_user, is_admin
 
@@ -105,7 +106,7 @@ class HintList(Resource):
 @hints_namespace.route("/<hint_id>")
 class Hint(Resource):
     @during_ctf_time_only
-    @authed_only
+    @check_challenge_visibility
     @hints_namespace.doc(
         description="Endpoint to get a specific Hint object",
         responses={
@@ -117,11 +118,23 @@ class Hint(Resource):
         },
     )
     def get(self, hint_id):
-        user = get_current_user()
         hint = Hints.query.filter_by(id=hint_id).first_or_404()
+        user = get_current_user()
+
+        # We allow public accessing of hints if challenges are visible and there is no cost or prerequisites
+        # If there is a cost or a prereq we should block the user from seeing the hint
+        if user is None:
+            if hint.cost or hint.prerequisites:
+                return (
+                    {
+                        "success": False,
+                        "errors": {"cost": ["You must login to unlock this hint"]},
+                    },
+                    403,
+                )
 
-        if hint.requirements:
-            requirements = hint.requirements.get("prerequisites", [])
+        if hint.prerequisites:
+            requirements = hint.prerequisites
 
             # Get the IDs of all hints that the user has unlocked
             all_unlocks = HintUnlocks.query.filter_by(account_id=user.account_id).all()
diff --git a/CTFd/models/__init__.py b/CTFd/models/__init__.py
index 713bfc3f..45f9d21b 100644
--- a/CTFd/models/__init__.py
+++ b/CTFd/models/__init__.py
@@ -181,6 +181,12 @@ class Hints(db.Model):
 
         return markup(build_markdown(self.content))
 
+    @property
+    def prerequisites(self):
+        if self.requirements:
+            return self.requirements.get("prerequisites", [])
+        return []
+
     def __init__(self, *args, **kwargs):
         super(Hints, self).__init__(**kwargs)
 


Test patch:
diff --git a/tests/api/v1/user/test_hints.py b/tests/api/v1/user/test_hints.py
index ad69882c..e7609135 100644
--- a/tests/api/v1/user/test_hints.py
+++ b/tests/api/v1/user/test_hints.py
@@ -3,6 +3,7 @@
 
 from freezegun import freeze_time
 
+from CTFd.models import Hints
 from CTFd.utils import set_config
 from tests.helpers import (
     create_ctfd,
@@ -180,3 +181,90 @@ def test_api_hint_admin_access():
         r_admin = admin.delete("/api/v1/hints/1", json="")
         assert r_admin.status_code == 200
     destroy_ctfd(app)
+
+
+def test_api_hints_accessible_public():
+    """Test that hints with no cost and no prerequsites can be viewed publicy"""
+    app = create_ctfd()
+    with app.app_context():
+        # Set challenges to be visible publicly
+        set_config("challenge_visibility", "public")
+
+        register_user(app)
+        chal = gen_challenge(app.db)
+        gen_hint(
+            app.db, chal.id, content="This is a free hint", cost=0, type="standard"
+        )
+        gen_hint(
+            app.db, chal.id, content="This is a private hint", cost=1, type="standard"
+        )
+        gen_hint(
+            app.db, chal.id, content="This is a private hint", cost=1, type="standard"
+        )
+        hint = Hints.query.filter_by(id=3).first()
+        hint.requirements = {"prerequisites": [2]}
+        app.db.session.commit()
+
+        with app.test_client() as non_logged_in_user:
+            r = non_logged_in_user.get("/api/v1/hints/1")
+            hint = r.get_json()["data"]
+            assert hint["content"] == "This is a free hint"
+
+            r = non_logged_in_user.get("/api/v1/hints/2")
+            assert r.status_code == 403
+            errors = r.get_json()["errors"]
+            assert errors == {"cost": ["You must login to unlock this hint"]}
+
+            r = non_logged_in_user.get("/api/v1/hints/3")
+            assert r.status_code == 403
+            errors = r.get_json()["errors"]
+            assert errors == {"cost": ["You must login to unlock this hint"]}
+
+            r = non_logged_in_user.post(
+                "/api/v1/unlocks", json={"target": 2, "type": "hints"}
+            )
+            assert r.status_code == 403
+
+        # Set challenges to be visible to only authed
+        set_config("challenge_visibility", "private")
+
+        # Free hints no longer visible to unauthed
+        with app.test_client() as non_logged_in_user:
+            r = non_logged_in_user.get("/api/v1/hints/1", json="")
+            assert r.status_code == 403
+
+        # Verify existing hint behavior for authed users
+        with login_as_user(app) as client:
+            r = client.get("/api/v1/hints/1")
+            hint = r.get_json()["data"]
+            assert hint["content"] == "This is a free hint"
+
+            r = client.get("/api/v1/hints/2")
+            assert r.status_code == 200
+            assert "content" not in r.get_json()["data"]
+
+            r = client.get("/api/v1/hints/3")
+            assert r.status_code == 403
+
+            gen_award(app.db, 2)
+
+            # Haven't unlocked the prereq hint
+            r = client.get("/api/v1/hints/3")
+            assert r.status_code == 403
+
+            # Unlock the prereq
+            r = client.post("/api/v1/unlocks", json={"target": 2, "type": "hints"})
+            assert r.status_code == 200
+            r = client.get("/api/v1/hints/2")
+            assert r.status_code == 200
+
+            # Attempt to unlock again but dont have the points
+            r = client.get("/api/v1/hints/3")
+            assert r.status_code == 200
+            assert "content" not in r.get_json()["data"]
+
+            r = client.post("/api/v1/unlocks", json={"target": 3, "type": "hints"})
+            assert r.status_code == 200
+            r = client.get("/api/v1/hints/3")
+            assert r.status_code == 200
+            assert "content" in r.get_json()["data"]