Skip to content

Commit 7245896

Browse files
authored
Merge pull request #2540 from plotly/fix/#2536
Fix pages meta tags
2 parents 76f6691 + ce5a3a6 commit 7245896

File tree

5 files changed

+144
-113
lines changed

5 files changed

+144
-113
lines changed

Diff for: CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ This project adheres to [Semantic Versioning](https://semver.org/).
88

99
- [#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.
1010

11+
## Added
12+
13+
- [#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).
14+
1115
## Fixed
1216

1317
- [#2508](https://github.com/plotly/dash/pull/2508) Fix error message, when callback output has different length than spec

Diff for: dash/_pages.py

+75
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import collections
2+
import importlib
23
import os
34
import re
45
import sys
@@ -360,3 +361,77 @@ def register_page(
360361
key=lambda i: (str(i.get("order", i["module"])), i["module"]),
361362
):
362363
PAGE_REGISTRY.move_to_end(page["module"])
364+
365+
366+
def _path_to_page(path_id):
367+
path_variables = None
368+
for page in PAGE_REGISTRY.values():
369+
if page["path_template"]:
370+
template_id = page["path_template"].strip("/")
371+
path_variables = _parse_path_variables(path_id, template_id)
372+
if path_variables:
373+
return page, path_variables
374+
if path_id == page["path"].strip("/"):
375+
return page, path_variables
376+
return {}, None
377+
378+
379+
def _page_meta_tags(app):
380+
start_page, path_variables = _path_to_page(flask.request.path.strip("/"))
381+
382+
# use the supplied image_url or create url based on image in the assets folder
383+
image = start_page.get("image", "")
384+
if image:
385+
image = app.get_asset_url(image)
386+
assets_image_url = (
387+
"".join([flask.request.url_root, image.lstrip("/")]) if image else None
388+
)
389+
supplied_image_url = start_page.get("image_url")
390+
image_url = supplied_image_url if supplied_image_url else assets_image_url
391+
392+
title = start_page.get("title", app.title)
393+
if callable(title):
394+
title = title(**path_variables) if path_variables else title()
395+
396+
description = start_page.get("description", "")
397+
if callable(description):
398+
description = description(**path_variables) if path_variables else description()
399+
400+
return [
401+
{"name": "description", "content": description},
402+
{"property": "twitter:card", "content": "summary_large_image"},
403+
{"property": "twitter:url", "content": flask.request.url},
404+
{"property": "twitter:title", "content": title},
405+
{"property": "twitter:description", "content": description},
406+
{"property": "twitter:image", "content": image_url or ""},
407+
{"property": "og:title", "content": title},
408+
{"property": "og:type", "content": "website"},
409+
{"property": "og:description", "content": description},
410+
{"property": "og:image", "content": image_url or ""},
411+
]
412+
413+
414+
def _import_layouts_from_pages(pages_folder):
415+
for root, dirs, files in os.walk(pages_folder):
416+
dirs[:] = [d for d in dirs if not d.startswith(".") and not d.startswith("_")]
417+
for file in files:
418+
if file.startswith("_") or file.startswith(".") or not file.endswith(".py"):
419+
continue
420+
page_path = os.path.join(root, file)
421+
with open(page_path, encoding="utf-8") as f:
422+
content = f.read()
423+
if "register_page" not in content:
424+
continue
425+
426+
module_name = _infer_module_name(page_path)
427+
spec = importlib.util.spec_from_file_location(module_name, page_path)
428+
page_module = importlib.util.module_from_spec(spec)
429+
spec.loader.exec_module(page_module)
430+
sys.modules[module_name] = page_module
431+
432+
if (
433+
module_name in PAGE_REGISTRY
434+
and not PAGE_REGISTRY[module_name]["supplied_layout"]
435+
):
436+
_validate.validate_pages_layout(module_name, page_module)
437+
PAGE_REGISTRY[module_name]["layout"] = getattr(page_module, "layout")

Diff for: dash/_utils.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import json
1111
import secrets
1212
import string
13+
from html import escape
1314
from functools import wraps
1415

1516
logger = logging.getLogger()
@@ -30,8 +31,12 @@ def interpolate_str(template, **data):
3031
return s
3132

3233

33-
def format_tag(tag_name, attributes, inner="", closed=False, opened=False):
34-
attributes = " ".join([f'{k}="{v}"' for k, v in attributes.items()])
34+
def format_tag(
35+
tag_name, attributes, inner="", closed=False, opened=False, sanitize=False
36+
):
37+
attributes = " ".join(
38+
[f'{k}="{escape(v) if sanitize else v}"' for k, v in attributes.items()]
39+
)
3540
tag = f"<{tag_name} {attributes}"
3641
if closed:
3742
tag += "/>"

Diff for: dash/dash.py

+29-111
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import base64
1616
import traceback
1717
from urllib.parse import urlparse
18-
from textwrap import dedent
1918

2019
import flask
2120

@@ -64,9 +63,10 @@
6463

6564
from . import _pages
6665
from ._pages import (
67-
_infer_module_name,
68-
_parse_path_variables,
6966
_parse_query_string,
67+
_page_meta_tags,
68+
_path_to_page,
69+
_import_layouts_from_pages,
7070
)
7171

7272
# Add explicit mapping for map files
@@ -210,6 +210,9 @@ class Dash:
210210
to be True. Default `None`.
211211
:type use_pages: boolean
212212
213+
:param include_pages_meta: Include the page meta tags for twitter cards.
214+
:type include_pages_meta: bool
215+
213216
:param assets_url_path: The local urls for assets will be:
214217
``requests_pathname_prefix + assets_url_path + '/' + asset_path``
215218
where ``asset_path`` is the path to a file inside ``assets_folder``.
@@ -348,6 +351,7 @@ def __init__( # pylint: disable=too-many-statements
348351
assets_external_path=None,
349352
eager_loading=False,
350353
include_assets_files=True,
354+
include_pages_meta=True,
351355
url_base_pathname=None,
352356
requests_pathname_prefix=None,
353357
routes_pathname_prefix=None,
@@ -418,6 +422,7 @@ def __init__( # pylint: disable=too-many-statements
418422
extra_hot_reload_paths=extra_hot_reload_paths or [],
419423
title=title,
420424
update_title=update_title,
425+
include_pages_meta=include_pages_meta,
421426
)
422427
self.config.set_read_only(
423428
[
@@ -854,67 +859,24 @@ def _generate_config_html(self):
854859
def _generate_renderer(self):
855860
return f'<script id="_dash-renderer" type="application/javascript">{self.renderer}</script>'
856861

857-
def _generate_meta_html(self):
858-
meta_tags = self.config.meta_tags
862+
def _generate_meta(self):
863+
meta_tags = []
859864
has_ie_compat = any(
860-
x.get("http-equiv", "") == "X-UA-Compatible" for x in meta_tags
865+
x.get("http-equiv", "") == "X-UA-Compatible" for x in self.config.meta_tags
861866
)
862-
has_charset = any("charset" in x for x in meta_tags)
863-
has_viewport = any(x.get("name") == "viewport" for x in meta_tags)
867+
has_charset = any("charset" in x for x in self.config.meta_tags)
868+
has_viewport = any(x.get("name") == "viewport" for x in self.config.meta_tags)
864869

865-
tags = []
866870
if not has_ie_compat:
867-
tags.append('<meta http-equiv="X-UA-Compatible" content="IE=edge">')
871+
meta_tags.append({"http-equiv": "X-UA-Compatible", "content": "IE=edge"})
868872
if not has_charset:
869-
tags.append('<meta charset="UTF-8">')
873+
meta_tags.append({"charset": "UTF-8"})
870874
if not has_viewport:
871-
tags.append(
872-
'<meta name="viewport" content="width=device-width, initial-scale=1">'
875+
meta_tags.append(
876+
{"name": "viewport", "content": "width=device-width, initial-scale=1"}
873877
)
874878

875-
tags += [format_tag("meta", x, opened=True) for x in meta_tags]
876-
877-
return "\n ".join(tags)
878-
879-
def _pages_meta_tags(self):
880-
start_page, path_variables = self._path_to_page(flask.request.path.strip("/"))
881-
882-
# use the supplied image_url or create url based on image in the assets folder
883-
image = start_page.get("image", "")
884-
if image:
885-
image = self.get_asset_url(image)
886-
assets_image_url = (
887-
"".join([flask.request.url_root, image.lstrip("/")]) if image else None
888-
)
889-
supplied_image_url = start_page.get("image_url")
890-
image_url = supplied_image_url if supplied_image_url else assets_image_url
891-
892-
title = start_page.get("title", self.title)
893-
if callable(title):
894-
title = title(**path_variables) if path_variables else title()
895-
896-
description = start_page.get("description", "")
897-
if callable(description):
898-
description = (
899-
description(**path_variables) if path_variables else description()
900-
)
901-
902-
return dedent(
903-
f"""
904-
<meta name="description" content="{description}" />
905-
<!-- Twitter Card data -->
906-
<meta property="twitter:card" content="summary_large_image">
907-
<meta property="twitter:url" content="{flask.request.url}">
908-
<meta property="twitter:title" content="{title}">
909-
<meta property="twitter:description" content="{description}">
910-
<meta property="twitter:image" content="{image_url}">
911-
<!-- Open Graph data -->
912-
<meta property="og:title" content="{title}" />
913-
<meta property="og:type" content="website" />
914-
<meta property="og:description" content="{description}" />
915-
<meta property="og:image" content="{image_url}">
916-
"""
917-
)
879+
return meta_tags + self.config.meta_tags
918880

919881
# Serve the JS bundles for each package
920882
def serve_component_suites(self, package_name, fingerprinted_path):
@@ -959,14 +921,14 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument
959921
scripts = self._generate_scripts_html()
960922
css = self._generate_css_dist_html()
961923
config = self._generate_config_html()
962-
metas = self._generate_meta_html()
924+
metas = self._generate_meta()
963925
renderer = self._generate_renderer()
964926

965927
# use self.title instead of app.config.title for backwards compatibility
966928
title = self.title
967-
pages_metas = ""
968-
if self.use_pages:
969-
pages_metas = self._pages_meta_tags()
929+
930+
if self.use_pages and self.config.include_pages_meta:
931+
metas = _page_meta_tags(self) + metas
970932

971933
if self._favicon:
972934
favicon_mod_time = os.path.getmtime(
@@ -983,8 +945,12 @@ def index(self, *args, **kwargs): # pylint: disable=unused-argument
983945
opened=True,
984946
)
985947

948+
tags = "\n ".join(
949+
format_tag("meta", x, opened=True, sanitize=True) for x in metas
950+
)
951+
986952
index = self.interpolate_index(
987-
metas=pages_metas + metas,
953+
metas=tags,
988954
title=title,
989955
css=css,
990956
config=config,
@@ -1988,57 +1954,11 @@ def verify_url_part(served_part, url_part, part_name):
19881954

19891955
self.server.run(host=host, port=port, debug=debug, **flask_run_options)
19901956

1991-
def _import_layouts_from_pages(self):
1992-
for root, dirs, files in os.walk(self.config.pages_folder):
1993-
dirs[:] = [
1994-
d for d in dirs if not d.startswith(".") and not d.startswith("_")
1995-
]
1996-
for file in files:
1997-
if (
1998-
file.startswith("_")
1999-
or file.startswith(".")
2000-
or not file.endswith(".py")
2001-
):
2002-
continue
2003-
page_path = os.path.join(root, file)
2004-
with open(page_path, encoding="utf-8") as f:
2005-
content = f.read()
2006-
if "register_page" not in content:
2007-
continue
2008-
2009-
module_name = _infer_module_name(page_path)
2010-
spec = importlib.util.spec_from_file_location(module_name, page_path)
2011-
page_module = importlib.util.module_from_spec(spec)
2012-
spec.loader.exec_module(page_module)
2013-
sys.modules[module_name] = page_module
2014-
2015-
if (
2016-
module_name in _pages.PAGE_REGISTRY
2017-
and not _pages.PAGE_REGISTRY[module_name]["supplied_layout"]
2018-
):
2019-
_validate.validate_pages_layout(module_name, page_module)
2020-
_pages.PAGE_REGISTRY[module_name]["layout"] = getattr(
2021-
page_module, "layout"
2022-
)
2023-
2024-
@staticmethod
2025-
def _path_to_page(path_id):
2026-
path_variables = None
2027-
for page in _pages.PAGE_REGISTRY.values():
2028-
if page["path_template"]:
2029-
template_id = page["path_template"].strip("/")
2030-
path_variables = _parse_path_variables(path_id, template_id)
2031-
if path_variables:
2032-
return page, path_variables
2033-
if path_id == page["path"].strip("/"):
2034-
return page, path_variables
2035-
return {}, None
2036-
20371957
def enable_pages(self):
20381958
if not self.use_pages:
20391959
return
20401960
if self.pages_folder:
2041-
self._import_layouts_from_pages()
1961+
_import_layouts_from_pages(self.config.pages_folder)
20421962

20431963
@self.server.before_request
20441964
def router():
@@ -2060,9 +1980,7 @@ def update(pathname, search):
20601980
"""
20611981

20621982
query_parameters = _parse_query_string(search)
2063-
page, path_variables = self._path_to_page(
2064-
self.strip_relative_path(pathname)
2065-
)
1983+
page, path_variables = _path_to_page(self.strip_relative_path(pathname))
20661984

20671985
# get layout
20681986
if page == {}:

Diff for: tests/integration/security/test_injection.py

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import requests
2+
3+
from dash import Dash, html, register_page
4+
5+
injection_script = "<scRipt>console.error(0x000F45)</scRipt>"
6+
7+
8+
def test_sinj001_url_injection(dash_duo):
9+
app = Dash(__name__, use_pages=True, pages_folder="")
10+
11+
register_page(
12+
"injected",
13+
layout=html.Div("Regular page"),
14+
title="Title",
15+
description="desc",
16+
name="injected",
17+
path="/injected",
18+
)
19+
20+
dash_duo.start_server(app)
21+
22+
url = f"{dash_duo.server_url}/?'\"--></style></scRipt>{injection_script}"
23+
dash_duo.server_url = url
24+
25+
assert dash_duo.get_logs() == []
26+
27+
ret = requests.get(url)
28+
29+
assert injection_script not in ret.text

0 commit comments

Comments
 (0)