diff --git a/.travis.yml b/.travis.yml index 99ad7ade..e4ef6a3c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -22,8 +22,8 @@ before_install: - sudo mysql_upgrade -u root -ppassword - sudo service mysql restart - sudo rm -f /etc/boto.cfg - - export AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE - - export AWS_ACCESS_KEY_ID=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + - export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE + - export AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY install: - pip install -r development.txt before_script: diff --git a/CTFd/__init__.py b/CTFd/__init__.py index 812d6e08..7be512fd 100644 --- a/CTFd/__init__.py +++ b/CTFd/__init__.py @@ -2,7 +2,8 @@ import sys import os from distutils.version import StrictVersion -from flask import Flask +from flask import Flask, Request +from werkzeug.utils import cached_property from werkzeug.contrib.fixers import ProxyFix from jinja2 import FileSystemLoader from jinja2.sandbox import SandboxedEnvironment @@ -24,11 +25,25 @@ if sys.version_info[0] < 3: __version__ = '2.0.1' +class CTFdRequest(Request): + @cached_property + def path(self): + """ + Hijack the original Flask request path because it does not account for subdirectory deployments in an intuitive + manner. We append script_root so that the path always points to the full path as seen in the browser. + e.g. /subdirectory/path/route vs /path/route + + :return: string + """ + return self.script_root + super(CTFdRequest, self).path + + class CTFdFlask(Flask): def __init__(self, *args, **kwargs): """Overriden Jinja constructor setting a custom jinja_environment""" self.jinja_environment = SandboxedBaseEnvironment self.session_interface = CachingSessionInterface(key_prefix='session') + self.request_class = CTFdRequest Flask.__init__(self, *args, **kwargs) def create_jinja_environment(self): diff --git a/CTFd/utils/decorators/__init__.py b/CTFd/utils/decorators/__init__.py index e97c5476..dcc6799c 100644 --- a/CTFd/utils/decorators/__init__.py +++ b/CTFd/utils/decorators/__init__.py @@ -39,7 +39,7 @@ def require_authentication_if_config(config_key): def __require_authentication_if_config(*args, **kwargs): value = get_config(config_key) if value and current_user.authed(): - return redirect(url_for('auth.login', next=request.path)) + return redirect(url_for('auth.login', next=request.full_path)) else: return f(*args, **kwargs) return __require_authentication_if_config @@ -82,7 +82,7 @@ def authed_only(f): if request.content_type == 'application/json': abort(403) else: - return redirect(url_for('auth.login', next=request.path)) + return redirect(url_for('auth.login', next=request.full_path)) return authed_only_wrapper @@ -102,7 +102,7 @@ def admins_only(f): if request.content_type == 'application/json': abort(403) else: - return redirect(url_for('auth.login', next=request.path)) + return redirect(url_for('auth.login', next=request.full_path)) return admins_only_wrapper @@ -113,7 +113,7 @@ def require_team(f): if get_config('user_mode') == TEAMS_MODE: team = get_current_team() if team is None: - return redirect(url_for('teams.private', next=request.path)) + return redirect(url_for('teams.private', next=request.full_path)) return f(*args, **kwargs) return require_team_wrapper diff --git a/CTFd/utils/decorators/visibility.py b/CTFd/utils/decorators/visibility.py index 8e5d1796..248246c3 100644 --- a/CTFd/utils/decorators/visibility.py +++ b/CTFd/utils/decorators/visibility.py @@ -18,7 +18,7 @@ def check_score_visibility(f): if request.content_type == 'application/json': abort(403) else: - return redirect(url_for('auth.login', next=request.path)) + return redirect(url_for('auth.login', next=request.full_path)) elif v == 'hidden': return render_template('errors/403.html', error='Scores are currently hidden'), 403 @@ -45,7 +45,7 @@ def check_challenge_visibility(f): if request.content_type == 'application/json': abort(403) else: - return redirect(url_for('auth.login', next=request.path)) + return redirect(url_for('auth.login', next=request.full_path)) return _check_challenge_visibility @@ -63,7 +63,7 @@ def check_account_visibility(f): if request.content_type == 'application/json': abort(403) else: - return redirect(url_for('auth.login', next=request.path)) + return redirect(url_for('auth.login', next=request.full_path)) elif v == 'admins': if is_admin(): diff --git a/CTFd/utils/initialization/__init__.py b/CTFd/utils/initialization/__init__.py index 647a499d..4903a276 100644 --- a/CTFd/utils/initialization/__init__.py +++ b/CTFd/utils/initialization/__init__.py @@ -1,4 +1,5 @@ -from flask import current_app as app, request, session, redirect, url_for, abort, render_template +from flask import Flask, current_app as app, request, session, redirect, url_for, abort, render_template +from werkzeug.wsgi import DispatcherMiddleware from CTFd.models import db, Tracking from CTFd.utils import markdown, get_config @@ -72,7 +73,7 @@ def init_request_processors(app): @app.before_request def needs_setup(): - if request.path == '/setup' or request.path.startswith('/themes'): + if request.path == url_for('views.setup') or request.path.startswith('/themes'): return if not is_setup(): return redirect(url_for('views.setup')) @@ -122,3 +123,14 @@ def init_request_processors(app): if request.content_type != 'application/json': if session['nonce'] != request.form.get('nonce'): abort(403) + + application_root = app.config.get('APPLICATION_ROOT') + if application_root != '/': + @app.before_request + def force_subdirectory_redirect(): + if request.path.startswith(application_root) is False: + return redirect(application_root + request.script_root + request.full_path) + + app.wsgi_app = DispatcherMiddleware(app.wsgi_app, { + application_root: app, + }) diff --git a/CTFd/views.py b/CTFd/views.py index e3b8e186..40142d4c 100644 --- a/CTFd/views.py +++ b/CTFd/views.py @@ -174,7 +174,7 @@ def static_html(route): abort(404) else: if page.auth_required and authed() is False: - return redirect(url_for('auth.login', next=request.path)) + return redirect(url_for('auth.login', next=request.full_path)) return render_template('page.html', content=markdown(page.content)) diff --git a/tests/helpers.py b/tests/helpers.py index ef34a538..85e45041 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -20,9 +20,13 @@ else: FakeRequest = namedtuple('FakeRequest', ['form']) -def create_ctfd(ctf_name="CTFd", name="admin", email="admin@ctfd.io", password="password", user_mode="users", setup=True, enable_plugins=False): +def create_ctfd(ctf_name="CTFd", name="admin", email="admin@ctfd.io", password="password", user_mode="users", setup=True, enable_plugins=False, application_root='/'): if enable_plugins: TestingConfig.SAFE_MODE = False + else: + TestingConfig.SAFE_MODE = True + + TestingConfig.APPLICATION_ROOT = application_root app = create_app(TestingConfig) diff --git a/tests/test_themes.py b/tests/test_themes.py index c93207f1..2b606d65 100644 --- a/tests/test_themes.py +++ b/tests/test_themes.py @@ -3,6 +3,9 @@ from tests.helpers import * from jinja2.sandbox import SecurityError +from werkzeug.test import Client +from werkzeug.wrappers import BaseResponse +from flask import request def test_themes_run_in_sandbox(): @@ -62,3 +65,72 @@ def test_custom_css(): r = admin.get('/static/user.css') assert r.get_data(as_text=True) == css_value2 destroy_ctfd(app) + + +def test_that_ctfd_can_be_deployed_in_subdir(): + """Test that CTFd can be deployed in a subdirectory""" + # This test is quite complicated. I do not suggest modifying it haphazardly. + # Flask is automatically inserting the APPLICATION_ROOT into the + # test urls which means when we hit /setup we hit /ctf/setup. + # You can use the raw Werkzeug client to bypass this as we do below. + app = create_ctfd(setup=False, application_root='/ctf') + with app.app_context(): + with app.test_client() as client: + r = client.get('/') + assert r.status_code == 302 + assert r.location == 'http://localhost/ctf/setup' + + r = client.get('/setup') + with client.session_transaction() as sess: + data = { + "ctf_name": 'name', + "name": 'admin', + "email": 'admin@ctfd.io', + "password": 'password', + "user_mode": 'users', + "nonce": sess.get('nonce') + } + r = client.post('/setup', data=data) + assert r.status_code == 302 + assert r.location == 'http://localhost/ctf/' + + c = Client(app) + app_iter, status, headers = c.get('/') + headers = dict(headers) + assert status == '302 FOUND' + assert headers['Location'] == 'http://localhost/ctf/' + + r = client.get('/challenges') + assert r.status_code == 200 + assert "Challenges" in r.get_data(as_text=True) + + r = client.get('/scoreboard') + assert r.status_code == 200 + assert "Scoreboard" in r.get_data(as_text=True) + destroy_ctfd(app) + + +def test_that_request_path_hijacking_works_properly(): + """Test that the CTFdRequest subclass correctly mimics the Flask Request when it should""" + app = create_ctfd(setup=False, application_root='/ctf') + assert app.request_class.__name__ == 'CTFdRequest' + with app.app_context(): + # Despite loading /challenges request.path should actually be /ctf/challenges because we are + # preprending script_root and the test context already accounts for the application_root + with app.test_request_context('/challenges'): + assert request.path == '/ctf/challenges' + destroy_ctfd(app) + + app = create_ctfd() + assert app.request_class.__name__ == 'CTFdRequest' + with app.app_context(): + # Under normal circumstances we should be an exact clone of BaseRequest + with app.test_request_context('/challenges'): + assert request.path == '/challenges' + + from flask import Flask + test_app = Flask('test') + assert test_app.request_class.__name__ == 'Request' + with test_app.test_request_context('/challenges'): + assert request.path == '/challenges' + destroy_ctfd(app) diff --git a/tests/users/test_auth.py b/tests/users/test_auth.py index 201604ba..ddcce77d 100644 --- a/tests/users/test_auth.py +++ b/tests/users/test_auth.py @@ -116,7 +116,7 @@ def test_user_get_logout(): client = login_as_user(app) client.get('/logout', follow_redirects=True) r = client.get('/challenges') - assert r.location == "http://localhost/login?next=%2Fchallenges" + assert r.location == "http://localhost/login?next=%2Fchallenges%3F" assert r.status_code == 302 destroy_ctfd(app) diff --git a/tests/users/test_views.py b/tests/users/test_views.py index 6a49509f..319cc53b 100644 --- a/tests/users/test_views.py +++ b/tests/users/test_views.py @@ -56,7 +56,7 @@ def test_page_requiring_auth(): with app.test_client() as client: r = client.get('/this-is-a-route') assert r.status_code == 302 - assert r.location == 'http://localhost/login?next=%2Fthis-is-a-route' + assert r.location == 'http://localhost/login?next=%2Fthis-is-a-route%3F' register_user(app) client = login_as_user(app)