From 7e8d2c35546cbd25d35436167286f8df9d793a77 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 24 May 2023 10:35:13 -0400 Subject: [PATCH 1/9] Add include_pages_meta config. --- dash/_pages.py | 53 ++++++++++++++++++++++++++++++++++++++ dash/dash.py | 70 ++++++++------------------------------------------ 2 files changed, 63 insertions(+), 60 deletions(-) diff --git a/dash/_pages.py b/dash/_pages.py index a4933dc3ec..63c0e8956c 100644 --- a/dash/_pages.py +++ b/dash/_pages.py @@ -5,6 +5,7 @@ from fnmatch import fnmatch from pathlib import Path from os.path import isfile, join +from textwrap import dedent from urllib.parse import parse_qs import flask @@ -360,3 +361,55 @@ def register_page( key=lambda i: (str(i.get("order", i["module"])), i["module"]), ): PAGE_REGISTRY.move_to_end(page["module"]) + + +def _path_to_page(path_id): + path_variables = None + for page in PAGE_REGISTRY.values(): + if page["path_template"]: + template_id = page["path_template"].strip("/") + path_variables = _parse_path_variables(path_id, template_id) + if path_variables: + return page, path_variables + if path_id == page["path"].strip("/"): + return page, path_variables + return {}, None + + +def _page_meta_tags(app): + start_page, path_variables = _path_to_page(flask.request.path.strip("/")) + + # use the supplied image_url or create url based on image in the assets folder + image = start_page.get("image", "") + if image: + image = app.get_asset_url(image) + assets_image_url = ( + "".join([flask.request.url_root, image.lstrip("/")]) if image else None + ) + supplied_image_url = start_page.get("image_url") + image_url = supplied_image_url if supplied_image_url else assets_image_url + + title = start_page.get("title", app.title) + if callable(title): + title = title(**path_variables) if path_variables else title() + + description = start_page.get("description", "") + if callable(description): + description = description(**path_variables) if path_variables else description() + + return dedent( + f""" + + + + + + + + + + + + + """ + ) diff --git a/dash/dash.py b/dash/dash.py index 9b791a944e..7ad9e0c589 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -15,7 +15,6 @@ import base64 import traceback from urllib.parse import urlparse -from textwrap import dedent import flask @@ -65,8 +64,9 @@ from . import _pages from ._pages import ( _infer_module_name, - _parse_path_variables, _parse_query_string, + _page_meta_tags, + _path_to_page, ) # Add explicit mapping for map files @@ -210,6 +210,9 @@ class Dash: to be True. Default `None`. :type use_pages: boolean + :param include_pages_meta: Include the page meta tags for twitter cards. + :type include_pages_meta: bool + :param assets_url_path: The local urls for assets will be: ``requests_pathname_prefix + assets_url_path + '/' + asset_path`` where ``asset_path`` is the path to a file inside ``assets_folder``. @@ -348,6 +351,7 @@ def __init__( # pylint: disable=too-many-statements assets_external_path=None, eager_loading=False, include_assets_files=True, + include_pages_meta=True, url_base_pathname=None, requests_pathname_prefix=None, routes_pathname_prefix=None, @@ -418,6 +422,7 @@ def __init__( # pylint: disable=too-many-statements extra_hot_reload_paths=extra_hot_reload_paths or [], title=title, update_title=update_title, + include_pages_meta=include_pages_meta, ) self.config.set_read_only( [ @@ -876,46 +881,6 @@ def _generate_meta_html(self): return "\n ".join(tags) - def _pages_meta_tags(self): - start_page, path_variables = self._path_to_page(flask.request.path.strip("/")) - - # use the supplied image_url or create url based on image in the assets folder - image = start_page.get("image", "") - if image: - image = self.get_asset_url(image) - assets_image_url = ( - "".join([flask.request.url_root, image.lstrip("/")]) if image else None - ) - supplied_image_url = start_page.get("image_url") - image_url = supplied_image_url if supplied_image_url else assets_image_url - - title = start_page.get("title", self.title) - if callable(title): - title = title(**path_variables) if path_variables else title() - - description = start_page.get("description", "") - if callable(description): - description = ( - description(**path_variables) if path_variables else description() - ) - - return dedent( - f""" - - - - - - - - - - - - - """ - ) - # Serve the JS bundles for each package def serve_component_suites(self, package_name, fingerprinted_path): path_in_pkg, has_fingerprint = check_fingerprint(fingerprinted_path) @@ -965,8 +930,8 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument # use self.title instead of app.config.title for backwards compatibility title = self.title pages_metas = "" - if self.use_pages: - pages_metas = self._pages_meta_tags() + if self.use_pages and self.config.include_pages_meta: + pages_metas = _page_meta_tags(self) if self._favicon: favicon_mod_time = os.path.getmtime( @@ -2021,19 +1986,6 @@ def _import_layouts_from_pages(self): page_module, "layout" ) - @staticmethod - def _path_to_page(path_id): - path_variables = None - for page in _pages.PAGE_REGISTRY.values(): - if page["path_template"]: - template_id = page["path_template"].strip("/") - path_variables = _parse_path_variables(path_id, template_id) - if path_variables: - return page, path_variables - if path_id == page["path"].strip("/"): - return page, path_variables - return {}, None - def enable_pages(self): if not self.use_pages: return @@ -2060,9 +2012,7 @@ def update(pathname, search): """ query_parameters = _parse_query_string(search) - page, path_variables = self._path_to_page( - self.strip_relative_path(pathname) - ) + page, path_variables = _path_to_page(self.strip_relative_path(pathname)) # get layout if page == {}: From 31505faa39952211b159018712fad7cb3b45c5a7 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 24 May 2023 12:38:23 -0400 Subject: [PATCH 2/9] Escape meta tags values --- dash/_pages.py | 28 +++++++++++----------------- dash/_utils.py | 9 +++++++-- dash/dash.py | 29 +++++++++++++++-------------- 3 files changed, 33 insertions(+), 33 deletions(-) diff --git a/dash/_pages.py b/dash/_pages.py index 63c0e8956c..75102c21c4 100644 --- a/dash/_pages.py +++ b/dash/_pages.py @@ -5,7 +5,6 @@ from fnmatch import fnmatch from pathlib import Path from os.path import isfile, join -from textwrap import dedent from urllib.parse import parse_qs import flask @@ -397,19 +396,14 @@ def _page_meta_tags(app): if callable(description): description = description(**path_variables) if path_variables else description() - return dedent( - f""" - - - - - - - - - - - - - """ - ) + return [ + {"name": "description", "content": description}, + {"property": "twitter:card", "content": "summary_large_image"}, + {"property": "twitter:url", "content": flask.request.url}, + {"property": "twitter:title", "content": title}, + {"property": "twitter:description", "content": description}, + {"property": "twitter:image", "content": image_url}, + {"property": "og:title", "content": title}, + {"property": "og:description", "content": description}, + {"property": "og:image_url", "content": image_url}, + ] diff --git a/dash/_utils.py b/dash/_utils.py index c43933008c..bd1b7a21b4 100644 --- a/dash/_utils.py +++ b/dash/_utils.py @@ -10,6 +10,7 @@ import json import secrets import string +from html import escape from functools import wraps logger = logging.getLogger() @@ -30,8 +31,12 @@ def interpolate_str(template, **data): return s -def format_tag(tag_name, attributes, inner="", closed=False, opened=False): - attributes = " ".join([f'{k}="{v}"' for k, v in attributes.items()]) +def format_tag( + tag_name, attributes, inner="", closed=False, opened=False, sanitize=False +): + attributes = " ".join( + [f'{k}="{escape(v) if sanitize else v}"' for k, v in attributes.items()] + ) tag = f"<{tag_name} {attributes}" if closed: tag += "/>" diff --git a/dash/dash.py b/dash/dash.py index 7ad9e0c589..01df08faca 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -859,27 +859,24 @@ def _generate_config_html(self): def _generate_renderer(self): return f'' - def _generate_meta_html(self): - meta_tags = self.config.meta_tags + def _generate_meta(self): + meta_tags = self.config.meta_tags.copy() has_ie_compat = any( x.get("http-equiv", "") == "X-UA-Compatible" for x in meta_tags ) has_charset = any("charset" in x for x in meta_tags) has_viewport = any(x.get("name") == "viewport" for x in meta_tags) - tags = [] if not has_ie_compat: - tags.append('') + meta_tags.append({"http-equiv": "X-UA-Compatible", "content": "IE=edge"}) if not has_charset: - tags.append('') + meta_tags.append({"charset": "UTF-8"}) if not has_viewport: - tags.append( - '' + meta_tags.append( + {"name": "viewport", "content": "width=device-width, initial-scale=1"} ) - tags += [format_tag("meta", x, opened=True) for x in meta_tags] - - return "\n ".join(tags) + return meta_tags # Serve the JS bundles for each package def serve_component_suites(self, package_name, fingerprinted_path): @@ -924,14 +921,14 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument scripts = self._generate_scripts_html() css = self._generate_css_dist_html() config = self._generate_config_html() - metas = self._generate_meta_html() + metas = self._generate_meta() renderer = self._generate_renderer() # use self.title instead of app.config.title for backwards compatibility title = self.title - pages_metas = "" + if self.use_pages and self.config.include_pages_meta: - pages_metas = _page_meta_tags(self) + metas += _page_meta_tags(self) if self._favicon: favicon_mod_time = os.path.getmtime( @@ -948,8 +945,12 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument opened=True, ) + tags = "\n ".join( + format_tag("meta", x, opened=True, sanitize=True) for x in metas + ) + index = self.interpolate_index( - metas=pages_metas + metas, + metas=tags, title=title, css=css, config=config, From d418298209e1b75501bf2f76fb9397982951c1dc Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 24 May 2023 14:00:33 -0400 Subject: [PATCH 3/9] Move import_layouts_from_page, fix metas order, fix none image_url --- dash/_pages.py | 31 +++++++++++++++++++++++++++++-- dash/dash.py | 41 ++++------------------------------------- 2 files changed, 33 insertions(+), 39 deletions(-) diff --git a/dash/_pages.py b/dash/_pages.py index 75102c21c4..76e70d6a78 100644 --- a/dash/_pages.py +++ b/dash/_pages.py @@ -1,4 +1,5 @@ import collections +import importlib import os import re import sys @@ -402,8 +403,34 @@ def _page_meta_tags(app): {"property": "twitter:url", "content": flask.request.url}, {"property": "twitter:title", "content": title}, {"property": "twitter:description", "content": description}, - {"property": "twitter:image", "content": image_url}, + {"property": "twitter:image", "content": image_url or ""}, {"property": "og:title", "content": title}, {"property": "og:description", "content": description}, - {"property": "og:image_url", "content": image_url}, + {"property": "og:image_url", "content": image_url or ""}, ] + + +def _import_layouts_from_pages(pages_folder): + for root, dirs, files in os.walk(pages_folder): + dirs[:] = [d for d in dirs if not d.startswith(".") and not d.startswith("_")] + for file in files: + if file.startswith("_") or file.startswith(".") or not file.endswith(".py"): + continue + page_path = os.path.join(root, file) + with open(page_path, encoding="utf-8") as f: + content = f.read() + if "register_page" not in content: + continue + + module_name = _infer_module_name(page_path) + spec = importlib.util.spec_from_file_location(module_name, page_path) + page_module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(page_module) + sys.modules[module_name] = page_module + + if ( + module_name in PAGE_REGISTRY + and not PAGE_REGISTRY[module_name]["supplied_layout"] + ): + _validate.validate_pages_layout(module_name, page_module) + PAGE_REGISTRY[module_name]["layout"] = getattr(page_module, "layout") diff --git a/dash/dash.py b/dash/dash.py index 01df08faca..a20888ec0a 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -63,10 +63,10 @@ from . import _pages from ._pages import ( - _infer_module_name, _parse_query_string, _page_meta_tags, _path_to_page, + _import_layouts_from_pages, ) # Add explicit mapping for map files @@ -860,7 +860,7 @@ def _generate_renderer(self): return f'' def _generate_meta(self): - meta_tags = self.config.meta_tags.copy() + meta_tags = [] has_ie_compat = any( x.get("http-equiv", "") == "X-UA-Compatible" for x in meta_tags ) @@ -876,7 +876,7 @@ def _generate_meta(self): {"name": "viewport", "content": "width=device-width, initial-scale=1"} ) - return meta_tags + return meta_tags + self.config.meta_tags # Serve the JS bundles for each package def serve_component_suites(self, package_name, fingerprinted_path): @@ -1954,44 +1954,11 @@ def verify_url_part(served_part, url_part, part_name): self.server.run(host=host, port=port, debug=debug, **flask_run_options) - def _import_layouts_from_pages(self): - for root, dirs, files in os.walk(self.config.pages_folder): - dirs[:] = [ - d for d in dirs if not d.startswith(".") and not d.startswith("_") - ] - for file in files: - if ( - file.startswith("_") - or file.startswith(".") - or not file.endswith(".py") - ): - continue - page_path = os.path.join(root, file) - with open(page_path, encoding="utf-8") as f: - content = f.read() - if "register_page" not in content: - continue - - module_name = _infer_module_name(page_path) - spec = importlib.util.spec_from_file_location(module_name, page_path) - page_module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(page_module) - sys.modules[module_name] = page_module - - if ( - module_name in _pages.PAGE_REGISTRY - and not _pages.PAGE_REGISTRY[module_name]["supplied_layout"] - ): - _validate.validate_pages_layout(module_name, page_module) - _pages.PAGE_REGISTRY[module_name]["layout"] = getattr( - page_module, "layout" - ) - def enable_pages(self): if not self.use_pages: return if self.pages_folder: - self._import_layouts_from_pages() + _import_layouts_from_pages(self.config.pages_folder) @self.server.before_request def router(): From 89a26d695bccf037c7f90782a2b9fa8fc505de44 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 24 May 2023 14:09:00 -0400 Subject: [PATCH 4/9] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 528f43559c..09e1f3fec0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This project adheres to [Semantic Versioning](https://semver.org/). - [#2538](https://github.com/plotly/dash/pull/2538) Add an upper bound to Flask and Werkzeug versions at `<2.2.3` because we expect the Dash ecosystem to be incompatible with the next minor release of Flask (this excludes the current latest Flask release 2.3.x). We will raise the upper bound to `<2.4` after we fix incompatibilities elsewhere in the Dash ecosystem. +## Added + +- [#2540](https://github.com/plotly/dash/pull/2540) Add `include_pages_meta=True` to `Dash._init__`, fix [#2536](https://github.com/plotly/dash/issues/2536). + ## Fixed - [#2508](https://github.com/plotly/dash/pull/2508) Fix error message, when callback output has different length than spec From 880cdd1f7a1485df2bdc9994dfd2520cf4cc29f7 Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 24 May 2023 15:15:04 -0400 Subject: [PATCH 5/9] fix meta multi viewport. --- dash/dash.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dash/dash.py b/dash/dash.py index a20888ec0a..7aec944259 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -862,10 +862,10 @@ def _generate_renderer(self): def _generate_meta(self): meta_tags = [] has_ie_compat = any( - x.get("http-equiv", "") == "X-UA-Compatible" for x in meta_tags + x.get("http-equiv", "") == "X-UA-Compatible" for x in self.config.meta_tags ) - has_charset = any("charset" in x for x in meta_tags) - has_viewport = any(x.get("name") == "viewport" for x in meta_tags) + has_charset = any("charset" in x for x in self.config.meta_tags) + has_viewport = any(x.get("name") == "viewport" for x in self.config.meta_tags) if not has_ie_compat: meta_tags.append({"http-equiv": "X-UA-Compatible", "content": "IE=edge"}) From 28de96742b1bd32096d5330707c11dd2e83b250d Mon Sep 17 00:00:00 2001 From: philippe Date: Wed, 24 May 2023 16:19:53 -0400 Subject: [PATCH 6/9] Add back og:type --- dash/_pages.py | 1 + 1 file changed, 1 insertion(+) diff --git a/dash/_pages.py b/dash/_pages.py index 76e70d6a78..db7d01b301 100644 --- a/dash/_pages.py +++ b/dash/_pages.py @@ -405,6 +405,7 @@ def _page_meta_tags(app): {"property": "twitter:description", "content": description}, {"property": "twitter:image", "content": image_url or ""}, {"property": "og:title", "content": title}, + {"property": "og:type", "content": "website"}, {"property": "og:description", "content": description}, {"property": "og:image_url", "content": image_url or ""}, ] From 43af904756ed4b5d0ff4450b37d16f56e22b7ebd Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 25 May 2023 09:23:08 -0400 Subject: [PATCH 7/9] Fix twitter cards --- dash/_pages.py | 2 +- dash/dash.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dash/_pages.py b/dash/_pages.py index db7d01b301..394c842b1d 100644 --- a/dash/_pages.py +++ b/dash/_pages.py @@ -407,7 +407,7 @@ def _page_meta_tags(app): {"property": "og:title", "content": title}, {"property": "og:type", "content": "website"}, {"property": "og:description", "content": description}, - {"property": "og:image_url", "content": image_url or ""}, + {"property": "og:image", "content": image_url or ""}, ] diff --git a/dash/dash.py b/dash/dash.py index 7aec944259..8fee670b08 100644 --- a/dash/dash.py +++ b/dash/dash.py @@ -928,7 +928,7 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument title = self.title if self.use_pages and self.config.include_pages_meta: - metas += _page_meta_tags(self) + metas = _page_meta_tags(self) + metas if self._favicon: favicon_mod_time = os.path.getmtime( From 2ed27f3dcebaa8351206af9dc107e41ff43f4f53 Mon Sep 17 00:00:00 2001 From: Philippe Duval Date: Thu, 25 May 2023 10:14:28 -0400 Subject: [PATCH 8/9] Update CHANGELOG.md Co-authored-by: Alex Johnson --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09e1f3fec0..4d7e2a04f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## Added -- [#2540](https://github.com/plotly/dash/pull/2540) Add `include_pages_meta=True` to `Dash._init__`, fix [#2536](https://github.com/plotly/dash/issues/2536). +- [#2540](https://github.com/plotly/dash/pull/2540) Add `include_pages_meta=True` to `Dash` constructor, and fix a security issue in pages meta tags [#2536](https://github.com/plotly/dash/issues/2536). ## Fixed From ce5a3a61c1471b6a05e238fe66eaa6e211f0205c Mon Sep 17 00:00:00 2001 From: philippe Date: Thu, 25 May 2023 11:26:04 -0400 Subject: [PATCH 9/9] Add test url injection --- tests/integration/security/test_injection.py | 29 ++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 tests/integration/security/test_injection.py diff --git a/tests/integration/security/test_injection.py b/tests/integration/security/test_injection.py new file mode 100644 index 0000000000..a2a64acf8e --- /dev/null +++ b/tests/integration/security/test_injection.py @@ -0,0 +1,29 @@ +import requests + +from dash import Dash, html, register_page + +injection_script = "" + + +def test_sinj001_url_injection(dash_duo): + app = Dash(__name__, use_pages=True, pages_folder="") + + register_page( + "injected", + layout=html.Div("Regular page"), + title="Title", + description="desc", + name="injected", + path="/injected", + ) + + dash_duo.start_server(app) + + url = f"{dash_duo.server_url}/?'\"-->{injection_script}" + dash_duo.server_url = url + + assert dash_duo.get_logs() == [] + + ret = requests.get(url) + + assert injection_script not in ret.text