Skip to content

Commit a0e89f9

Browse files
✨ add extensive URL checking
1 parent 245e360 commit a0e89f9

6 files changed

Lines changed: 94 additions & 4 deletions

File tree

‎mindee/input/url_input_source.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from mindee.error.mindee_error import MindeeSourceError
1111
from mindee.input.bytes_input import BytesInput
1212
from mindee.logger import logger
13+
from mindee.mindee_http.response_validation import validate_url_for_source
1314
from mindee.parsing.common.string_dict import StringDict
1415

1516

@@ -25,8 +26,7 @@ def __init__(self, url: str) -> None:
2526
2627
:param url: URL to send, must be HTTPS.
2728
"""
28-
if not url.lower().startswith("https"):
29-
raise MindeeSourceError("URL must be HTTPS")
29+
validate_url_for_source(url)
3030

3131
logger.debug("URL input: %s", url)
3232

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
class CancellationToken:
2+
"""Custom cancellation token that can be used to cancel a polling request."""
3+
4+
is_canceled: bool
5+
"""A cancellation token that can be used to cancel a request."""
6+
7+
def __init__(
8+
self,
9+
is_canceled: bool = False,
10+
):
11+
self.is_canceled = is_canceled
12+
13+
def cancel(self):
14+
"""Cancel the request."""
15+
self.is_canceled = True

‎mindee/mindee_http/response_validation.py‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,79 @@
1+
import ipaddress
12
import json
3+
from urllib.parse import urlparse
24

35
import httpx
46

7+
from mindee.error.mindee_error import MindeeSourceError
58
from mindee.parsing.common.string_dict import StringDict
69

10+
_CGNAT_BLOCK = ipaddress.IPv4Network("100.64.0.0/10")
11+
_IPV6_UNIQUE_LOCAL = ipaddress.IPv6Network("fc00::/7")
12+
13+
14+
def validate_url_for_source(url: str) -> None:
15+
"""
16+
Validates that a URL is safe to send to the Mindee server.
17+
18+
Rejects any URL that could be used for Server-Side Request Forgery (SSRF):
19+
20+
- non-HTTPS schemes,
21+
- embedded userinfo (e.g. ``https://user:pass@host``),
22+
- loopback hostnames (``localhost``, ``*.localhost``),
23+
- literal IP addresses that are loopback, link-local, private (RFC 1918),
24+
any-local (``0.0.0.0``), multicast, IPv6 unique-local (``fc00::/7``),
25+
or carrier-grade NAT (``100.64.0.0/10``).
26+
27+
Note: DNS resolution is not performed. A hostname that resolves to a
28+
private IP will not be caught here.
29+
30+
:param url: The URL string to validate.
31+
:raises MindeeSourceError: If the URL fails any security check.
32+
"""
33+
try:
34+
parsed = urlparse(url)
35+
except Exception as exc:
36+
raise MindeeSourceError("Invalid URL") from exc
37+
38+
if parsed.scheme.lower() != "https":
39+
raise MindeeSourceError("URL must be HTTPS")
40+
41+
if parsed.username or parsed.password:
42+
raise MindeeSourceError("Source URLs must not embed user credentials")
43+
44+
host = parsed.hostname
45+
if not host:
46+
raise MindeeSourceError("Source URL is missing a host")
47+
48+
lower_host = host.lower()
49+
if (
50+
lower_host == "localhost"
51+
or lower_host.endswith(".localhost")
52+
or lower_host == "ip6-localhost"
53+
or lower_host == "ip6-loopback"
54+
):
55+
raise MindeeSourceError(f"Loopback hostnames are not allowed: {host}")
56+
57+
try:
58+
addr = ipaddress.ip_address(lower_host)
59+
except ValueError:
60+
return
61+
62+
if (
63+
addr.is_loopback
64+
or addr.is_link_local
65+
or addr.is_private
66+
or addr.is_unspecified
67+
or addr.is_multicast
68+
):
69+
raise MindeeSourceError(f"URL host resolves to a disallowed address: {addr}")
70+
71+
if isinstance(addr, ipaddress.IPv4Address) and addr in _CGNAT_BLOCK:
72+
raise MindeeSourceError(f"URL host resolves to a disallowed address: {addr}")
73+
74+
if isinstance(addr, ipaddress.IPv6Address) and addr in _IPV6_UNIQUE_LOCAL:
75+
raise MindeeSourceError(f"URL host resolves to a disallowed address: {addr}")
76+
777

878
def is_valid_sync_response(response: httpx.Response) -> bool:
979
"""

‎mindee/v2/client.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from mindee.input import URLInputSource
1010
from mindee.input.local_input_source import LocalInputSource
1111
from mindee.logger import logger
12+
from mindee.mindee_http.cancellation_token import CancellationToken
1213
from mindee.parsing.common.common_response import CommonStatus
1314
from mindee.v2.client_options.base_parameters import BaseParameters
1415
from mindee.v2.mindee_http.mindee_api_v2 import MindeeAPIV2
@@ -104,13 +105,16 @@ def enqueue_and_get_result(
104105
response_type: type[TypeBaseResponse],
105106
input_source: LocalInputSource | URLInputSource,
106107
params: BaseParameters,
108+
cancellation_token: CancellationToken | None = None,
107109
) -> TypeBaseResponse:
108110
"""
109111
Enqueues to an asynchronous endpoint and automatically polls for a response.
110112
111113
:param input_source: The document/source file to use. Can be local or remote.
112114
:param params: Parameters to set when sending a file.
113115
:param response_type: The product class to use for the response object.
116+
:param cancellation_token: A cancellation token that can be used to cancel the
117+
request.
114118
115119
:return: A valid inference response.
116120
"""
@@ -128,6 +132,8 @@ def enqueue_and_get_result(
128132
sleep(params.polling_options.initial_delay_sec)
129133
try_counter = 0
130134
while try_counter < params.polling_options.max_retries:
135+
if cancellation_token and cancellation_token.is_canceled:
136+
raise MindeeError("Request canceled through cancellation token.")
131137
job_response = self.get_job(enqueue_response.job.id)
132138
assert isinstance(job_response, JobResponse)
133139
if job_response.job.status == CommonStatus.FAILED.value:

‎tests/data‎

‎tests/v2/file_operations/test_split_operation_integration.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ def check_findoc_return(findoc_response: ExtractionResponse):
2727
@pytest.mark.pypdfium2
2828
@pytest.mark.integration
2929
def test_pdf_should_extract_splits():
30-
3130
client = Client()
3231
split_input = PathInput(V2_PRODUCT_DATA_DIR / "split" / "default_sample.pdf")
3332
response = client.enqueue_and_get_result(

0 commit comments

Comments
 (0)