CVE-2022-2514: Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') (CWE-79)
value: The time and filter parameters in Fava prior to v1.22 are vulnerable to reflected XSS due to the lack of escaping of error messages which contained the parameters in verbatim.
CWE description: The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
CVSS v3 base score: 6.1
Published: 2022-07-25T14:15Z
Repository: https://github.com/beancount/fava
Language: Python
Files changed: src/fava/application.py, src/fava/core/file.py, src/fava/template_filters.py, src/fava/templates/_layout.html, src/fava/templates/errors.html, src/fava/templates/help.html
Commit date: 2022-07-03 11:23:55 +0200
Commit message: avoid use of |safe filter in templates

Explicitly mark those few places where we need to pass in data that
should not be escaped on the Python side.

Diff: diff --git a/src/fava/application.py b/src/fava/application.py
index 45bc5e31..4dd454bb 100644
--- a/src/fava/application.py
+++ b/src/fava/application.py
@@ -33,10 +33,11 @@ from flask import render_template_string
 from flask import request
 from flask import send_file
 from flask.wrappers import Response
 from flask_babel import Babel  # type: ignore
 from flask_babel import get_translations
+from markupsafe import Markup
 from werkzeug.utils import secure_filename
 
 from fava import __version__ as fava_version
 from fava import LANGUAGES
 from fava import template_filters
@@ -382,14 +383,16 @@ def help_page(page_slug: str) -> str:
     )
     return render_template(
         "_layout.html",
         active_page="help",
         page_slug=page_slug,
-        help_html=render_template_string(
-            html,
-            beancount_version=beancount_version,
-            fava_version=fava_version,
+        help_html=Markup(
+            render_template_string(
+                html,
+                beancount_version=beancount_version,
+                fava_version=fava_version,
+            )
         ),
         HELP_PAGES=HELP_PAGES,
     )
 
 
diff --git a/src/fava/core/file.py b/src/fava/core/file.py
index 74a07425..b2b9d753 100644
--- a/src/fava/core/file.py
+++ b/src/fava/core/file.py
@@ -20,10 +20,11 @@ from beancount.core.flags import FLAG_CONVERSIONS
 from beancount.core.flags import FLAG_MERGING
 from beancount.core.flags import FLAG_PADDING
 from beancount.core.flags import FLAG_SUMMARIZE
 from beancount.core.flags import FLAG_TRANSFER
 from beancount.parser.printer import format_entry  # type: ignore
+from markupsafe import Markup
 
 from fava.core._compat import FLAG_RETURNS
 from fava.core._compat import FLAG_UNREALIZED
 from fava.core.accounts import get_entry_accounts
 from fava.core.fava_options import InsertEntryOption
@@ -174,11 +175,13 @@ class FileModule(FavaModule):
                     currency_column,
                     indent,
                 )
                 self.ledger.extensions.after_insert_entry(entry)
 
