From 0812a4125598b11be35d8814116538f97f6c4367 Mon Sep 17 00:00:00 2001 From: Diogo Fernandes Date: Mon, 9 Nov 2026 13:55:56 -0300 Subject: [PATCH 1/2] fix: security issues --- .github/workflows/pypi.yml | 2 +- .github/workflows/tests.yml | 4 +- HISTORY.rst | 29 +++- README.md | 139 +++++++++++++------ html4docx/constants.py | 9 ++ html4docx/h4d.py | 14 +- html4docx/resources.py | 263 ++++++++++++++++++++++++++++++++++++ html4docx/utils.py | 75 ---------- pyproject.toml | 3 + requirements_test.txt | 1 + setup.cfg | 1 + setup.py | 3 +- tests/test_css_features.py | 18 ++- tests/test_css_parser.py | 2 +- tests/test_h4d.py | 2 + tests/test_security.py | 216 +++++++++++++++++++++++++++++ 16 files changed, 646 insertions(+), 135 deletions(-) create mode 100644 html4docx/resources.py create mode 100644 tests/test_security.py diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 1927456..5c3051e 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -76,7 +76,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c829e02..5db40cb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false # IMPORTANT: run all Python versions even if one fails matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] steps: - uses: actions/checkout@v4 @@ -34,7 +34,7 @@ jobs: ruff check . --output-format=concise - name: Run tests run: | - python -m pytest + python -m pytest -n auto --dist loadscope - name: Upload test.docx uses: actions/upload-artifact@v4 with: diff --git a/HISTORY.rst b/HISTORY.rst index 63898ed..c07f8f7 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,17 +3,38 @@ Release History --------------- -1.3.0 (2026-xx-xx) +1.3.0 (unreleased) ++++++++++++++++++ +**Security** + +- Fixed SSRF (CWE-918) and arbitrary local file read (CWE-73 / CWE-22) when converting untrusted HTML through ```` and ````. All versions up to and including 1.2.1 are affected. See `GHSA-xh3w-67hm-58x9 `_ . | `dfop02 `_ + + - Remote fetches are restricted to ``http`` and ``https``. ``file://``, ``ftp://`` and other schemes are refused, including through redirects. + - Connections to non-public IPs (loopback, private, link-local/cloud metadata, reserved, multicast) are refused. The check runs on the connected IP for every redirect hop, which also prevents DNS rebinding. + - Local files are confined to ``local-files-root``, with ``..`` and symlinks resolved first. + - Each resource is capped at ``max-resource-bytes``. + +**Breaking changes** + +- Local files (paths and ``file://``) are disabled by default. Set ``parser.options['local-files-root']`` to the directory that may be read. Relative paths are now resolved against it instead of the current working directory. +- Resources on private network addresses (e.g. intranet servers) are blocked by default. Add them to ``parser.options['private-network-allowlist']``. +- ``HTTP_PROXY`` / ``HTTPS_PROXY`` environment variables are no longer honored when fetching remote resources. + **Updates** -- None + +- Resource fetching moved from ``html4docx.utils`` to the new ``html4docx.resources`` module. ``utils.fetch_image``, ``utils.fetch_image_data`` and ``utils.fetch_external_css`` are still importable for backward compatibility and accept an optional ``policy`` argument. | `dfop02 `_ +- README: options reorganized, new external resources security and migration sections, and outdated examples fixed. | `dfop02 `_ +- Add ``pytest-xdist`` to ``pyproject.toml`` and ``requirements_test.txt`` to speed up tests on CI. | `dfop02 `_ +- Add Python 3.15 to the CI tests matrix and add 3.15 to the package classifiers. | `dfop02 `_ **Fixes** -- None + +- Malformed ``data:image`` URIs no longer raise an exception; the image is skipped and a warning is logged. | `dfop02 `_ **New Features** -- None + +- New options: ``allow-remote-resources``, ``allow-private-network``, ``private-network-allowlist``, ``local-files-root`` and ``max-resource-bytes``. | `dfop02 `_ 1.2.1 (2026-10-19) ++++++++++++++++++ diff --git a/README.md b/README.md index 56efd92..6c36925 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ ![Version](https://img.shields.io/pypi/v/html-for-docx.svg) ![Supported Versions](https://img.shields.io/pypi/pyversions/html-for-docx.svg) -Convert html to docx, this project is a fork from descontinued [pqzx/html2docx](https://github.com/pqzx/html2docx). +Convert html to docx, this project is a fork from the discontinued [pqzx/html2docx](https://github.com/pqzx/html2docx). -## How install +## How to install `pip install html-for-docx` @@ -17,11 +17,14 @@ Convert html to docx, this project is a fork from descontinued [pqzx/html2docx]( Add HTML-formatted content to an existing `.docx` document ```python +from docx import Document from html4docx import HtmlToDocx +document = Document('existing_file.docx') parser = HtmlToDocx() html_string = '

Hello world

