Repository: CTFd/CTFd

Issue:
Not possible to download files as anonymous user
**Environment**:

  - CTFd Version/Commit: 2.0.0 from master
  - Operating System: Debian Stretch, Ubuntu 18.04, ...
  - Web Browser and Version: Firefox 63

**What happened?**

* In admin, set visibility of challenges to public
* Logout
* Open challenge view (`/challenges`)
* Click on a challenge with a file
* Try to download the file (`/files/c378d661d2c9e103c4409cd4c92d801f/alice_bob.py`
* => Error 403

**What did you expect to happen?**

* ...
* Click on a challenge with a file
* Try to download the file
* File downloads

**How to reproduce your issue**

_see above_

**Any associated stack traces or error logs**

_none_


Discussion:
I think the reason is this `if` (or rather the corresponding `else`): 
https://github.com/CTFd/CTFd/blob/master/CTFd/views.py#L191

We fixed this in our fork by removing this `else` and adding a `@check_challenge_visibility` decorator to the function instead.
I just realized that there is a test for this: 
https://github.com/CTFd/CTFd/blob/master/tests/users/test_views.py#L178

So it appears this is intended behavior.  But I am not sure why. If i enable a public challenge list, I also want user's to be able to download files.

I propose to declare this a bug in the "specification" then.

An alternative is to create a separate config switch for that. 
Tests for the way I understood that functionality: 

```python
            set_config('challenge_visibility', 'public')
            # Unauthed user should return 200
            with app.test_client() as client:
                r = client.get(url)

                assert r.status_code == 200
                assert r.get_data(as_text=True) == 'testing file load'

            set_config('challenge_visibility', 'private')
            # Unauthed user should return 403
            with app.test_client() as client:
                r = client.get(url)

                assert r.status_code == 403 or r.status_code == 302
                assert r.get_data(as_text=True) != 'testing file load'
```
I think this is an oversight. Thanks for pointing out the issue!
By the way I don't think wrapping the files handler in check_challenge_visibility is the best solution because that handler also deals with file downloads for regular non-challenge files. 
Good point. 

So it probably makes more sense to include `get_config('challenge_visibility')` somewhere in the check (for challenge files). 

Like changing line 190 to something like this, and no further changes:

`if f.type == 'challenge' and get_config('challenge_visibility') == 'private':`
The function you probably want is:

```
from CTFd.utils.config.visibility import challenges_visible
```

I'll have a fix ready soon :) 

Resolving patch:
diff --git a/CTFd/api/v1/challenges.py b/CTFd/api/v1/challenges.py
--- a/CTFd/api/v1/challenges.py
+++ b/CTFd/api/v1/challenges.py
@@ -27,7 +27,7 @@
 )
 from CTFd.cache import cache, clear_standings
 from CTFd.utils.scores import get_standings
-from CTFd.utils.config.visibility import scores_visible, accounts_visible
+from CTFd.utils.config.visibility import scores_visible, accounts_visible, challenges_visible
 from CTFd.utils.user import get_current_user, is_admin, authed
 from CTFd.utils.modes import get_model
 from CTFd.schemas.tags import TagSchema
@@ -72,10 +72,10 @@ def get(self):
         response = []
         tag_schema = TagSchema(view='user', many=True)
         for challenge in challenges:
-            requirements = challenge.requirements
-            if requirements:
-                prereqs = set(requirements.get('prerequisites', []))
-                anonymize = requirements.get('anonymize')
+            if challenge.requirements:
+                requirements = challenge.requirements.get('prerequisites', [])
+                anonymize = challenge.requirements.get('anonymize')
+                prereqs = set(requirements)
                 if solve_ids >= prereqs:
                     pass
                 else:
@@ -160,19 +160,22 @@ def get(self, challenge_id):
 
         chal_class = get_chal_class(chal.type)
 
-        requirements = chal.requirements
-        if requirements:
-            if current_user.authed():
+        if chal.requirements:
+            requirements = chal.requirements.get('prerequisites', [])
+            anonymize = chal.requirements.get('anonymize')
+            if challenges_visible():
                 user = get_current_user()
-                solve_ids = Solves.query \
-                    .with_entities(Solves.challenge_id) \
-                    .filter_by(account_id=user.account_id) \
-                    .order_by(Solves.challenge_id.asc()) \
-                    .all()
+                if user:
+                    solve_ids = Solves.query \
+                        .with_entities(Solves.challenge_id) \
+                        .filter_by(account_id=user.account_id) \
+                        .order_by(Solves.challenge_id.asc()) \
+                        .all()
+                else:
+                    # We need to handle the case where a user is viewing challenges anonymously
+                    solve_ids = []
                 solve_ids = set([value for value, in solve_ids])