-    def render_entries(self, entries: Entries) -> Generator[str, None, None]:
+    def render_entries(
+        self, entries: Entries
+    ) -> Generator[Markup, None, None]:
         """Return entries in Beancount format.
 
         Only renders :class:`.Balance` and :class:`.Transaction`.
 
         Args:
@@ -191,16 +194,18 @@ class FileModule(FavaModule):
         for entry in entries:
             if isinstance(entry, (Balance, Transaction)):
                 if isinstance(entry, Transaction) and entry.flag in EXCL_FLAGS:
                     continue
                 try:
-                    yield get_entry_slice(entry)[0] + "\n"
+                    yield Markup(get_entry_slice(entry)[0] + "\n")
                 except (KeyError, FileNotFoundError):
-                    yield _format_entry(
-                        entry,
-                        self.ledger.fava_options.currency_column,
-                        indent,
+                    yield Markup(
+                        _format_entry(
+                            entry,
+                            self.ledger.fava_options.currency_column,
+                            indent,
+                        )
                     )
 
 
 def incomplete_sortkey(entry: Directive) -> tuple[datetime.date, int]:
     """Sortkey for entries that might have incomplete metadata."""
diff --git a/src/fava/template_filters.py b/src/fava/template_filters.py
index 43876aac..713ceadc 100644
--- a/src/fava/template_filters.py
+++ b/src/fava/template_filters.py
@@ -10,18 +10,19 @@ import re
 import unicodedata
 from typing import Any
 from typing import MutableMapping
 from typing import TypeVar
 
-import flask
 from beancount.core import compare
 from beancount.core import realization
 from beancount.core.account import ACCOUNT_RE
 from beancount.core.data import Directive
 from beancount.core.inventory import Inventory
 from beancount.core.number import Decimal
 from beancount.core.number import ZERO
+from flask import url_for
+from markupsafe import Markup
 
 from fava.context import g
 from fava.core.conversion import cost
 from fava.core.conversion import cost_or_value as cost_or_value_without_context
 from fava.core.conversion import units
@@ -143,18 +144,18 @@ def should_show(account: TreeNode) -> bool:
 def basename(file_path: str) -> str:
     """Return the basename of a filepath."""
     return unicodedata.normalize("NFC", os.path.basename(file_path))
 
 
-def format_errormsg(message: str) -> str:
+def format_errormsg(message: str) -> Markup:
     """Match account names in error messages and insert HTML links for them."""
     match = re.search(ACCOUNT_RE, message)
     if not match:
-        return message
+        return Markup(message)
     account = match.group()
-    url = flask.url_for("account", name=account)
-    return (
+    url = url_for("account", name=account)
+    return Markup(
         message.replace(account, f'<a href="{url}">{account}</a>')
         .replace("for '", "for ")
         .replace("': ", ": ")
     )
 
diff --git a/src/fava/templates/_layout.html b/src/fava/templates/_layout.html
index d15d4c9f..5fd37859 100644
--- a/src/fava/templates/_layout.html
+++ b/src/fava/templates/_layout.html
@@ -41,11 +41,11 @@
     <article>
       {%- endif %}
       <svelte-component type="charts"></svelte-component>
       {% block content %}
       {% if content %}
-      {{ content|safe }}
+      {{ content }}
       {% else %}
       {% include active_page + '.html' %}
       {% endif %}
       {% endblock %}
       <script type="application/json" id="chart-data">{{ charts.chart_data|tojson }}</script>
diff --git a/src/fava/templates/errors.html b/src/fava/templates/errors.html
index 65eaa832..0a04db6a 100644
--- a/src/fava/templates/errors.html
+++ b/src/fava/templates/errors.html
@@ -11,11 +11,11 @@
     {% for error in ledger.errors %}
     <tr>
       {% with link=url_for_source(file_path=error.source['filename'], line=error.source['lineno']) %}
       <td><a class="source" href="{{ link }}" title="{{ _('Show source %(file)s:%(lineno)s', file=error.source['filename'], lineno=error.source['lineno']) }}">{{ error.source['filename'] }}</a></td>
       <td class="num"><a class="source" href="{{ link }}" title="{{ _('Show source %(file)s:%(lineno)s', file=error.source['filename'], lineno=error.source['lineno']) }}">{{ error.source['lineno'] }}</a></td>
-      <td class="pre">{{ error.message|format_errormsg|safe }}</td>
+      <td class="pre">{{ error.message|format_errormsg }}</td>
       {% endwith %}
     </tr>
     {% endfor %}
   </tbody>
 </table>
diff --git a/src/fava/templates/help.html b/src/fava/templates/help.html
index 04d3ad73..0ad1fdad 100644
--- a/src/fava/templates/help.html
+++ b/src/fava/templates/help.html
@@ -10,8 +10,8 @@
         </li>
       {% endfor %}
     </ul>
   </div>
   <div class="help-text">
-    {{ help_html|safe }}
+    {{ help_html }}
   </div>
 </div>

Vulnerable code:         help_html=render_template_string(
            html,
            beancount_version=beancount_version,
            fava_version=fava_version,
    def render_entries(self, entries: Entries) -> Generator[str, None, None]:
                    yield get_entry_slice(entry)[0] + "\n"
                    yield _format_entry(
                        entry,
                        self.ledger.fava_options.currency_column,
                        indent,
import flask
def format_errormsg(message: str) -> str:
        return message
    url = flask.url_for("account", name=account)
    return (
      {{ content|safe }}
      <td class="pre">{{ error.message|format_errormsg|safe }}</td>
    {{ help_html|safe }}
Fixed code: from markupsafe import Markup
        help_html=Markup(
            render_template_string(
                html,
                beancount_version=beancount_version,
                fava_version=fava_version,
            )
from markupsafe import Markup
    def render_entries(
        self, entries: Entries
    ) -> Generator[Markup, None, None]:
                    yield Markup(get_entry_slice(entry)[0] + "\n")
                    yield Markup(
                        _format_entry(
                            entry,
                            self.ledger.fava_options.currency_column,
                            indent,
                        )
from flask import url_for
from markupsafe import Markup
def format_errormsg(message: str) -> Markup:
        return Markup(message)
    url = url_for("account", name=account)
    return Markup(
      {{ content }}
      <td class="pre">{{ error.message|format_errormsg }}</td>
    {{ help_html }}