' -parser.add_html_to_document(html_string, filename_docx) +parser.add_html_to_document(html_string, document) +document.save('existing_file.docx') ``` You can use `python-docx` to manipulate directly the file, here an example @@ -54,9 +57,9 @@ for part in ['First', 'Second', 'Third']: parser.save('your_file_name.docx') ``` -When you pass a `Document` object, you can either use `document.save()` from python-docx or `parser.save()` from html4docx, both works well. +When you pass a `Document` object, you can either use `document.save()` from python-docx or `parser.save()` from html4docx, both work well. -Both supports saving it in-memory, using `BytesIO`. +Both support saving it in-memory, using `BytesIO`. ```python from io import BytesIO @@ -89,6 +92,15 @@ parser.parse_html_file(input_html_file_path, output_docx_file_path) parser.parse_html_file(input_html_file_path, output_docx_file_path, 'utf-8') ``` +If the HTML file references local images or stylesheets, allow the folder that holds them (see [External resources security](#external-resources-security)): + +```python +import os + +parser.options['local-files-root'] = os.path.dirname(os.path.abspath(input_html_file_path)) +parser.parse_html_file(input_html_file_path, output_docx_file_path) +``` + #### Convert files from a string ```python @@ -120,31 +132,62 @@ All table styles we support can be found [here](https://python-docx.readthedocs. ## Options -There is 7 options that you can use to personalize your execution: -- Disable Images: Ignore all images. -- Disable Tables: If you do it, it will render just the raw tables content -- Disable Styles: Ignore all CSS styles. Also disables Style-Map. -- Disable Fix-HTML: Use BeautifulSoap to Fix possible HTML missing tags. -- Disable Style-Map: Ignore CSS classes to word styles mapping -- Disable Tag-Override: Ignore html tag overrides. -- Disable HTML-Comments: Ignore all "" comments from HTML. - -This is how you could disable them if you want: +All options are set through `parser.options` before parsing. Copy only the ones you need to change: ```python from html4docx import HtmlToDocx parser = HtmlToDocx() -parser.options['images'] = False # Default True -parser.options['tables'] = False # Default True -parser.options['styles'] = False # Default True -parser.options['fix-html'] = False # Default True -parser.options['html-comments'] = False # Default False -parser.options['style-map'] = False # Default True -parser.options['tag-override'] = False # Default True + +# --- Rendering --- +parser.options['images'] = False # Default True. False ignores all images. +parser.options['tables'] = False # Default True. False renders only the raw table content. +parser.options['styles'] = False # Default True. False ignores all CSS (also disables 'style-map'). +parser.options['fix-html'] = False # Default True. False skips BeautifulSoup fixing of missing HTML tags. +parser.options['style-map'] = False # Default True. False ignores CSS class to Word style mapping. +parser.options['tag-override'] = False # Default True. False ignores HTML tag overrides. +parser.options['html-comments'] = True # Default False. True renders "" comments. + +# --- External resources security (see the section below) --- +parser.options['allow-remote-resources'] = False # Default True. False skips all http(s) images and stylesheets. +parser.options['private-network-allowlist'] = ["10.0.5.20", "192.168.10.0/24"] # Default []. Private IPs/CIDRs that may be fetched. +parser.options['local-files-root'] = '/srv/app/assets' # Default None. Directory local files may be read from (None = disabled). +parser.options['max-resource-bytes'] = 5 * 1024 * 1024 # Default 10 MB. Maximum size of each fetched resource. +parser.options['allow-private-network'] = False # Default False. True allows EVERY private address and ignores the allowlist: trusted HTML only! + docx = parser.parse_html_string(input_html_file_string) ``` +### External resources security + +HTML is often untrusted input, so images (``) and stylesheets (``) are fetched under a restrictive policy by default: + +- **Remote resources:** only `http`/`https` are fetched. `file://`, `ftp://` and any other scheme are always refused. Inline images (`data:image/...`) keep working. +- **Private network:** requests to non-public addresses (loopback, private ranges, link-local such as the cloud metadata endpoint `169.254.169.254`, reserved, multicast) are refused. The check is applied to the IP actually connected to, on every redirect hop, so DNS rebinding and open redirects can't bypass it. +- **Local files:** disabled by default. When `local-files-root` is set, only files inside that directory (after resolving `..` and symlinks) can be read, and relative paths are resolved against it. +- **Size:** each resource, remote or local, is limited to `max-resource-bytes`. + +**Reaching an internal server.** Add its addresses to `private-network-allowlist`. Only those IPs/CIDRs are opened, and everything else stays blocked. Public addresses (CDNs, websites) never need to be listed. Keep entries as narrow as possible: a broad range such as `169.254.0.0/16` also opens cloud metadata endpoints. Invalid entries raise `ValueError` instead of being silently ignored. `allow-private-network` opens every private address at once and should only be used when the HTML is fully trusted. + +> **Converting HTML from users?** Keep `allow-private-network` disabled, keep `private-network-allowlist` as small as possible and never point `local-files-root` at a directory containing sensitive files. If you don't need external images or stylesheets at all, set `allow-remote-resources` to `False`. + +Blocked or unavailable resources are skipped and a warning is logged (logger `html4docx.resources`); the conversion itself does not fail. + +#### Migrating from <=1.2.x + +Public `http`/`https` URLs and `data:image/...` images work as before. What changed: + +- **Local paths** in `` and `` are disabled by default. Set `local-files-root` to the folder that holds your assets. Relative paths are now resolved against it, not the current working directory: + +```python +import os + +parser.options['local-files-root'] = os.path.abspath('./assets') +``` + +- **Private network addresses** (e.g. an intranet server) are blocked. Add them to `private-network-allowlist`. +- **Proxies:** `HTTP_PROXY` / `HTTPS_PROXY` environment variables are ignored, because routing through a proxy would bypass the private network protection. If your server can only reach the internet through a proxy, remote resources will not load. Embed images as `data:image/...` URIs, put CSS in an inline `