From a0e89f93fd50655701c12ce1eff703d93eb59ae7 Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:42:48 +0200 Subject: [PATCH 1/4] :sparkles: add extensive URL checking --- mindee/input/url_input_source.py | 4 +- mindee/mindee_http/cancellation_token.py | 15 ++++ mindee/mindee_http/response_validation.py | 70 +++++++++++++++++++ mindee/v2/client.py | 6 ++ tests/data | 2 +- .../test_split_operation_integration.py | 1 - 6 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 mindee/mindee_http/cancellation_token.py diff --git a/mindee/input/url_input_source.py b/mindee/input/url_input_source.py index 972cb75b..d975a76e 100644 --- a/mindee/input/url_input_source.py +++ b/mindee/input/url_input_source.py @@ -10,6 +10,7 @@ from mindee.error.mindee_error import MindeeSourceError from mindee.input.bytes_input import BytesInput from mindee.logger import logger +from mindee.mindee_http.response_validation import validate_url_for_source from mindee.parsing.common.string_dict import StringDict @@ -25,8 +26,7 @@ def __init__(self, url: str) -> None: :param url: URL to send, must be HTTPS. """ - if not url.lower().startswith("https"): - raise MindeeSourceError("URL must be HTTPS") + validate_url_for_source(url) logger.debug("URL input: %s", url) diff --git a/mindee/mindee_http/cancellation_token.py b/mindee/mindee_http/cancellation_token.py new file mode 100644 index 00000000..4cb65034 --- /dev/null +++ b/mindee/mindee_http/cancellation_token.py @@ -0,0 +1,15 @@ +class CancellationToken: + """Custom cancellation token that can be used to cancel a polling request.""" + + is_canceled: bool + """A cancellation token that can be used to cancel a request.""" + + def __init__( + self, + is_canceled: bool = False, + ): + self.is_canceled = is_canceled + + def cancel(self): + """Cancel the request.""" + self.is_canceled = True diff --git a/mindee/mindee_http/response_validation.py b/mindee/mindee_http/response_validation.py index bcf0c771..20061001 100644 --- a/mindee/mindee_http/response_validation.py +++ b/mindee/mindee_http/response_validation.py @@ -1,9 +1,79 @@ +import ipaddress import json +from urllib.parse import urlparse import httpx +from mindee.error.mindee_error import MindeeSourceError from mindee.parsing.common.string_dict import StringDict +_CGNAT_BLOCK = ipaddress.IPv4Network("100.64.0.0/10") +_IPV6_UNIQUE_LOCAL = ipaddress.IPv6Network("fc00::/7") + + +def validate_url_for_source(url: str) -> None: + """ + Validates that a URL is safe to send to the Mindee server. + + Rejects any URL that could be used for Server-Side Request Forgery (SSRF): + + - non-HTTPS schemes, + - embedded userinfo (e.g. ``https://user:pass@host``), + - loopback hostnames (``localhost``, ``*.localhost``), + - literal IP addresses that are loopback, link-local, private (RFC 1918), + any-local (``0.0.0.0``), multicast, IPv6 unique-local (``fc00::/7``), + or carrier-grade NAT (``100.64.0.0/10``). + + Note: DNS resolution is not performed. A hostname that resolves to a + private IP will not be caught here. + + :param url: The URL string to validate. + :raises MindeeSourceError: If the URL fails any security check. + """ + try: + parsed = urlparse(url) + except Exception as exc: + raise MindeeSourceError("Invalid URL") from exc + + if parsed.scheme.lower() != "https": + raise MindeeSourceError("URL must be HTTPS") + + if parsed.username or parsed.password: + raise MindeeSourceError("Source URLs must not embed user credentials") + + host = parsed.hostname + if not host: + raise MindeeSourceError("Source URL is missing a host") + + lower_host = host.lower() + if ( + lower_host == "localhost" + or lower_host.endswith(".localhost") + or lower_host == "ip6-localhost" + or lower_host == "ip6-loopback" + ): + raise MindeeSourceError(f"Loopback hostnames are not allowed: {host}") + + try: + addr = ipaddress.ip_address(lower_host) + except ValueError: + return + + if ( + addr.is_loopback + or addr.is_link_local + or addr.is_private + or addr.is_unspecified + or addr.is_multicast + ): + raise MindeeSourceError(f"URL host resolves to a disallowed address: {addr}") + + if isinstance(addr, ipaddress.IPv4Address) and addr in _CGNAT_BLOCK: + raise MindeeSourceError(f"URL host resolves to a disallowed address: {addr}") + + if isinstance(addr, ipaddress.IPv6Address) and addr in _IPV6_UNIQUE_LOCAL: + raise MindeeSourceError(f"URL host resolves to a disallowed address: {addr}") + def is_valid_sync_response(response: httpx.Response) -> bool: """ diff --git a/mindee/v2/client.py b/mindee/v2/client.py index 1d54c9ca..69735e92 100644 --- a/mindee/v2/client.py +++ b/mindee/v2/client.py @@ -9,6 +9,7 @@ from mindee.input import URLInputSource from mindee.input.local_input_source import LocalInputSource from mindee.logger import logger +from mindee.mindee_http.cancellation_token import CancellationToken from mindee.parsing.common.common_response import CommonStatus from mindee.v2.client_options.base_parameters import BaseParameters from mindee.v2.mindee_http.mindee_api_v2 import MindeeAPIV2 @@ -104,6 +105,7 @@ def enqueue_and_get_result( response_type: type[TypeBaseResponse], input_source: LocalInputSource | URLInputSource, params: BaseParameters, + cancellation_token: CancellationToken | None = None, ) -> TypeBaseResponse: """ Enqueues to an asynchronous endpoint and automatically polls for a response. @@ -111,6 +113,8 @@ def enqueue_and_get_result( :param input_source: The document/source file to use. Can be local or remote. :param params: Parameters to set when sending a file. :param response_type: The product class to use for the response object. + :param cancellation_token: A cancellation token that can be used to cancel the + request. :return: A valid inference response. """ @@ -128,6 +132,8 @@ def enqueue_and_get_result( sleep(params.polling_options.initial_delay_sec) try_counter = 0 while try_counter < params.polling_options.max_retries: + if cancellation_token and cancellation_token.is_canceled: + raise MindeeError("Request canceled through cancellation token.") job_response = self.get_job(enqueue_response.job.id) assert isinstance(job_response, JobResponse) if job_response.job.status == CommonStatus.FAILED.value: diff --git a/tests/data b/tests/data index 2d7fcf8f..e41ab97c 160000 --- a/tests/data +++ b/tests/data @@ -1 +1 @@ -Subproject commit 2d7fcf8f591f6d7f40e39862965325e6a8a21874 +Subproject commit e41ab97c2833f15ea5c4edf221c2d197d212f632 diff --git a/tests/v2/file_operations/test_split_operation_integration.py b/tests/v2/file_operations/test_split_operation_integration.py index b7d8bc07..9e3266e3 100644 --- a/tests/v2/file_operations/test_split_operation_integration.py +++ b/tests/v2/file_operations/test_split_operation_integration.py @@ -27,7 +27,6 @@ def check_findoc_return(findoc_response: ExtractionResponse): @pytest.mark.pypdfium2 @pytest.mark.integration def test_pdf_should_extract_splits(): - client = Client() split_input = PathInput(V2_PRODUCT_DATA_DIR / "split" / "default_sample.pdf") response = client.enqueue_and_get_result( From e4bac08864caa5980bf191038ba6ebaec71c2d84 Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:55:52 +0200 Subject: [PATCH 2/4] :sparkles: add simple fields typed accessors --- .../parsing/inference/field/simple_field.py | 21 +++++ tests/v2/parsing/test_simple_field.py | 80 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 tests/v2/parsing/test_simple_field.py diff --git a/mindee/v2/parsing/inference/field/simple_field.py b/mindee/v2/parsing/inference/field/simple_field.py index 574c2289..65de5a51 100644 --- a/mindee/v2/parsing/inference/field/simple_field.py +++ b/mindee/v2/parsing/inference/field/simple_field.py @@ -15,6 +15,27 @@ def __init__(self, raw_response: StringDict, indent_level: int = 0): else: self.value = value + @property + def string_value(self) -> str | None: + """Retrieves a string field value as a string.""" + if self.value is not None and not isinstance(self.value, str): + raise ValueError("Value is not a string") + return self.value + + @property + def number_value(self) -> float | None: + """Retrieves a number field value as a float.""" + if self.value is not None and not isinstance(self.value, float): + raise ValueError("Value is not a number") + return self.value + + @property + def boolean_value(self) -> bool | None: + """Retrieves a boolean field value as a boolean.""" + if self.value is not None and not isinstance(self.value, bool): + raise ValueError("Value is not a boolean") + return self.value + def __str__(self) -> str: if isinstance(self.value, bool): return "True" if self.value else "False" diff --git a/tests/v2/parsing/test_simple_field.py b/tests/v2/parsing/test_simple_field.py new file mode 100644 index 00000000..99fe3d80 --- /dev/null +++ b/tests/v2/parsing/test_simple_field.py @@ -0,0 +1,80 @@ +import pytest + +from mindee.v2.parsing.inference.field.simple_field import SimpleField + + +def _make_field(value) -> SimpleField: + return SimpleField({"value": value} if value is not None else {}) + + +@pytest.mark.v2 +class TestSimpleFieldStringValue: + def test_returns_string_when_value_is_string(self): + field = _make_field("hello") + assert field.string_value == "hello" + + def test_returns_none_when_value_is_none(self): + field = _make_field(None) + assert field.string_value is None + + def test_raises_when_value_is_number(self): + field = _make_field(3.14) + with pytest.raises(ValueError, match="Value is not a string"): + _ = field.string_value + + def test_raises_when_value_is_boolean(self): + field = _make_field(True) + with pytest.raises(ValueError, match="Value is not a string"): + _ = field.string_value + + +@pytest.mark.v2 +class TestSimpleFieldNumberValue: + def test_returns_float_when_value_is_float(self): + field = _make_field(3.14) + assert field.number_value == 3.14 + + def test_returns_float_when_value_is_int(self): + # Integers are coerced to float in the constructor + field = SimpleField({"value": 42}) + assert field.number_value == 42.0 + assert isinstance(field.number_value, float) + + def test_returns_none_when_value_is_none(self): + field = _make_field(None) + assert field.number_value is None + + def test_raises_when_value_is_string(self): + field = _make_field("42") + with pytest.raises(ValueError, match="Value is not a number"): + _ = field.number_value + + def test_raises_when_value_is_boolean(self): + field = _make_field(True) + with pytest.raises(ValueError, match="Value is not a number"): + _ = field.number_value + + +@pytest.mark.v2 +class TestSimpleFieldBooleanValue: + def test_returns_true_when_value_is_true(self): + field = _make_field(True) + assert field.boolean_value is True + + def test_returns_false_when_value_is_false(self): + field = _make_field(False) + assert field.boolean_value is False + + def test_returns_none_when_value_is_none(self): + field = _make_field(None) + assert field.boolean_value is None + + def test_raises_when_value_is_string(self): + field = _make_field("true") + with pytest.raises(ValueError, match="Value is not a boolean"): + _ = field.boolean_value + + def test_raises_when_value_is_number(self): + field = _make_field(1.0) + with pytest.raises(ValueError, match="Value is not a boolean"): + _ = field.boolean_value From 62aac9ac48e7b2aafc44c18ab89e3c25b39ec373 Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:59:30 +0200 Subject: [PATCH 3/4] :arrow_up: bump dependencies --- .github/workflows/_publish-code.yml | 4 ++-- .github/workflows/_publish-docs.yml | 4 ++-- .github/workflows/_smoke-test.yml | 4 ++-- .github/workflows/_static-analysis.yml | 6 +++--- .github/workflows/_test-cli.yml | 4 ++-- .github/workflows/_test-integrations.yml | 8 ++++---- .github/workflows/_test-regressions.yml | 4 ++-- .github/workflows/_test-units.yml | 8 ++++---- .github/workflows/_workflow_lint.yml | 2 +- .pre-commit-config.yaml | 6 +++--- pyproject.toml | 2 +- 11 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/_publish-code.yml b/.github/workflows/_publish-code.yml index 23ae423e..cf1da514 100644 --- a/.github/workflows/_publish-code.yml +++ b/.github/workflows/_publish-code.yml @@ -22,7 +22,7 @@ jobs: env: PYTHON_VERSION: "3.12" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python ${{ env.PYTHON_VERSION }} uses: actions/setup-python@v6 @@ -30,7 +30,7 @@ jobs: python-version: ${{ env.PYTHON_VERSION }} - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-build-${{ hashFiles('pyproject.toml') }} diff --git a/.github/workflows/_publish-docs.yml b/.github/workflows/_publish-docs.yml index 084f03e2..3379bc7a 100644 --- a/.github/workflows/_publish-docs.yml +++ b/.github/workflows/_publish-docs.yml @@ -15,7 +15,7 @@ jobs: matrix: python-version: ["3.12"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 @@ -23,7 +23,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-docs-${{ hashFiles('pyproject.toml') }} diff --git a/.github/workflows/_smoke-test.yml b/.github/workflows/_smoke-test.yml index 24fe7e1f..23183baa 100644 --- a/.github/workflows/_smoke-test.yml +++ b/.github/workflows/_smoke-test.yml @@ -25,7 +25,7 @@ jobs: - "3.14" runs-on: "ubuntu-22.04" steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive @@ -35,7 +35,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-samples-${{ hashFiles('**/pyproject.toml') }} diff --git a/.github/workflows/_static-analysis.yml b/.github/workflows/_static-analysis.yml index 2131052b..f91fc1ca 100644 --- a/.github/workflows/_static-analysis.yml +++ b/.github/workflows/_static-analysis.yml @@ -14,7 +14,7 @@ jobs: matrix: python-version: ["3.12"] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v6 @@ -22,7 +22,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-lint-${{ hashFiles('pyproject.toml') }} @@ -44,7 +44,7 @@ jobs: pip install -e '.[lint]' - name: Cache pre-commit - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pre-commit key: ${{ runner.os }}-prec-${{ hashFiles('.pre-commit-config.yaml') }} diff --git a/.github/workflows/_test-cli.yml b/.github/workflows/_test-cli.yml index c9d501e4..699796f3 100644 --- a/.github/workflows/_test-cli.yml +++ b/.github/workflows/_test-cli.yml @@ -31,7 +31,7 @@ jobs: - "3.14" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive @@ -41,7 +41,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-cli-${{ hashFiles('**/pyproject.toml') }} diff --git a/.github/workflows/_test-integrations.yml b/.github/workflows/_test-integrations.yml index 3d1b8c19..b26d068a 100644 --- a/.github/workflows/_test-integrations.yml +++ b/.github/workflows/_test-integrations.yml @@ -24,7 +24,7 @@ jobs: - "3.14" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive @@ -34,7 +34,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-test-${{ hashFiles('pyproject.toml') }} @@ -80,7 +80,7 @@ jobs: - "3.10" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive @@ -90,7 +90,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-test-${{ hashFiles('pyproject.toml') }} diff --git a/.github/workflows/_test-regressions.yml b/.github/workflows/_test-regressions.yml index 7c10cf07..3b7eefd0 100644 --- a/.github/workflows/_test-regressions.yml +++ b/.github/workflows/_test-regressions.yml @@ -21,7 +21,7 @@ jobs: - "3.14" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive @@ -31,7 +31,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-test-${{ hashFiles('pyproject.toml') }} diff --git a/.github/workflows/_test-units.yml b/.github/workflows/_test-units.yml index 071051fc..a0c79eba 100644 --- a/.github/workflows/_test-units.yml +++ b/.github/workflows/_test-units.yml @@ -23,7 +23,7 @@ jobs: - "3.14" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive @@ -33,7 +33,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-test-${{ hashFiles('pyproject.toml') }} @@ -65,7 +65,7 @@ jobs: - "3.14" runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: submodules: recursive @@ -75,7 +75,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ~/.cache/pip key: ${{ runner.os }}-test-${{ hashFiles('pyproject.toml') }} diff --git a/.github/workflows/_workflow_lint.yml b/.github/workflows/_workflow_lint.yml index 8eb21904..1c5e2720 100644 --- a/.github/workflows/_workflow_lint.yml +++ b/.github/workflows/_workflow_lint.yml @@ -10,7 +10,7 @@ jobs: actionlint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/cache@v6v7 - name: Download actionlint id: get_actionlint run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 2867f26e..59e7e6be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,13 +1,13 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.15.18 + rev: v0.15.21 hooks: - id: ruff-check args: [ --fix, --exit-non-zero-on-fix] - id: ruff-format - repo: https://github.com/betterleaks/betterleaks - rev: v1.5.0 + rev: v1.6.1 hooks: - id: betterleaks @@ -31,7 +31,7 @@ repos: stages: [pre-push] - repo: https://github.com/pre-commit/mirrors-mypy - rev: v2.1.0 + rev: v2.2.0 hooks: - id: mypy args: [] diff --git a/pyproject.toml b/pyproject.toml index e14520ac..faa5f5a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ lint = [ ] test = [ "toml~=0.10.2", - "pytest~=9.0.3,<9.2.0", + "pytest>=9.0.3,<9.2.0", "pytest-cov~=7.1.0", "respx~=0.23.1" ] From 540d1d88bd3e5501861c879d3202313afc62bd1b Mon Sep 17 00:00:00 2001 From: sebastianMindee <130448732+sebastianMindee@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:10:01 +0200 Subject: [PATCH 4/4] :bug: fix issues with tests + add better tests for URL input source --- .github/workflows/_workflow_lint.yml | 2 +- mindee/v2/client.py | 2 + mindee/v2/mindee_http/mindee_api_v2.py | 3 +- tests/v1/input/test_url_validation.py | 116 ++++++++++++++++++ .../test_crop_operation_integration.py | 4 +- 5 files changed, 123 insertions(+), 4 deletions(-) create mode 100644 tests/v1/input/test_url_validation.py diff --git a/.github/workflows/_workflow_lint.yml b/.github/workflows/_workflow_lint.yml index 1c5e2720..0e09112a 100644 --- a/.github/workflows/_workflow_lint.yml +++ b/.github/workflows/_workflow_lint.yml @@ -10,7 +10,7 @@ jobs: actionlint: runs-on: ubuntu-latest steps: - - uses: actions/cache@v6v7 + - uses: actions/checkout@v7 - name: Download actionlint id: get_actionlint run: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) diff --git a/mindee/v2/client.py b/mindee/v2/client.py index 69735e92..bc50d6e5 100644 --- a/mindee/v2/client.py +++ b/mindee/v2/client.py @@ -129,6 +129,8 @@ def enqueue_and_get_result( logger.debug( "Successfully enqueued document with job ID: %s", enqueue_response.job.id ) + if cancellation_token and cancellation_token.is_canceled: + raise MindeeError("Request canceled through cancellation token.") sleep(params.polling_options.initial_delay_sec) try_counter = 0 while try_counter < params.polling_options.max_retries: diff --git a/mindee/v2/mindee_http/mindee_api_v2.py b/mindee/v2/mindee_http/mindee_api_v2.py index f25e15c8..f58fea03 100644 --- a/mindee/v2/mindee_http/mindee_api_v2.py +++ b/mindee/v2/mindee_http/mindee_api_v2.py @@ -1,3 +1,4 @@ +import json import os from collections.abc import Callable from typing import TypeVar @@ -297,7 +298,7 @@ def get_models(self, name: str | None, model_type: str | None): def _response_json(response: httpx.Response) -> StringDict: try: return response.json() - except httpx.DecodingError as e: + except (httpx.DecodingError, json.JSONDecodeError) as e: raise MindeeHTTPUnknownErrorV2( f"HTTP {response.status_code} response is not valid JSON: " f"{response.text}" diff --git a/tests/v1/input/test_url_validation.py b/tests/v1/input/test_url_validation.py new file mode 100644 index 00000000..fa2c8f03 --- /dev/null +++ b/tests/v1/input/test_url_validation.py @@ -0,0 +1,116 @@ +import pytest + +from mindee.error.mindee_error import MindeeSourceError +from mindee.mindee_http.response_validation import validate_url_for_source + + +@pytest.mark.v1 +class TestValidateUrlScheme: + def test_rejects_http(self): + with pytest.raises(MindeeSourceError, match="HTTPS"): + validate_url_for_source("http://example.com/file.pdf") + + def test_rejects_ftp(self): + with pytest.raises(MindeeSourceError, match="HTTPS"): + validate_url_for_source("ftp://example.com/file.pdf") + + def test_accepts_https(self): + validate_url_for_source("https://example.com/file.pdf") + + +@pytest.mark.v1 +class TestValidateUrlUserinfo: + def test_rejects_username_and_password(self): + with pytest.raises(MindeeSourceError, match="credentials"): + validate_url_for_source("https://user:pass@example.com/file.pdf") + + def test_rejects_username_only(self): + with pytest.raises(MindeeSourceError, match="credentials"): + validate_url_for_source("https://user@example.com/file.pdf") + + +@pytest.mark.v1 +class TestValidateUrlLoopbackHostnames: + def test_rejects_localhost(self): + with pytest.raises(MindeeSourceError, match="Loopback"): + validate_url_for_source("https://localhost/file.pdf") + + def test_rejects_localhost_subdomain(self): + with pytest.raises(MindeeSourceError, match="Loopback"): + validate_url_for_source("https://myapp.localhost/file.pdf") + + def test_rejects_ip6_localhost(self): + with pytest.raises(MindeeSourceError, match="Loopback"): + validate_url_for_source("https://ip6-localhost/file.pdf") + + def test_rejects_ip6_loopback(self): + with pytest.raises(MindeeSourceError, match="Loopback"): + validate_url_for_source("https://ip6-loopback/file.pdf") + + +@pytest.mark.v1 +class TestValidateUrlLoopbackIPs: + def test_rejects_ipv4_loopback(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://127.0.0.1/file.pdf") + + def test_rejects_ipv4_loopback_other(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://127.0.0.2/file.pdf") + + def test_rejects_ipv6_loopback(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://[::1]/file.pdf") + + +@pytest.mark.v1 +class TestValidateUrlPrivateIPs: + def test_rejects_rfc1918_10_block(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://10.0.0.1/file.pdf") + + def test_rejects_rfc1918_172_block(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://172.16.0.1/file.pdf") + + def test_rejects_rfc1918_192_block(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://192.168.1.1/file.pdf") + + def test_rejects_link_local(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://169.254.0.1/file.pdf") + + def test_rejects_unspecified(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://0.0.0.0/file.pdf") + + def test_rejects_multicast(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://224.0.0.1/file.pdf") + + +@pytest.mark.v1 +class TestValidateUrlCgnat: + def test_rejects_cgnat_start(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://100.64.0.1/file.pdf") + + def test_rejects_cgnat_end(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://100.127.255.255/file.pdf") + + def test_accepts_just_outside_cgnat(self): + # 100.128.0.1 is outside 100.64.0.0/10 + validate_url_for_source("https://100.128.0.1/file.pdf") + + +@pytest.mark.v1 +class TestValidateUrlIpv6UniqueLocal: + def test_rejects_ipv6_ula_fc(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://[fc00::1]/file.pdf") + + def test_rejects_ipv6_ula_fd(self): + with pytest.raises(MindeeSourceError, match="disallowed"): + validate_url_for_source("https://[fd00::1]/file.pdf") diff --git a/tests/v2/file_operations/test_crop_operation_integration.py b/tests/v2/file_operations/test_crop_operation_integration.py index d2e047c5..1ea8623b 100644 --- a/tests/v2/file_operations/test_crop_operation_integration.py +++ b/tests/v2/file_operations/test_crop_operation_integration.py @@ -58,8 +58,8 @@ def test_image_should_extract_crops(): extracted_crops.save_all_to_disk(OUTPUT_DIR) crop0_size = os.path.getsize(OUTPUT_DIR / output_files[0]) crop1_size = os.path.getsize(OUTPUT_DIR / output_files[1]) - assert 180000 <= crop0_size <= 199685 - assert 190000 <= crop1_size <= 199433 + assert 180000 <= crop0_size <= 230000 + assert 190000 <= crop1_size <= 230000 @pytest.fixture(scope="module", autouse=True)