diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5005d9c..727370a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,10 +40,10 @@ jobs: python-version: "3.10" - name: Install requirements - run: python -m pip install wheel + run: python -m pip install wheel setuptools build - name: Build a distribution - run: python setup.py sdist bdist_wheel + run: python -m build - name: Publish package to TestPyPI uses: pypa/gh-action-pypi-publish@master diff --git a/CHANGELOG.md b/CHANGELOG.md index 809e3fa..6f2ad17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Changelog --------- Unreleased +- (SECURITY) Use [defusedxml](https://github.com/tiran/defusedxml) to prevent XML SAX vulnerabilities ([#93](https://github.com/stchris/untangle/issues/93)) 1.2.0 - (SECURITY) Prevent XML SAX vulnerability: External Entities injection ([#60](https://github.com/stchris/untangle/issues/60)) diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..bb3ec5f --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include README.md diff --git a/poetry.lock b/poetry.lock index 3c78283..778c68b 100644 --- a/poetry.lock +++ b/poetry.lock @@ -43,6 +43,27 @@ d = ["aiohttp (>=3.7.4)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "build" +version = "0.8.0" +description = "A simple, correct PEP 517 build frontend" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +colorama = {version = "*", markers = "os_name == \"nt\""} +importlib-metadata = {version = ">=0.22", markers = "python_version < \"3.8\""} +packaging = ">=19.0" +pep517 = ">=0.9.1" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2021.08.31)", "sphinx (>=4.0,<5.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)"] +test = ["filelock (>=3)", "pytest (>=6.2.4)", "pytest-cov (>=2.12)", "pytest-mock (>=2)", "pytest-rerunfailures (>=9.1)", "pytest-xdist (>=1.34)", "toml (>=0.10.0)", "wheel (>=0.36.0)", "setuptools (>=42.0.0)", "setuptools (>=56.0.0)"] +typing = ["importlib-metadata (>=4.6.4)", "mypy (==0.950)", "typing-extensions (>=3.7.4.3)"] +virtualenv = ["virtualenv (>=20.0.35)"] + [[package]] name = "click" version = "8.1.3" @@ -63,6 +84,14 @@ category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +[[package]] +name = "defusedxml" +version = "0.7.1" +description = "XML bomb protection for Python stdlib modules" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" + [[package]] name = "flake8" version = "4.0.1" @@ -136,6 +165,19 @@ category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +[[package]] +name = "pep517" +version = "0.12.0" +description = "Wrappers to build Python packages using PEP 517 hooks" +category = "dev" +optional = false +python-versions = "*" + +[package.dependencies] +importlib_metadata = {version = "*", markers = "python_version < \"3.8\""} +tomli = {version = ">=1.1.0", markers = "python_version >= \"3.6\""} +zipp = {version = "*", markers = "python_version < \"3.8\""} + [[package]] name = "platformdirs" version = "2.5.2" @@ -259,7 +301,7 @@ testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest- [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "980a1e2ae04af56a26f5af5eaee75f27aed265f6fabd1063878f02cbf9ff1211" +content-hash = "b27722b23f8379a524e0e979e75811a164f0b50a672c44b764c020e1b7779fc4" [metadata.files] atomicwrites = [ @@ -295,6 +337,10 @@ black = [ {file = "black-22.6.0-py3-none-any.whl", hash = "sha256:ac609cf8ef5e7115ddd07d85d988d074ed00e10fbc3445aee393e70164a2219c"}, {file = "black-22.6.0.tar.gz", hash = "sha256:6c6d39e28aed379aec40da1c65434c77d75e65bb59a1e1c283de545fb4e7c6c9"}, ] +build = [ + {file = "build-0.8.0-py3-none-any.whl", hash = "sha256:19b0ed489f92ace6947698c3ca8436cb0556a66e2aa2d34cd70e2a5d27cd0437"}, + {file = "build-0.8.0.tar.gz", hash = "sha256:887a6d471c901b1a6e6574ebaeeebb45e5269a79d095fe9a8f88d6614ed2e5f0"}, +] click = [ {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, @@ -303,6 +349,10 @@ colorama = [ {file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"}, {file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"}, ] +defusedxml = [ + {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, + {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, +] flake8 = [ {file = "flake8-4.0.1-py2.py3-none-any.whl", hash = "sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d"}, {file = "flake8-4.0.1.tar.gz", hash = "sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"}, @@ -331,6 +381,10 @@ pathspec = [ {file = "pathspec-0.9.0-py2.py3-none-any.whl", hash = "sha256:7d15c4ddb0b5c802d161efc417ec1a2558ea2653c2e8ad9c19098201dc1c993a"}, {file = "pathspec-0.9.0.tar.gz", hash = "sha256:e564499435a2673d586f6b2130bb5b95f04a3ba06f81b8f895b651a3c76aabb1"}, ] +pep517 = [ + {file = "pep517-0.12.0-py2.py3-none-any.whl", hash = "sha256:dd884c326898e2c6e11f9e0b64940606a93eb10ea022a2e067959f3a110cf161"}, + {file = "pep517-0.12.0.tar.gz", hash = "sha256:931378d93d11b298cf511dd634cf5ea4cb249a28ef84160b3247ee9afb4e8ab0"}, +] platformdirs = [ {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, diff --git a/pyproject.toml b/pyproject.toml index 0690c68..d58c6f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,19 @@ version = "1.2.0" description = "Converts XML to Python objects" authors = ["Christian Stefanescu "] license = "MIT" +readme = "README.md" [tool.poetry.dependencies] python = "^3.7" +defusedxml = "^0.7.1" [tool.poetry.dev-dependencies] pytest = "^7.1.2" flake8 = "^4.0.1" black = "^22.6.0" +build = "^0.8.0" +setuptools = "^62.6.0" +wheel = "^0.37.1" [build-system] requires = ["poetry-core>=1.0.0"] diff --git a/setup.py b/setup.py index b063ceb..13ea21a 100755 --- a/setup.py +++ b/setup.py @@ -3,19 +3,24 @@ import untangle -from setuptools import setup +from setuptools import setup, find_packages from pathlib import Path +long_description = (Path(__file__).parent / "README.md").read_text() + setup( name="untangle", + packages=find_packages(), version=untangle.__version__, description="Convert XML documents into Python objects", long_description_content_type="text/markdown", - long_description=(Path(__file__).parent / "README.md").read_text(), + long_description=long_description, author="Christian Stefanescu", author_email="hello@stchris.net", url="http://github.com/stchris//untangle", py_modules=["untangle"], + install_requires=["defusedxml"], + include_package_data=True, license="MIT", classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/test_untangle.py b/tests/test_untangle.py index 8d279d7..aeab073 100755 --- a/tests/test_untangle.py +++ b/tests/test_untangle.py @@ -5,6 +5,8 @@ import untangle import xml +import defusedxml + class FromStringTestCase(unittest.TestCase): """Basic parsing tests with input as string""" @@ -364,16 +366,16 @@ class ParserFeatureTestCase(unittest.TestCase): def test_valid_feature(self): # xml.sax.handler.feature_external_ges -> load external general (text) # entities, such as DTDs - doc = untangle.parse(self.bad_dtd_xml, feature_external_ges=False) - self.assertEqual(doc.foo["bar"], "baz") + with self.assertRaises(defusedxml.common.ExternalReferenceForbidden): + untangle.parse(self.bad_dtd_xml) def test_invalid_feature(self): with self.assertRaises(AttributeError): untangle.parse(self.bad_dtd_xml, invalid_feature=True) def test_invalid_external_dtd(self): - with self.assertRaises(IOError): - untangle.parse(self.bad_dtd_xml, feature_external_ges=True) + with self.assertRaises(defusedxml.common.ExternalReferenceForbidden): + untangle.parse(self.bad_dtd_xml) class TestEquals(unittest.TestCase): @@ -393,11 +395,11 @@ def test_list_equals(self): class TestExternalEntityExpansion(unittest.TestCase): def test_xxe(self): # from https://pypi.org/project/defusedxml/#external-entity-expansion-remote - o = untangle.parse("tests/res/xxe.xml") - assert o.root.cdata == "" + with self.assertRaises(defusedxml.common.EntitiesForbidden): + untangle.parse("tests/res/xxe.xml") if __name__ == "__main__": unittest.main() -# vim: set expandtab ts=4 sw=4: +# vim: set expandtab ts=4 sw=4 diff --git a/untangle.py b/untangle.py index fad0819..d15d69f 100755 --- a/untangle.py +++ b/untangle.py @@ -15,8 +15,8 @@ """ import os import keyword -from xml.sax import make_parser, handler -from xml.sax.handler import feature_external_ges +from defusedxml.sax import make_parser +from xml.sax import handler try: @@ -188,12 +188,15 @@ def parse(filename, **parser_features): Raises ``xml.sax.SAXParseException`` if something goes wrong during parsing. + + Raises ``defusedxml.common.EntitiesForbidden`` + or ``defusedxml.common.ExternalReferenceForbidden`` + when a potentially malicious entity load is attempted. See also + https://github.com/tiran/defusedxml#attack-vectors """ if filename is None or (is_string(filename) and filename.strip()) == "": raise ValueError("parse() takes a filename, URL or XML string") parser = make_parser() - # See https://github.com/stchris/untangle/issues/60 - parser.setFeature(feature_external_ges, False) for feature, value in parser_features.items(): parser.setFeature(getattr(handler, feature), value) sax_handler = Handler()