From 9b0b63465a1e374bd1a5c171c0731a9b4230abaa Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 16 Jun 2024 14:31:13 +0100 Subject: [PATCH 1/5] Add support for Flask.host_matching Allows configuration of Flask-DebugToolbar to support a Flask app running in `host_matching` mode. When Flask is configured this way, routes are each tied to a `host` definition. This can either be a single explicit host, or a host definition that includes variable values similar to Werkzeug path definitions, eg `.toolbar.com`. Handling explicit domains is simple, as the host can be passed through directly - and this happens automatically. If the host contains any variable parts, then calls to `url_for` need to be able to access the appropriate values for those variables. If the host string specified by the user contains arbitrary variables, it's difficult for the toolbar to know what those should evaluate to. So we restrict the possible options for the toolbar host to one of two options here: either a single explicit host, or a full-wildcard host. The wildcard host is managed internally by Flask-DebugToolbar so that we know: 1) the variable name, and 2) what value to inject for it (the current request's host). --- docs/index.rst | 7 ++ src/flask_debugtoolbar/__init__.py | 61 ++++++++++++ tests/test_toolbar.py | 150 +++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+) diff --git a/docs/index.rst b/docs/index.rst index cc3a40c..71b5f01 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -55,6 +55,13 @@ Name Description De ==================================== ===================================== ========================== ``DEBUG_TB_ENABLED`` Enable the toolbar? ``app.debug`` ``DEBUG_TB_HOSTS`` Whitelist of hosts to display toolbar any host +``DEBUG_TB_ROUTES_HOST`` The host to associate with toolbar ``None`` + routes (where its assets are served + from), or the sentinel value `*` to + serve from the same host as the + current request (ie any host). This + is only required if Flask is + configured to use `host_matching`. ``DEBUG_TB_INTERCEPT_REDIRECTS`` Should intercept redirects? ``True`` ``DEBUG_TB_PANELS`` List of module/class names of panels enable all built-in panels ``DEBUG_TB_PROFILER_ENABLED`` Enable the profiler on all requests ``False``, user-enabled diff --git a/src/flask_debugtoolbar/__init__.py b/src/flask_debugtoolbar/__init__.py index 6a0f3b4..f223f3d 100644 --- a/src/flask_debugtoolbar/__init__.py +++ b/src/flask_debugtoolbar/__init__.py @@ -59,6 +59,8 @@ class DebugToolbarExtension: def __init__(self, app: Flask | None = None) -> None: self.app = app + self.toolbar_routes_host: str | None = None + # Support threads running `flask.copy_current_request_context` without # poping toolbar during `teardown_request` self.debug_toolbars_var: ContextVar[dict[Request, DebugToolbar]] = ContextVar( @@ -97,6 +99,8 @@ class DebugToolbarExtension: "var to be set" ) + self._validate_and_configure_toolbar_routes_host(app) + DebugToolbar.load_panels(app) app.before_request(self.process_request) @@ -110,6 +114,7 @@ class DebugToolbarExtension: "/_debug_toolbar/static/", "_debug_toolbar.static", self.send_static_file, + host=self.toolbar_routes_host, ) app.register_blueprint(module, url_prefix="/_debug_toolbar/views") @@ -118,6 +123,7 @@ class DebugToolbarExtension: return { "DEBUG_TB_ENABLED": app.debug, "DEBUG_TB_HOSTS": (), + "DEBUG_TB_ROUTES_HOST": None, "DEBUG_TB_INTERCEPT_REDIRECTS": True, "DEBUG_TB_PANELS": ( "flask_debugtoolbar.panels.versions.VersionDebugPanel", @@ -135,6 +141,61 @@ class DebugToolbarExtension: "SQLALCHEMY_RECORD_QUERIES": app.debug, } + def _validate_and_configure_toolbar_routes_host(self, app: Flask) -> None: + toolbar_routes_host = app.config["DEBUG_TB_ROUTES_HOST"] + if app.url_map.host_matching and not toolbar_routes_host: + import warnings + + warnings.warn( + "Flask-DebugToolbar requires DEBUG_TB_ROUTES_HOST to be set if Flask " + "is running in `host_matching` mode. Static assets for the toolbar " + "will not be served correctly unless this is set.", + stacklevel=1, + ) + + if toolbar_routes_host: + if not app.url_map.host_matching: + raise ValueError( + "`DEBUG_TB_ROUTES_HOST` should only be set if your Flask app is " + "using `host_matching`." + ) + + if toolbar_routes_host.strip() == "*": + toolbar_routes_host = "" + elif "<" in toolbar_routes_host and ">" in toolbar_routes_host: + raise ValueError( + "`DEBUG_TB_ROUTES_HOST` must either be a host name with no " + "variables, to serve all Flask-DebugToolbar assets from a single " + "host, or `*` to match the current request's host." + ) + + # Automatically inject `toolbar_routes_host` into `url_for` calls for + # the toolbar's `send_static_file` method. + @app.url_defaults + def inject_toolbar_routes_host_if_required( + endpoint: str, values: dict[str, t.Any] + ) -> None: + if app.url_map.is_endpoint_expecting(endpoint, "toolbar_routes_host"): + values.setdefault("toolbar_routes_host", request.host) + + # Automatically strip `toolbar_routes_host` from the endpoint values so + # that the `send_static_host` method doesn't receive that parameter, + # as it's not actually required internally. + @app.url_value_preprocessor + def strip_toolbar_routes_host_from_static_endpoint( + endpoint: str | None, values: dict[str, t.Any] | None + ) -> None: + if ( + endpoint + and values + and app.url_map.is_endpoint_expecting( + endpoint, "toolbar_routes_host" + ) + ): + values.pop("toolbar_routes_host", None) + + self.toolbar_routes_host = toolbar_routes_host + def dispatch_request(self) -> t.Any: """Modified version of ``Flask.dispatch_request`` to call :meth:`process_view`. diff --git a/tests/test_toolbar.py b/tests/test_toolbar.py index f392def..7e974fb 100644 --- a/tests/test_toolbar.py +++ b/tests/test_toolbar.py @@ -1,8 +1,16 @@ from __future__ import annotations +import typing as t +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest from flask import Flask +from flask import Response from flask.testing import FlaskClient +from flask_debugtoolbar import DebugToolbarExtension + def load_app(name: str) -> FlaskClient: app: Flask = __import__(name).app @@ -15,3 +23,145 @@ def test_basic_app() -> None: index = app.get("/") assert index.status_code == 200 assert b'
Flask: + app = Flask(__name__, **app_config) + app.config["DEBUG"] = True + app.config["SECRET_KEY"] = "abc123" + + for key, value in toolbar_config.items(): + app.config[key] = value + + DebugToolbarExtension(app) + + return app + + +def test_toolbar_is_host_matching_but_flask_is_not() -> None: + with pytest.raises(ValueError) as e: + app_with_config( + app_config=dict(host_matching=False), + toolbar_config=dict( + DEBUG_TB_ENABLED=True, DEBUG_TB_ROUTES_HOST="myapp.com" + ), + ) + + assert str(e.value) == ( + "`DEBUG_TB_ROUTES_HOST` should only be set if your Flask app is " + "using `host_matching`." + ) + + +def test_flask_is_host_matching_but_toolbar_is_not() -> None: + with pytest.warns(UserWarning) as record: + app_with_config( + app_config=dict(host_matching=True, static_host="static.com"), + toolbar_config=dict(DEBUG_TB_ENABLED=True), + ) + + assert isinstance(record[0].message, UserWarning) + assert record[0].message.args[0] == ( + "Flask-DebugToolbar requires DEBUG_TB_ROUTES_HOST to be set if Flask " + "is running in `host_matching` mode. Static assets for the toolbar " + "will not be served correctly unless this is set." + ) + + +def test_toolbar_host_variables_rejected() -> None: + with pytest.raises(ValueError) as e: + app_with_config( + app_config=dict(host_matching=True, static_host="static.com"), + toolbar_config=dict( + DEBUG_TB_ENABLED=True, DEBUG_TB_ROUTES_HOST=".com" + ), + ) + + assert str(e.value) == ( + "`DEBUG_TB_ROUTES_HOST` must either be a host name with no " + "variables, to serve all Flask-DebugToolbar assets from a single " + "host, or `*` to match the current request's host." + ) + + +def test_toolbar_in_host_mode_injects_toolbar_html() -> None: + app = app_with_config( + app_config=dict(host_matching=True, static_host="static.com"), + toolbar_config=dict(DEBUG_TB_ENABLED=True, DEBUG_TB_ROUTES_HOST="myapp.com"), + ) + + @app.route("/", host="myapp.com") + def index() -> str: + return "OK" + + with app.test_client() as client: + with app.app_context(): + response = client.get("/", headers={"Host": "myapp.com"}) + assert '
None: + app = app_with_config( + app_config=dict(host_matching=True, static_host="static.com"), + toolbar_config=dict(DEBUG_TB_ENABLED=True, DEBUG_TB_ROUTES_HOST=tb_routes_host), + ) + + @app.route("/", host=request_host) + def index() -> str: + return "OK" + + with app.test_client() as client: + with app.app_context(): + response = client.get("/", headers={"Host": request_host}) + + assert ( + """""" + ) in response.text + + +@patch( + "flask.helpers.werkzeug.utils.send_from_directory", + return_value=Response(b"some-file", mimetype="text/css", status=200), +) +@pytest.mark.parametrize( + "tb_routes_host, request_host, expected_status_code", + ( + ("toolbar.com", "toolbar.com", 200), + ("toolbar.com", "myapp.com", 404), + ("toolbar.com", "static.com", 404), + ("*", "toolbar.com", 200), + ("*", "myapp.com", 200), + ("*", "static.com", 200), + ), +) +def test_toolbar_serves_assets_based_on_host_configuration( + mock_send_from_directory: MagicMock, + tb_routes_host: str, + request_host: str, + expected_status_code: int, +) -> None: + app = app_with_config( + app_config=dict(host_matching=True, static_host="static.com"), + toolbar_config=dict(DEBUG_TB_ENABLED=True, DEBUG_TB_ROUTES_HOST=tb_routes_host), + ) + + with app.test_client() as client: + with app.app_context(): + response = client.get( + "/_debug_toolbar/static/js/toolbar.js", headers={"Host": request_host} + ) + assert response.status_code == expected_status_code From 449405e3ba2f6a9a6aeeccd919a72d8cb098443f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2024 14:32:42 -0700 Subject: [PATCH 2/5] Bump urllib3 from 2.2.1 to 2.2.2 in /requirements (#270) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 907a5e2..46feb5e 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -232,7 +232,7 @@ typing-extensions==4.11.0 # -r typing.txt # mypy # sqlalchemy -urllib3==2.2.1 +urllib3==2.2.2 # via # -r docs.txt # requests diff --git a/requirements/docs.txt b/requirements/docs.txt index 624e1f4..4e50070 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -57,7 +57,7 @@ sphinxcontrib-qthelp==1.0.3 # via sphinx sphinxcontrib-serializinghtml==1.1.5 # via sphinx -urllib3==2.2.1 +urllib3==2.2.2 # via requests zipp==3.18.1 # via importlib-metadata From a2362ec4dd93de4c704f4cba1ccc714e8f14a1ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2024 21:37:04 +0000 Subject: [PATCH 3/5] Bump the python-requirements group across 1 directory with 4 updates Bumps the python-requirements group with 4 updates in the /requirements directory: [mypy](https://github.com/python/mypy), [pyright](https://github.com/RobertCraigie/pyright-python), [pytest](https://github.com/pytest-dev/pytest) and [tox](https://github.com/tox-dev/tox). Updates `mypy` from 1.10.0 to 1.10.1 - [Changelog](https://github.com/python/mypy/blob/master/CHANGELOG.md) - [Commits](https://github.com/python/mypy/compare/v1.10.0...v1.10.1) Updates `pyright` from 1.1.365 to 1.1.370 - [Release notes](https://github.com/RobertCraigie/pyright-python/releases) - [Commits](https://github.com/RobertCraigie/pyright-python/compare/v1.1.365...v1.1.370) Updates `pytest` from 8.2.1 to 8.2.2 - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/8.2.1...8.2.2) Updates `tox` from 4.15.0 to 4.15.1 - [Release notes](https://github.com/tox-dev/tox/releases) - [Changelog](https://github.com/tox-dev/tox/blob/main/docs/changelog.rst) - [Commits](https://github.com/tox-dev/tox/compare/4.15.0...4.15.1) --- updated-dependencies: - dependency-name: mypy dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-requirements - dependency-name: pyright dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-requirements - dependency-name: pytest dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-requirements - dependency-name: tox dependency-type: direct:development update-type: version-update:semver-patch dependency-group: python-requirements ... Signed-off-by: dependabot[bot] --- requirements/dev.txt | 8 ++++---- requirements/tests.txt | 2 +- requirements/typing.txt | 6 +++--- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 907a5e2..9c7f46c 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -108,7 +108,7 @@ markupsafe==2.1.5 # -r typing.txt # jinja2 # werkzeug -mypy==1.10.0 +mypy==1.10.1 # via -r typing.txt mypy-extensions==1.0.0 # via @@ -150,9 +150,9 @@ pygments==2.18.0 # sphinx pyproject-api==1.6.1 # via tox -pyright==1.1.365 +pyright==1.1.370 # via -r typing.txt -pytest==8.2.1 +pytest==8.2.2 # via # -r tests.txt # -r typing.txt @@ -214,7 +214,7 @@ tomli==2.0.1 # pyproject-api # pytest # tox -tox==4.15.0 +tox==4.15.1 # via -r dev.in types-docutils==0.21.0.20240423 # via diff --git a/requirements/tests.txt b/requirements/tests.txt index 5bc0675..67b8a9f 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -34,7 +34,7 @@ pluggy==1.5.0 # via pytest pygments==2.18.0 # via -r tests.in -pytest==8.2.1 +pytest==8.2.2 # via -r tests.in sqlalchemy==2.0.29 # via flask-sqlalchemy diff --git a/requirements/typing.txt b/requirements/typing.txt index a43f839..b3efcb7 100644 --- a/requirements/typing.txt +++ b/requirements/typing.txt @@ -28,7 +28,7 @@ markupsafe==2.1.5 # via # jinja2 # werkzeug -mypy==1.10.0 +mypy==1.10.1 # via -r typing.in mypy-extensions==1.0.0 # via mypy @@ -38,9 +38,9 @@ packaging==24.0 # via pytest pluggy==1.5.0 # via pytest -pyright==1.1.365 +pyright==1.1.370 # via -r typing.in -pytest==8.2.1 +pytest==8.2.2 # via -r typing.in sqlalchemy==2.0.29 # via flask-sqlalchemy From c3c3d5ec989521f608625913cfec7e10eadf4d50 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 6 Jul 2024 01:57:29 +0000 Subject: [PATCH 4/5] Bump certifi from 2024.2.2 to 2024.7.4 in /requirements Bumps [certifi](https://github.com/certifi/python-certifi) from 2024.2.2 to 2024.7.4. - [Commits](https://github.com/certifi/python-certifi/compare/2024.02.02...2024.07.04) --- updated-dependencies: - dependency-name: certifi dependency-type: indirect ... Signed-off-by: dependabot[bot] --- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/dev.txt b/requirements/dev.txt index 46feb5e..9b89e67 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -19,7 +19,7 @@ blinker==1.8.1 # flask cachetools==5.3.3 # via tox -certifi==2024.2.2 +certifi==2024.7.4 # via # -r docs.txt # requests diff --git a/requirements/docs.txt b/requirements/docs.txt index 4e50070..4b0776f 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -8,7 +8,7 @@ alabaster==0.7.13 # via sphinx babel==2.14.0 # via sphinx -certifi==2024.2.2 +certifi==2024.7.4 # via requests charset-normalizer==3.3.2 # via requests From 05104beefc056805f51bedf254c0566837bfbb5e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jul 2024 19:24:17 +0000 Subject: [PATCH 5/5] Bump zipp from 3.18.1 to 3.19.1 in /requirements Bumps [zipp](https://github.com/jaraco/zipp) from 3.18.1 to 3.19.1. - [Release notes](https://github.com/jaraco/zipp/releases) - [Changelog](https://github.com/jaraco/zipp/blob/main/NEWS.rst) - [Commits](https://github.com/jaraco/zipp/compare/v3.18.1...v3.19.1) --- updated-dependencies: - dependency-name: zipp dependency-type: indirect ... Signed-off-by: dependabot[bot] --- requirements/build.txt | 2 +- requirements/dev.txt | 2 +- requirements/docs.txt | 2 +- requirements/tests.txt | 2 +- requirements/typing.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/requirements/build.txt b/requirements/build.txt index 0416fc7..4ce6108 100644 --- a/requirements/build.txt +++ b/requirements/build.txt @@ -14,5 +14,5 @@ pyproject-hooks==1.1.0 # via build tomli==2.0.1 # via build -zipp==3.18.1 +zipp==3.19.1 # via importlib-metadata diff --git a/requirements/dev.txt b/requirements/dev.txt index 46feb5e..f213ea6 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -245,7 +245,7 @@ werkzeug==3.0.3 # -r tests.txt # -r typing.txt # flask -zipp==3.18.1 +zipp==3.19.1 # via # -r docs.txt # -r tests.txt diff --git a/requirements/docs.txt b/requirements/docs.txt index 4e50070..44bd147 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -59,5 +59,5 @@ sphinxcontrib-serializinghtml==1.1.5 # via sphinx urllib3==2.2.2 # via requests -zipp==3.18.1 +zipp==3.19.1 # via importlib-metadata diff --git a/requirements/tests.txt b/requirements/tests.txt index 5bc0675..934021d 100644 --- a/requirements/tests.txt +++ b/requirements/tests.txt @@ -44,5 +44,5 @@ typing-extensions==4.11.0 # via sqlalchemy werkzeug==3.0.3 # via flask -zipp==3.18.1 +zipp==3.19.1 # via importlib-metadata diff --git a/requirements/typing.txt b/requirements/typing.txt index a43f839..90cf732 100644 --- a/requirements/typing.txt +++ b/requirements/typing.txt @@ -60,7 +60,7 @@ typing-extensions==4.11.0 # sqlalchemy werkzeug==3.0.3 # via flask -zipp==3.18.1 +zipp==3.19.1 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: