Repository: plotly/dash

Issue:
[BUG] Turn twitter cards off by default to avoid security issues
-  replace the result of `pip list | grep dash` below
```
dash                      2.9.3
dash-auth                 2.0.0
dash-bootstrap-components 1.4.1
dash-core-components      2.0.0
dash-extensions           0.1.13
dash-html-components      2.0.0
dash-table                5.0.0
```

-  if frontend related, tell us your Browser, Version and OS

    - OS: OSX
    - Browser Firefox
    - Version 102.10

**Describe the bug**

Deploying a dash app with pages allows attackers to embed code into the webapp. This is a potential security vulnerability since it allows attackers to execute arbitrary code in the context of the dash sandbox.

Concretely, if use_pages is true, dash calls self._pages_meta_tags()
https://github.com/plotly/dash/blob/a8b3ddbec5a0c639d41230137c5e5744d5f43c8f/dash/dash.py#L968

which *always* adds the twitter and og meta tags
https://github.com/plotly/dash/blob/a8b3ddbec5a0c639d41230137c5e5744d5f43c8f/dash/dash.py#L906

The twitter meta tag includes the URL

```
            <!-- Twitter Card data -->
            <meta property="twitter:card" content="summary_large_image">
            <meta property="twitter:url" content="{flask.request.url}">
            <meta property="twitter:title" content="{title}">
            <meta property="twitter:description" content="{description}">
            <meta property="twitter:image" content="{image_url}">
```

So if the dash app is involved with a URL that includes a `<script>` tag, the script specified in the tag will be executed.

Example URL

```
[dash_app_base_url]/?'"--></style></scRipt><scRipt>netsparker(0x000F45)</scRipt>
```

This causes our dash app to fail cyber security/pen testing scans.

A workaround is to a custom `index_string` which removes all `meta` tags, but that has the disadvantage of not including any meta tags, even the ones that we might want.

A better option would be to make the twitter and og meta tags opt-in; it is not clear that specifying a twitter card is necessary for all deployers.

I am happy to submit a PR (`include_card_configs` property) if that would help

**Screenshots**

-----------

#### Vulnerability report
<img width="873" alt="Screenshot 2023-05-21 at 9 54 22 PM" src="https://github.com/plotly/dash/assets/2423263/64d70995-8819-4604-b3f0-aef0435d9eac">

-----------

#### Page source includes the embedded script
<img width="873" alt="Screenshot 2023-05-21 at 9 52 51 PM" src="https://github.com/plotly/dash/assets/2423263/b90e03f2-8bed-4528-aada-e77fc853373b">

-----------

#### Example of an embedded alert in firefox
<img width="878" alt="Screenshot 2023-05-21 at 9 57 17 PM" src="https://github.com/plotly/dash/assets/2423263/2aea626b-1480-4d0f-b356-4392a60ab2b6">




Discussion:
Thanks @shankari! I think we can address the security concern by calling [`html.escape`](http://htmlencode.net/python) on the url - and in fact seems like we should do that on _everything_ that goes into any of the `<meta>` tag attributes. So that would be here and in [`format_tag`](https://github.com/plotly/dash/blob/a8b3ddbec5a0c639d41230137c5e5744d5f43c8f/dash/_utils.py#L34)

If we can address this security flaw and you still want the ability to skip these meta tags I'd rather make them opt-out rather than opt-in, as they're fairly low overhead and they're a nice touch in various places you might post links to your app.
@alexcjohnson I have a workaround for this now, so I will see if one of my summer interns can work on this when they get started in a month or so. If not, I will see if I can spend a Friday working on it.



Resolving patch:
diff --git a/dash/_pages.py b/dash/_pages.py
--- a/dash/_pages.py
+++ b/dash/_pages.py
@@ -1,4 +1,5 @@
 import collections
+import importlib
 import os
 import re
 import sys
@@ -360,3 +361,77 @@ 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 [
+        {"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 or ""},
+        {"property": "og:title", "content": title},
+        {"property": "og:type", "content": "website"},
+        {"property": "og:description", "content": description},
+        {"property": "og:image", "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/_utils.py b/dash/_utils.py
--- 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
--- 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
 
@@ -64,9 +63,10 @@
 
 from . import _pages
 from ._pages import (
-    _infer_module_name,
-    _parse_path_variables,
     _parse_query_string,
+    _page_meta_tags,
+    _path_to_page,
+    _import_layouts_from_pages,
 )
 
 # 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(
             [
@@ -854,67 +859,24 @@ def _generate_config_html(self):
     def _generate_renderer(self):
         return f'<script id="_dash-renderer" type="application/javascript">{self.renderer}</script>'
 
-    def _generate_meta_html(self):
-        meta_tags = self.config.meta_tags
+    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)
 
-        tags = []
         if not has_ie_compat:
-            tags.append('<meta http-equiv="X-UA-Compatible" content="IE=edge">')
+            meta_tags.append({"http-equiv": "X-UA-Compatible", "content": "IE=edge"})
         if not has_charset:
-            tags.append('<meta charset="UTF-8">')
+            meta_tags.append({"charset": "UTF-8"})
         if not has_viewport:
-            tags.append(
-                '<meta name="viewport" content="width=device-width, initial-scale=1">'
+            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)
-
-    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"""
-            <meta name="description" content="{description}" />
-            <!-- Twitter Card data -->
-            <meta property="twitter:card" content="summary_large_image">
-            <meta property="twitter:url" content="{flask.request.url}">
-            <meta property="twitter:title" content="{title}">
-            <meta property="twitter:description" content="{description}">
-            <meta property="twitter:image" content="{image_url}">
-            <!-- Open Graph data -->
-            <meta property="og:title" content="{title}" />
-            <meta property="og:type" content="website" />
-            <meta property="og:description" content="{description}" />
-            <meta property="og:image" content="{image_url}">
-            """
-        )
+        return meta_tags + self.config.meta_tags
 
     # Serve the JS bundles for each package
     def serve_component_suites(self, package_name, fingerprinted_path):
@@ -959,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:
-            pages_metas = self._pages_meta_tags()
+
+        if self.use_pages and self.config.include_pages_meta:
+            metas = _page_meta_tags(self) + metas
 
         if self._favicon:
             favicon_mod_time = os.path.getmtime(
@@ -983,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,
@@ -1988,57 +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"
-                    )
-
-    @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
         if self.pages_folder:
-            self._import_layouts_from_pages()
+            _import_layouts_from_pages(self.config.pages_folder)
 
         @self.server.before_request
         def router():
@@ -2060,9 +1980,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 == {}:


Test patch:
diff --git a/tests/integration/security/test_injection.py b/tests/integration/security/test_injection.py
new file mode 100644
--- /dev/null
+++ b/tests/integration/security/test_injection.py
@@ -0,0 +1,29 @@
+import requests
+
+from dash import Dash, html, register_page
+
+injection_script = "<scRipt>console.error(0x000F45)</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}/?'\"--></style></scRipt>{injection_script}"
+    dash_duo.server_url = url
+
+    assert dash_duo.get_logs() == []
+
+    ret = requests.get(url)
+
+    assert injection_script not in ret.text