-
-                prereqs = set(requirements.get('prerequisites', []))
-                anonymize = requirements.get('anonymize')
+                prereqs = set(requirements)
                 if solve_ids >= prereqs or is_admin():
                     pass
                 else:
@@ -313,16 +316,15 @@ def post(self):
         if challenge.state == 'locked':
             abort(403)
 
-        requirements = challenge.requirements
-        if requirements:
+        if challenge.requirements:
+            requirements = challenge.requirements.get('prerequisites', [])
             solve_ids = Solves.query \
                 .with_entities(Solves.challenge_id) \
                 .filter_by(account_id=user.account_id) \
                 .order_by(Solves.challenge_id.asc()) \
                 .all()
             solve_ids = set([solve_id for solve_id, in solve_ids])
-
-            prereqs = set(requirements.get('prerequisites', []))
+            prereqs = set(requirements)
             if solve_ids >= prereqs:
                 pass
             else:
diff --git a/CTFd/views.py b/CTFd/views.py
--- a/CTFd/views.py
+++ b/CTFd/views.py
@@ -9,6 +9,7 @@
 from CTFd.utils import config
 from CTFd.utils.uploads import get_uploader
 from CTFd.utils.config.pages import get_page
+from CTFd.utils.config.visibility import challenges_visible
 from CTFd.utils.security.auth import login_user
 from CTFd.utils.security.csrf import generate_nonce
 from CTFd.utils import user as current_user
@@ -188,7 +189,7 @@ def files(path):
     """
     f = Files.query.filter_by(location=path).first_or_404()
     if f.type == 'challenge':
-        if current_user.authed():
+        if challenges_visible():
             if current_user.is_admin() is False:
                 if not ctftime():
                     abort(403)


Test patch:
diff --git a/tests/users/test_views.py b/tests/users/test_views.py
--- a/tests/users/test_views.py
+++ b/tests/users/test_views.py
@@ -171,14 +171,23 @@ def test_user_can_access_files():
             f = gen_file(app.db, location=model_path, challenge_id=chal_id)
             url = url_for('views.files', path=model_path)
 
-            # Unauthed user should return 403
+            # Unauthed user should be able to see challenges if challenges are public
+            set_config('challenge_visibility', 'public')
+            with app.test_client() as client:
+                r = client.get(url)
+
+                assert r.status_code == 200
+                assert r.get_data(as_text=True) == 'testing file load'
+
+            # Unauthed user should be able to see challenges if challenges are private
+            set_config('challenge_visibility', 'private')
             with app.test_client() as client:
                 r = client.get(url)
 
                 assert r.status_code == 403
                 assert r.get_data(as_text=True) != 'testing file load'
 
-            # Authed user should be able to see the files
+            # Authed user should be able to see files if challenges are private
             register_user(app)
             client = login_as_user(app)
             r = client.get(url)
@@ -187,18 +196,26 @@ def test_user_can_access_files():
 
             with freeze_time("2017-10-7"):
                 set_config('end', '1507262400')  # Friday, October 6, 2017 12:00:00 AM GMT-04:00 DST
-
-                # Authed users shouldn't be able to see files if the CTF hasn't started
-                client = login_as_user(app)
-                r = client.get(url)
-                assert r.status_code == 403
-                assert r.get_data(as_text=True) != 'testing file load'
-
-                # Admins should be able to see files if the CTF hasn't started
-                admin = login_as_user(app, "admin")
-                r = admin.get(url)
-                assert r.status_code == 200
-                assert r.get_data(as_text=True) == 'testing file load'
+                for v in ('public', 'private'):
+                    set_config('challenge_visibility', v)
+
+                    # Unauthed users shouldn't be able to see files if the CTF hasn't started
+                    client = app.test_client()
+                    r = client.get(url)
+                    assert r.status_code == 403
+                    assert r.get_data(as_text=True) != 'testing file load'
+
+                    # Authed users shouldn't be able to see files if the CTF hasn't started
+                    client = login_as_user(app)
+                    r = client.get(url)
+                    assert r.status_code == 403
+                    assert r.get_data(as_text=True) != 'testing file load'
+
+                    # Admins should be able to see files if the CTF hasn't started
+                    admin = login_as_user(app, "admin")
+                    r = admin.get(url)
+                    assert r.status_code == 200
+                    assert r.get_data(as_text=True) == 'testing file load'
         finally:
             rmdir(directory)
     destroy_ctfd(app)