Skip to content

Commit

Permalink
Add markupsafe.Markup XSS plugin (#1225)
Browse files Browse the repository at this point in the history
* Add markupsafe.Markup XSS plugin

* Apply suggestions from code review

Co-authored-by: Eric Brown <ericwb@users.noreply.github.com>

---------

Co-authored-by: Eric Brown <ericwb@users.noreply.github.com>
  • Loading branch information
Daverball and ericwb authored Feb 4, 2025
1 parent 6133e08 commit 5e3e694
Show file tree
Hide file tree
Showing 8 changed files with 216 additions and 8 deletions.
4 changes: 4 additions & 0 deletions bandit/core/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,7 @@ def filename(self):
@property
def file_data(self):
return self._context.get("file_data")

@property
def import_aliases(self):
return self._context.get("import_aliases")
118 changes: 118 additions & 0 deletions bandit/plugins/markupsafe_markup_xss.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Copyright (c) 2025 David Salvisberg
#
# SPDX-License-Identifier: Apache-2.0
r"""
============================================
B704: Potential XSS on markupsafe.Markup use
============================================
``markupsafe.Markup`` does not perform any escaping, so passing dynamic
content, like f-strings, variables or interpolated strings will potentially
lead to XSS vulnerabilities, especially if that data was submitted by users.
Instead you should interpolate the resulting ``markupsafe.Markup`` object,
which will perform escaping, or use ``markupsafe.escape``.
**Config Options:**
This plugin allows you to specify additional callable that should be treated
like ``markupsafe.Markup``. By default we recognize ``flask.Markup`` as
an alias, but there are other subclasses or similar classes in the wild
that you may wish to treat the same.
Additionally there is a whitelist for callable names, whose result may
be safely passed into ``markupsafe.Markup``. This is useful for escape
functions like e.g. ``bleach.clean`` which don't themselves return
``markupsafe.Markup``, so they need to be wrapped. Take care when using
this setting, since incorrect use may introduce false negatives.
These two options can be set in a shared configuration section
`markupsafe_xss`.
.. code-block:: yaml
markupsafe_xss:
# Recognize additional aliases
extend_markup_names:
- webhelpers.html.literal
- my_package.Markup
# Allow the output of these functions to pass into Markup
allowed_calls:
- bleach.clean
- my_package.sanitize
:Example:
.. code-block:: none
>> Issue: [B704:markupsafe_markup_xss] Potential XSS with
``markupsafe.Markup`` detected. Do not use ``Markup``
on untrusted data.
Severity: Medium Confidence: High
CWE: CWE-79 (https://cwe.mitre.org/data/definitions/79.html)
Location: ./examples/markupsafe_markup_xss.py:5:0
4 content = "<script>alert('Hello, world!')</script>"
5 Markup(f"unsafe {content}")
6 flask.Markup("unsafe {}".format(content))
.. seealso::
- https://pypi.org/project/MarkupSafe/
- https://markupsafe.palletsprojects.com/en/stable/escaping/#markupsafe.Markup
- https://cwe.mitre.org/data/definitions/79.html
.. versionadded:: 1.8.3
"""
import ast

import bandit
from bandit.core import issue
from bandit.core import test_properties as test
from bandit.core.utils import get_call_name


def gen_config(name):
if name == "markupsafe_xss":
return {
"extend_markup_names": [],
"allowed_calls": [],
}


@test.takes_config("markupsafe_xss")
@test.checks("Call")
@test.test_id("B704")
def markupsafe_markup_xss(context, config):

qualname = context.call_function_name_qual
if qualname not in ("markupsafe.Markup", "flask.Markup"):
if qualname not in config.get("extend_markup_names", []):
# not a Markup call
return None

args = context.node.args
if not args or isinstance(args[0], ast.Constant):
# both no arguments and a constant are fine
return None

allowed_calls = config.get("allowed_calls", [])
if (
allowed_calls
and isinstance(args[0], ast.Call)
and get_call_name(args[0], context.import_aliases) in allowed_calls
):
# the argument contains a whitelisted call
return None

return bandit.Issue(
severity=bandit.MEDIUM,
confidence=bandit.HIGH,
cwe=issue.Cwe.XSS,
text=f"Potential XSS with ``{qualname}`` detected. Do "
f"not use ``{context.call_function_name}`` on untrusted data.",
)
5 changes: 5 additions & 0 deletions doc/source/plugins/b704_markupsafe_markup_xss.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---------------------------
B704: markupsafe_markup_xss
---------------------------

.. automodule:: bandit.plugins.markupsafe_markup_xss
13 changes: 13 additions & 0 deletions examples/markupsafe_markup_xss.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import flask
from markupsafe import Markup, escape

content = "<script>alert('Hello, world!')</script>"
Markup(f"unsafe {content}") # B704
flask.Markup("unsafe {}".format(content)) # B704
Markup("safe {}").format(content)
flask.Markup(b"safe {}", encoding='utf-8').format(content)
escape(content)
Markup(content) # B704
flask.Markup("unsafe %s" % content) # B704
Markup(object="safe")
Markup(object="unsafe {}".format(content)) # Not currently detected
9 changes: 9 additions & 0 deletions examples/markupsafe_markup_xss_allowed_calls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from bleach import clean
from markupsafe import Markup

content = "<script>alert('Hello, world!')</script>"
Markup(clean(content))

# indirect assignments are currently not supported
cleaned = clean(content)
Markup(cleaned)
6 changes: 6 additions & 0 deletions examples/markupsafe_markup_xss_extend_markup_names.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from markupsafe import Markup
from webhelpers.html import literal

content = "<script>alert('Hello, world!')</script>"
Markup(f"unsafe {content}")
literal(f"unsafe {content}")
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,9 @@ bandit.plugins =
# bandit/plugins/trojansource.py
trojansource = bandit.plugins.trojansource:trojansource

# bandit/plugins/markupsafe_markup_xss.py
markupsafe_markup_xss = bandit.plugins.markupsafe_markup_xss:markupsafe_markup_xss

[build_sphinx]
all_files = 1
build-dir = doc/build
Expand Down
66 changes: 58 additions & 8 deletions tests/functional/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
#
# SPDX-License-Identifier: Apache-2.0
import os
from contextlib import contextmanager

import testtools

Expand Down Expand Up @@ -33,6 +34,18 @@ def setUp(self):
self.b_mgr.b_conf._settings["plugins_dir"] = path
self.b_mgr.b_ts = b_test_set.BanditTestSet(config=b_conf)

@contextmanager
def with_test_set(self, ts):
"""A helper context manager to change the test set without
side-effects for any follow-up tests.
"""
orig_ts = self.b_mgr.b_ts
self.b_mgr.b_ts = ts
try:
yield
finally:
self.b_mgr.b_ts = orig_ts

def run_example(self, example_script, ignore_nosec=False):
"""A helper method to run the specified test
Expand Down Expand Up @@ -526,21 +539,25 @@ def test_django_xss_secure(self):
"SEVERITY": {"UNDEFINED": 0, "LOW": 0, "MEDIUM": 0, "HIGH": 0},
"CONFIDENCE": {"UNDEFINED": 0, "LOW": 0, "MEDIUM": 0, "HIGH": 0},
}
self.b_mgr.b_ts = b_test_set.BanditTestSet(
config=self.b_mgr.b_conf, profile={"exclude": ["B308"]}
)
self.check_example("mark_safe_secure.py", expect)
with self.with_test_set(
b_test_set.BanditTestSet(
config=self.b_mgr.b_conf, profile={"exclude": ["B308"]}
)
):
self.check_example("mark_safe_secure.py", expect)

def test_django_xss_insecure(self):
"""Test for Django XSS via django.utils.safestring"""
expect = {
"SEVERITY": {"UNDEFINED": 0, "LOW": 0, "MEDIUM": 29, "HIGH": 0},
"CONFIDENCE": {"UNDEFINED": 0, "LOW": 0, "MEDIUM": 0, "HIGH": 29},
}
self.b_mgr.b_ts = b_test_set.BanditTestSet(
config=self.b_mgr.b_conf, profile={"exclude": ["B308"]}
)
self.check_example("mark_safe_insecure.py", expect)
with self.with_test_set(
b_test_set.BanditTestSet(
config=self.b_mgr.b_conf, profile={"exclude": ["B308"]}
)
):
self.check_example("mark_safe_insecure.py", expect)

def test_xml(self):
"""Test xml vulnerabilities."""
Expand Down Expand Up @@ -876,3 +893,36 @@ def test_trojansource_latin1(self):
"CONFIDENCE": {"UNDEFINED": 0, "LOW": 0, "MEDIUM": 0, "HIGH": 0},
}
self.check_example("trojansource_latin1.py", expect)

def test_markupsafe_markup_xss(self):
expect = {
"SEVERITY": {"UNDEFINED": 0, "LOW": 0, "MEDIUM": 4, "HIGH": 0},
"CONFIDENCE": {"UNDEFINED": 0, "LOW": 0, "MEDIUM": 0, "HIGH": 4},
}
self.check_example("markupsafe_markup_xss.py", expect)

def test_markupsafe_markup_xss_extend_markup_names(self):
expect = {
"SEVERITY": {"UNDEFINED": 0, "LOW": 0, "MEDIUM": 2, "HIGH": 0},
"CONFIDENCE": {"UNDEFINED": 0, "LOW": 0, "MEDIUM": 0, "HIGH": 2},
}
b_conf = b_config.BanditConfig()
b_conf.config["markupsafe_xss"] = {
"extend_markup_names": ["webhelpers.html.literal"]
}
with self.with_test_set(b_test_set.BanditTestSet(config=b_conf)):
self.check_example(
"markupsafe_markup_xss_extend_markup_names.py", expect
)

def test_markupsafe_markup_xss_allowed_calls(self):
expect = {
"SEVERITY": {"UNDEFINED": 0, "LOW": 0, "MEDIUM": 1, "HIGH": 0},
"CONFIDENCE": {"UNDEFINED": 0, "LOW": 0, "MEDIUM": 0, "HIGH": 1},
}
b_conf = b_config.BanditConfig()
b_conf.config["markupsafe_xss"] = {"allowed_calls": ["bleach.clean"]}
with self.with_test_set(b_test_set.BanditTestSet(config=b_conf)):
self.check_example(
"markupsafe_markup_xss_allowed_calls.py", expect
)

0 comments on commit 5e3e694

Please sign in to comment.