Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.10", "3.11", "3.12", "3.13", "3.14", "3.15"]

steps:
- uses: actions/checkout@v4
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.10", "3.11", "3.12", "3.13", "3.14", "3.15"]

steps:
- uses: actions/checkout@v4
Expand All @@ -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:
Expand Down
29 changes: 25 additions & 4 deletions HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<img src>`` and ``<link rel="stylesheet" href>``. All versions up to and including 1.2.1 are affected. See `GHSA-xh3w-67hm-58x9 <https://github.com/dfop02/html4docx/security/advisories/GHSA-xh3w-67hm-58x9>`_ . | `dfop02 <https://github.com/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 <https://github.com/dfop02>`_
- README: options reorganized, new external resources security and migration sections, and outdated examples fixed. | `dfop02 <https://github.com/dfop02>`_
- Add ``pytest-xdist`` to ``pyproject.toml`` and ``requirements_test.txt`` to speed up tests on CI. | `dfop02 <https://github.com/dfop02>`_
- Add Python 3.15 to the CI tests matrix and add 3.15 to the package classifiers. | `dfop02 <https://github.com/dfop02>`_

**Fixes**
- None

- Malformed ``data:image`` URIs no longer raise an exception; the image is skipped and a warning is logged. | `dfop02 <https://github.com/dfop02>`_

**New Features**
- None

- New options: ``allow-remote-resources``, ``allow-private-network``, ``private-network-allowlist``, ``local-files-root`` and ``max-resource-bytes``. | `dfop02 <https://github.com/dfop02>`_

1.2.1 (2026-10-19)
++++++++++++++++++
Expand Down
139 changes: 98 additions & 41 deletions README.md

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions html4docx/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
# Style to use with tables. By default no style is used.
DEFAULT_TABLE_STYLE = None

DEFAULT_MAX_RESOURCE_BYTES = 10 * 1024 * 1024
REMOTE_RESOURCE_SCHEMES = ("http", "https")

DEFAULT_OPTIONS = {
'fix-html': True,
'images': True,
Expand All @@ -18,6 +21,12 @@
'html-comments': False,
"style-map": True,
"tag-override": True,
# Security of external resources (<img src>, <link rel="stylesheet">)
"allow-remote-resources": True, # fetch http(s) resources
"allow-private-network": False, # allow loopback/private/link-local/metadata IPs (SSRF!)
"private-network-allowlist": [], # IPs/CIDRs allowed even when private, e.g. ["10.0.5.20/32"]
"local-files-root": None, # directory allowed for local files; None = local files disabled
"max-resource-bytes": 10 * 1024 * 1024,
}

DEFAULT_TABLE_ROW_SELECTORS = [
Expand Down
14 changes: 9 additions & 5 deletions html4docx/h4d.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from docx.oxml.ns import qn
from docx.shared import RGBColor

from html4docx import constants, utils
from html4docx import constants, resources, utils
from html4docx.css_parser import CSSParser
from html4docx.metadata import Metadata

Expand All @@ -37,6 +37,10 @@ def __init__(self, style_map=None, tag_style_overrides=None, default_paragraph_s
self.tag_style_overrides = tag_style_overrides or constants.DEFAULT_TAG_OVERRIDES
self.default_paragraph_style = default_paragraph_style or constants.DEFAULT_PARAGRAPH_STYLE

def _resource_policy(self) -> resources.ResourcePolicy:
"""Security policy for external resources, built from self.options."""
return resources.ResourcePolicy.from_options(self.options)

def set_initial_attrs(self, document=None):
self.tags = {
"span": [],
Expand Down Expand Up @@ -1230,7 +1234,7 @@ def handle_img(self, current_attrs):
width = utils.unit_converter(utils.add_px(current_attrs["width"])) if "width" in current_attrs else None

# fetch image
image = utils.fetch_image_data(src)
image = resources.fetch_image_data(src, self._resource_policy())

if not self.paragraph:
self.paragraph = self.doc.add_paragraph()
Expand Down Expand Up @@ -2121,7 +2125,7 @@ def _extract_and_parse_link_tags(self) -> None:
continue

# Fetch external CSS
css_content = utils.fetch_external_css(href)
css_content = resources.fetch_external_css(href, self._resource_policy())
if css_content:
# Use selective parsing to only load relevant rules
# This prevents loading thousands of unused CSS rules from frameworks
Expand All @@ -2140,7 +2144,7 @@ def _extract_link_tags_from_string(self, html: str) -> None:
for match in link_pattern.finditer(html):
href = match.group(1)
if href:
css_content = utils.fetch_external_css(href)
css_content = resources.fetch_external_css(href, self._resource_policy())
if css_content:
# Use selective parsing
self.css_parser.parse_css(css_content, selective=True)
Expand Down Expand Up @@ -2169,7 +2173,7 @@ def _extract_and_parse_css_in_document_order(self) -> None:
continue
href = element.get("href") or element.get("data-href")
if href:
css_content = utils.fetch_external_css(href)
css_content = resources.fetch_external_css(href, self._resource_policy())
if css_content:
self.css_parser.parse_css(css_content, selective=True)
element.decompose()
Expand Down
Loading
Loading