Skip to content

docling-core rejects Azure Blob SAS URLs when the storage account resolves through a Private Endpoint #600

Description

@JViktoRArtola

Summary

After updating to docling-core==2.74.1 (via docling==2.89.0), Docling rejects Azure Blob SAS URLs before attempting the download when the Blob hostname resolves to a private IP through Azure Private Link.

This appears to come from the URL safety validation in docling_core.utils.file.resolve_source_to_stream(), which rejects any URL whose hostname resolves to a non-global IP.

Environment

  • docling==2.89.0
  • docling-core==2.74.1
  • Python 3.13
  • Windows 11 locally
  • Azure Blob Storage SAS URL
  • Storage account uses Azure Private Endpoint / privatelink.blob.core.windows.net

Actual behavior

Calling DocumentConverter.convert() with a valid Azure Blob SAS URL fails with:

ValueError: URL is not allowed: https://<storage-account>.blob.core.windows.net/<container>/<document>.pdf?se=<expiry>&sp=r&sv=<api-version>&sr=b&sig=<redacted>

Expected behavior

Docling should allow the URL, or provide a supported opt-out / allowlist mechanism for trusted private hosts used behind Azure Private Endpoints.

The SAS URL is valid and intentionally points to a private Azure Storage endpoint. From the application point of view, this is a legitimate remote document source.

Minimal reproduction

from docling.document_converter import DocumentConverter

converter = DocumentConverter()
converter.convert(
    "https://<storage-account>.blob.core.windows.net/<container>/<document>.pdf?se=<expiry>&sp=r&sv=<api-version>&sr=b&sig=<redacted>"
)

This fails inside docling_core.utils.file.resolve_source_to_stream() before any fetch is attempted.

DNS evidence

In our environment, the Azure Blob host resolves like this:

<storage-account>.blob.core.windows.net             CNAME
<storage-account>.privatelink.blob.core.windows.net A 10.x.x.x

And a minimal check shows the resolved IP is private:

import socket
import ipaddress
from urllib.parse import urlparse

url = "https://<storage-account>.blob.core.windows.net/<container>/<document>.pdf?se=<expiry>&sp=r&sv=<api-version>&sr=b&sig=<redacted>"
host = urlparse(url).hostname
ip = socket.gethostbyname(host)

print(host)  # <storage-account>.blob.core.windows.net
print(ip)    # 10.x.x.x
print(ipaddress.ip_address(ip).is_private)  # True

Relevant code path

The rejection appears to come from this logic:

http_url = TypeAdapter(AnyHttpUrl).validate_python(source)
url_str = str(http_url)

if not _is_safe_url(url_str):
    raise ValueError(f"URL is not allowed: {url_str}")

_is_safe_url() resolves the hostname and requires the resulting IP to be globally routable, which blocks Azure Storage accounts accessed through Private Link.

Why this is a problem

This makes Docling incompatible with a common enterprise Azure setup where Blob Storage is intentionally reachable only through private networking.

The URL is not untrusted in our case; it is generated by our application and protected with a SAS token. The current behavior blocks legitimate private infrastructure along with SSRF-style targets.

Questions

  1. Is this strict private-IP rejection intentional for all remote sources?
  2. Would you accept a configuration-based override, allowlist, or callback for trusted hosts?

Full stack trace

Traceback (most recent call last):
  File ".../app/messaging/consumer.py", line 159, in _process_message
    await self.message_handler(message_value)
  File ".../entrypoints/conversion_worker/lifespan.py", line 24, in _handle_message
    await asyncio.get_running_loop().run_in_executor(None, process_file_markdown, event.data.model_dump())
  File ".../Lib/concurrent/futures/thread.py", line 59, in run
    result = self.fn(*self.args, **self.kwargs)
  File ".../.venv/Lib/site-packages/sentry_sdk/integrations/threading.py", line 179, in wrapped_fn
    return fn(*args, **kwargs)
  File ".../app/services/conversion.py", line 99, in process_file_markdown
    markdown_content = convert_to_markdown_isolated(file_path=url, fast=fast)
  File ".../entrypoints/conversion_worker/docling_isolated.py", line 359, in convert_to_markdown_isolated
    return _get_docling_runtime_manager().convert_to_markdown(file_path=file_path, fast=fast)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File ".../entrypoints/conversion_worker/docling_isolated.py", line 303, in convert_to_markdown
    markdown_content = client.convert_to_markdown(file_path=file_path, fast=fast)
  File ".../app/clients/docling.py", line 396, in convert_to_markdown
    result = converter.convert(file_path)
  File ".../.venv/Lib/site-packages/pydantic/_internal/_validate_call.py", line 40, in wrapper_function
    return wrapper(*args, **kwargs)
  File ".../.venv/Lib/site-packages/pydantic/_internal/_validate_call.py", line 137, in __call__
    res = self.__pydantic_validator__.validate_python(pydantic_core.ArgsKwargs(args, kwargs))
  File ".../.venv/Lib/site-packages/docling/document_converter.py", line 388, in convert
    return next(all_res)
  File ".../.venv/Lib/site-packages/docling/document_converter.py", line 447, in convert_all
    for conv_res in conv_res_iter:
                    ^^^^^^^^^^^^^
  File ".../.venv/Lib/site-packages/docling/document_converter.py", line 542, in _convert
    for input_batch in chunkify(
                       ~~~~~~~~^
        conv_input.docs(self.format_to_options),
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        settings.perf.doc_batch_size,
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    ):
    ^
  File ".../.venv/Lib/site-packages/docling/utils/utils.py", line 15, in chunkify
    for first in iterator:
                 ^^^^^^^^
  File ".../.venv/Lib/site-packages/docling/datamodel/document.py", line 452, in docs
    resolve_source_to_stream(item, self.headers)
  File ".../.venv/Lib/site-packages/docling_core/utils/file.py", line 119, in resolve_source_to_stream
    raise ValueError(f"URL is not allowed: {url_str}")
ValueError: URL is not allowed: https://<storage-account>.blob.core.windows.net/<container>/<document>.pdf?se=<expiry>&sp=r&sv=<api-version>&sr=b&sig=<redacted>

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions