diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml
index 1927456..c192e99 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.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..4ec540e 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.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 @@


-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 `